@telorun/http-server 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/http-api-controller.d.ts +3 -7
- package/dist/http-api-controller.js +173 -67
- package/dist/http-server-controller.js +76 -3
- package/package.json +5 -4
- package/src/http-api-controller.ts +209 -86
- package/src/http-server-controller.ts +80 -3
- package/dist/openapi.js +0 -152
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Automated release.
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @telorun/sdk@0.2.5
|
|
10
|
+
|
|
11
|
+
## 0.1.3
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Automated release.
|
|
16
|
+
- Updated dependencies
|
|
17
|
+
- @telorun/sdk@0.2.4
|
|
18
|
+
|
|
3
19
|
## 0.1.2
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
1
|
import { Static } from "@sinclair/typebox";
|
|
2
|
+
import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
3
3
|
import { FastifyInstance } from "fastify";
|
|
4
4
|
declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
5
5
|
routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
@@ -13,11 +13,7 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
|
13
13
|
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
14
14
|
}>>;
|
|
15
15
|
}>;
|
|
16
|
-
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").
|
|
17
|
-
kind: import("@sinclair/typebox").TString;
|
|
18
|
-
name: import("@sinclair/typebox").TString;
|
|
19
|
-
inputs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
20
|
-
}>>;
|
|
16
|
+
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
21
17
|
response: import("@sinclair/typebox").TObject<{
|
|
22
18
|
status: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TString]>;
|
|
23
19
|
statuses: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
@@ -43,5 +39,5 @@ export declare class HttpServerApi implements ResourceInstance {
|
|
|
43
39
|
private registerRoutes;
|
|
44
40
|
private registerRoute;
|
|
45
41
|
}
|
|
46
|
-
export declare function create(resource:
|
|
42
|
+
export declare function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi>;
|
|
47
43
|
export {};
|
|
@@ -10,11 +10,7 @@ const HttpApiRouteManifest = Type.Object({
|
|
|
10
10
|
headers: Type.Optional(Type.Any()),
|
|
11
11
|
})),
|
|
12
12
|
}),
|
|
13
|
-
handler: Type.Optional(Type.
|
|
14
|
-
kind: Type.String(),
|
|
15
|
-
name: Type.String(),
|
|
16
|
-
inputs: Type.Optional(Type.Any()),
|
|
17
|
-
})),
|
|
13
|
+
handler: Type.Optional(Type.Any()), // Any handler shape is allowed - will be processed in create()
|
|
18
14
|
response: Type.Object({
|
|
19
15
|
status: Type.Union([Type.Number({ minimum: 100, maximum: 599 }), Type.String()]),
|
|
20
16
|
statuses: Type.Record(Type.String(), Type.Object({
|
|
@@ -58,7 +54,10 @@ export class HttpServerApi {
|
|
|
58
54
|
}
|
|
59
55
|
registerRoute(app, route) {
|
|
60
56
|
const handler = route.handler ? resolveHandlerName(route.handler) : null;
|
|
61
|
-
const
|
|
57
|
+
const translatedPath = translateOpenApiPath(route.request.path);
|
|
58
|
+
const schema = {
|
|
59
|
+
response: {},
|
|
60
|
+
};
|
|
62
61
|
if (route.request.schema?.query) {
|
|
63
62
|
schema.querystring = route.request.schema?.query;
|
|
64
63
|
}
|
|
@@ -74,99 +73,139 @@ export class HttpServerApi {
|
|
|
74
73
|
schema.response = Object.keys(route.response.statuses).reduce((acc, status) => {
|
|
75
74
|
const statusConfig = route.response.statuses[status];
|
|
76
75
|
if (statusConfig.schema) {
|
|
77
|
-
acc[status] = {};
|
|
78
|
-
if (statusConfig.schema.query) {
|
|
79
|
-
acc[status].querystring = statusConfig.schema.query;
|
|
80
|
-
}
|
|
81
76
|
if (statusConfig.schema.body) {
|
|
82
|
-
acc[status]
|
|
77
|
+
acc[status] = statusConfig.schema.body;
|
|
83
78
|
}
|
|
84
|
-
|
|
85
|
-
acc[status]
|
|
79
|
+
else {
|
|
80
|
+
acc[status] = {};
|
|
86
81
|
}
|
|
87
82
|
}
|
|
88
83
|
return acc;
|
|
89
84
|
}, {});
|
|
90
85
|
app.route({
|
|
91
86
|
method: route.request.method,
|
|
92
|
-
url:
|
|
87
|
+
url: translatedPath,
|
|
93
88
|
schema,
|
|
94
89
|
handler: async (request, reply) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
90
|
+
try {
|
|
91
|
+
// Normalize headers to lowercase
|
|
92
|
+
const normalizedHeaders = normalizeHeaders(request.headers);
|
|
93
|
+
// Construct standardized Telo request object
|
|
94
|
+
const requestPayload = {
|
|
95
|
+
method: request.method,
|
|
96
|
+
path: request.url,
|
|
97
|
+
params: request.params || {},
|
|
98
|
+
query: request.query || {},
|
|
99
|
+
headers: normalizedHeaders,
|
|
100
|
+
body: request.body,
|
|
101
|
+
};
|
|
102
|
+
// Wrap in "request" object as per spec
|
|
103
|
+
const teloRequestContext = { request: requestPayload };
|
|
104
|
+
const result = handler
|
|
105
|
+
? await this.ctx.invoke(handler.kind, handler.name, resolveHandlerInputs(route.handler, teloRequestContext))
|
|
106
|
+
: undefined;
|
|
107
|
+
const response = route.response;
|
|
108
|
+
// Determine final status code
|
|
109
|
+
let statusCode = response.status;
|
|
110
|
+
if (typeof statusCode === "string") {
|
|
111
|
+
statusCode = this.ctx.expandValue(statusCode, { result });
|
|
112
|
+
}
|
|
113
|
+
// Convert status to string for lookup
|
|
114
|
+
const statusKey = String(statusCode);
|
|
115
|
+
const statusConfig = response.statuses[statusKey];
|
|
116
|
+
if (!statusConfig) {
|
|
117
|
+
reply.code(500);
|
|
118
|
+
return reply.send({
|
|
119
|
+
error: "InternalServerError",
|
|
120
|
+
message: "Response status configuration not found",
|
|
121
|
+
status: 500,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Set HTTP status code
|
|
125
|
+
reply.code(statusCode);
|
|
126
|
+
// Map and set response headers if specified
|
|
127
|
+
if (statusConfig.headers) {
|
|
128
|
+
const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
|
|
129
|
+
Object.entries(mappedHeaders).forEach(([key, value]) => {
|
|
130
|
+
reply.header(key, value);
|
|
131
|
+
});
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
// Map and send response body if specified
|
|
134
|
+
if (statusConfig.body !== undefined) {
|
|
135
|
+
const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
|
|
136
|
+
// Validate response body if schema is specified
|
|
137
|
+
if (statusConfig.schema && statusConfig.schema.body) {
|
|
138
|
+
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
139
|
+
}
|
|
140
|
+
return reply.send(mappedBody);
|
|
141
|
+
}
|
|
142
|
+
// No body mapping, send result as-is
|
|
143
|
+
return reply.send(result);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Let the error handler deal with all errors
|
|
147
|
+
throw error;
|
|
134
148
|
}
|
|
135
|
-
// No body mapping, send result as-is
|
|
136
|
-
return reply.send(result);
|
|
137
149
|
},
|
|
138
150
|
});
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
export async function create(resource, ctx) {
|
|
154
|
+
// First validate with a permissive schema (handler can be any shape)
|
|
142
155
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
143
|
-
|
|
156
|
+
// Process routes and register unnamed handlers as child resources
|
|
157
|
+
let handlerCounter = 0;
|
|
158
|
+
const processedRoutes = (resource.routes || []).map((route) => {
|
|
159
|
+
if (!route.handler) {
|
|
160
|
+
return route;
|
|
161
|
+
}
|
|
162
|
+
// Check if handler is unnamed (inline handler)
|
|
163
|
+
if (typeof route.handler === "object" && !route.handler.name) {
|
|
164
|
+
// Use resolveChildren to register the unnamed handler and get its normalized reference
|
|
165
|
+
const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
|
|
166
|
+
// Return route with the resolved handler reference
|
|
167
|
+
return {
|
|
168
|
+
...route,
|
|
169
|
+
handler: {
|
|
170
|
+
kind: resolvedHandler.kind,
|
|
171
|
+
name: resolvedHandler.name,
|
|
172
|
+
inputs: route.handler.inputs,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return route;
|
|
177
|
+
});
|
|
178
|
+
// Create the API instance with processed routes
|
|
179
|
+
const processedResource = {
|
|
180
|
+
...resource,
|
|
181
|
+
routes: processedRoutes,
|
|
182
|
+
};
|
|
183
|
+
return new HttpServerApi(ctx, processedResource);
|
|
144
184
|
}
|
|
145
185
|
function resolveHandlerName(handler) {
|
|
146
186
|
if (typeof handler === "string") {
|
|
147
187
|
const [kind, name] = handler.split("/");
|
|
148
188
|
return { kind, name };
|
|
149
189
|
}
|
|
150
|
-
if (handler &&
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return { name
|
|
190
|
+
if (handler && typeof handler === "object" && typeof handler.kind === "string") {
|
|
191
|
+
// name should always be present after create() processes the routes
|
|
192
|
+
// but fallback gracefully if it's not
|
|
193
|
+
const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
|
|
194
|
+
return { name, kind: handler.kind };
|
|
155
195
|
}
|
|
156
|
-
throw new Error("Unable to resolve handler");
|
|
196
|
+
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
157
197
|
}
|
|
158
|
-
function resolveHandlerInputs(handler,
|
|
198
|
+
function resolveHandlerInputs(handler, requestContext) {
|
|
159
199
|
if (typeof handler === "string") {
|
|
160
|
-
return
|
|
200
|
+
return requestContext;
|
|
161
201
|
}
|
|
162
202
|
if (!handler || typeof handler !== "object") {
|
|
163
|
-
return
|
|
203
|
+
return requestContext;
|
|
164
204
|
}
|
|
165
205
|
if (!handler.inputs) {
|
|
166
|
-
return
|
|
206
|
+
return requestContext;
|
|
167
207
|
}
|
|
168
|
-
|
|
169
|
-
return resolveTemplateInputs(handler.inputs, context);
|
|
208
|
+
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
170
209
|
}
|
|
171
210
|
function resolveTemplateInputs(value, context) {
|
|
172
211
|
if (typeof value === "string") {
|
|
@@ -199,3 +238,70 @@ function resolveTemplatePath(pathExpression, context) {
|
|
|
199
238
|
}
|
|
200
239
|
return current;
|
|
201
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
243
|
+
* Example: /api/v1/users/{userId} -> /api/v1/users/:userId
|
|
244
|
+
*/
|
|
245
|
+
function translateOpenApiPath(openApiPath) {
|
|
246
|
+
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Normalizes all header keys to lowercase as per Telo spec
|
|
250
|
+
*/
|
|
251
|
+
function normalizeHeaders(headers) {
|
|
252
|
+
const normalized = {};
|
|
253
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
254
|
+
normalized[key.toLowerCase()] = value;
|
|
255
|
+
}
|
|
256
|
+
return normalized;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Legacy function - kept for compatibility but not used
|
|
260
|
+
* Converts framework-specific validation errors to standardized Telo format
|
|
261
|
+
* Returns null if the error is not a validation error
|
|
262
|
+
*/
|
|
263
|
+
function convertValidationError(error) {
|
|
264
|
+
// Check if this is a Fastify/AJV validation error
|
|
265
|
+
if (!error || typeof error !== "object") {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
// Fastify validation errors have a statusCode of 400 and validation array
|
|
269
|
+
if (error.statusCode === 400 && Array.isArray(error.validation)) {
|
|
270
|
+
const details = error.validation.map((err) => {
|
|
271
|
+
const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
|
|
272
|
+
// Determine location from keyword/message context
|
|
273
|
+
let location = "body"; // default
|
|
274
|
+
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
275
|
+
location = determinLocationFromContext(err);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
location = determinLocationFromContext(err);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
location,
|
|
282
|
+
path: path || err.params?.missingProperty || "",
|
|
283
|
+
message: err.message || "Validation failed",
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
error: "ValidationError",
|
|
288
|
+
message: "Request validation failed",
|
|
289
|
+
status: 400,
|
|
290
|
+
details,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Helper to determine the location (body, query, params, headers) from validation error context
|
|
297
|
+
*/
|
|
298
|
+
function determinLocationFromContext(err) {
|
|
299
|
+
// AJV validation errors in Fastify include parent keyword context
|
|
300
|
+
if (err.parentSchema && err.instancePath) {
|
|
301
|
+
const path = err.instancePath;
|
|
302
|
+
// This is a simplified check; in practice, Fastify provides better context
|
|
303
|
+
// For now, default to "body" for general validation errors
|
|
304
|
+
return "body";
|
|
305
|
+
}
|
|
306
|
+
return "body";
|
|
307
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import swagger from "@fastify/swagger";
|
|
2
2
|
import apiReference from "@scalar/fastify-api-reference";
|
|
3
|
+
import addFormats from "ajv-formats";
|
|
3
4
|
import Fastify from "fastify";
|
|
4
5
|
class HttpServer {
|
|
5
6
|
releaseHold = null;
|
|
@@ -18,13 +19,23 @@ class HttpServer {
|
|
|
18
19
|
if (!this.port) {
|
|
19
20
|
throw new Error("Http.Server port is required");
|
|
20
21
|
}
|
|
21
|
-
this.app = Fastify({ logger: true });
|
|
22
|
+
this.app = Fastify({ logger: true, ajv: { plugins: [addFormats.default] } });
|
|
22
23
|
}
|
|
23
24
|
async init() {
|
|
24
|
-
this.setupPlugins();
|
|
25
|
+
await this.setupPlugins();
|
|
25
26
|
this.setupRoutes();
|
|
26
27
|
}
|
|
27
28
|
async setupPlugins() {
|
|
29
|
+
// Register custom error handler for validation errors
|
|
30
|
+
this.app.setErrorHandler((error, request, reply) => {
|
|
31
|
+
const mappedError = convertFastifyValidationError(error);
|
|
32
|
+
if (mappedError) {
|
|
33
|
+
reply.code(400);
|
|
34
|
+
return reply.send(mappedError);
|
|
35
|
+
}
|
|
36
|
+
// Let Fastify handle other errors normally
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
28
39
|
if (this.resource.openapi) {
|
|
29
40
|
const servers = [];
|
|
30
41
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
@@ -56,7 +67,7 @@ class HttpServer {
|
|
|
56
67
|
const type = mount.type || "";
|
|
57
68
|
const { kind, name } = parseType(type);
|
|
58
69
|
const prefix = mount.path || "";
|
|
59
|
-
const api = this.ctx.
|
|
70
|
+
const api = this.ctx.moduleContext.getInvokable(name);
|
|
60
71
|
if (!api) {
|
|
61
72
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
62
73
|
}
|
|
@@ -102,3 +113,65 @@ function parseType(type) {
|
|
|
102
113
|
}
|
|
103
114
|
return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
|
|
104
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Converts Fastify validation errors to standardized Telo format
|
|
118
|
+
* Returns null if the error is not a validation error
|
|
119
|
+
*/
|
|
120
|
+
function convertFastifyValidationError(error) {
|
|
121
|
+
// Check if this is a Fastify validation error
|
|
122
|
+
if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const message = error.message || "";
|
|
126
|
+
const details = [];
|
|
127
|
+
// Parse Fastify validation error message to extract location and field
|
|
128
|
+
// Format examples:
|
|
129
|
+
// "querystring must have required property 'name'"
|
|
130
|
+
// "body must be object"
|
|
131
|
+
// "params.userId must be string"
|
|
132
|
+
let location = "body"; // default
|
|
133
|
+
let fieldPath = "";
|
|
134
|
+
let validationMessage = "Validation failed";
|
|
135
|
+
// Try to extract location from message
|
|
136
|
+
if (message.includes("querystring")) {
|
|
137
|
+
location = "query";
|
|
138
|
+
}
|
|
139
|
+
else if (message.includes("params")) {
|
|
140
|
+
location = "params";
|
|
141
|
+
}
|
|
142
|
+
else if (message.includes("headers")) {
|
|
143
|
+
location = "headers";
|
|
144
|
+
}
|
|
145
|
+
else if (message.includes("body")) {
|
|
146
|
+
location = "body";
|
|
147
|
+
}
|
|
148
|
+
// Extract field name from "must have required property 'fieldName'" pattern
|
|
149
|
+
const requiredMatch = message.match(/must have required property '([^']+)'/);
|
|
150
|
+
if (requiredMatch) {
|
|
151
|
+
fieldPath = requiredMatch[1];
|
|
152
|
+
validationMessage = `is a required property`;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Extract field from "fieldName must be" pattern
|
|
156
|
+
const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
|
|
157
|
+
if (fieldMatch) {
|
|
158
|
+
fieldPath = fieldMatch[1];
|
|
159
|
+
}
|
|
160
|
+
validationMessage = message
|
|
161
|
+
.replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
|
|
162
|
+
.replace(" must ", " ");
|
|
163
|
+
}
|
|
164
|
+
if (fieldPath || message) {
|
|
165
|
+
details.push({
|
|
166
|
+
location,
|
|
167
|
+
path: fieldPath,
|
|
168
|
+
message: validationMessage,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
error: "ValidationError",
|
|
173
|
+
message: "Request validation failed",
|
|
174
|
+
status: 400,
|
|
175
|
+
details,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@fastify/swagger": "^9.6.1",
|
|
21
|
+
"@scalar/fastify-api-reference": "^1.44.6",
|
|
20
22
|
"@sinclair/typebox": "^0.34.48",
|
|
21
23
|
"ajv": "^8.17.1",
|
|
24
|
+
"ajv-formats": "^3.0.1",
|
|
22
25
|
"fastify": "^5.7.2",
|
|
23
|
-
"@
|
|
24
|
-
"@scalar/fastify-api-reference": "^1.44.6",
|
|
25
|
-
"@telorun/sdk": "0.2.3"
|
|
26
|
+
"@telorun/sdk": "0.2.5"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@types/node": "^20.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
1
|
import { Static, Type } from "@sinclair/typebox";
|
|
3
|
-
import {
|
|
2
|
+
import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
3
|
+
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
4
4
|
|
|
5
5
|
const HttpApiRouteManifest = Type.Object({
|
|
6
6
|
request: Type.Object({
|
|
@@ -15,11 +15,7 @@ const HttpApiRouteManifest = Type.Object({
|
|
|
15
15
|
}),
|
|
16
16
|
),
|
|
17
17
|
}),
|
|
18
|
-
handler: Type.Optional(Type.
|
|
19
|
-
kind: Type.String(),
|
|
20
|
-
name: Type.String(),
|
|
21
|
-
inputs: Type.Optional(Type.Any()),
|
|
22
|
-
})),
|
|
18
|
+
handler: Type.Optional(Type.Any()), // Any handler shape is allowed - will be processed in create()
|
|
23
19
|
response: Type.Object({
|
|
24
20
|
status: Type.Union([Type.Number({ minimum: 100, maximum: 599 }), Type.String()]),
|
|
25
21
|
statuses: Type.Record(
|
|
@@ -77,7 +73,12 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
77
73
|
|
|
78
74
|
private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
|
|
79
75
|
const handler = route.handler ? resolveHandlerName(route.handler) : null;
|
|
80
|
-
const
|
|
76
|
+
const translatedPath = translateOpenApiPath(route.request.path);
|
|
77
|
+
|
|
78
|
+
const schema: any = {
|
|
79
|
+
response: {},
|
|
80
|
+
};
|
|
81
|
+
|
|
81
82
|
if (route.request.schema?.query) {
|
|
82
83
|
schema.querystring = route.request.schema?.query;
|
|
83
84
|
}
|
|
@@ -90,19 +91,15 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
90
91
|
if (route.request.schema?.headers) {
|
|
91
92
|
schema.headers = route.request.schema?.headers;
|
|
92
93
|
}
|
|
94
|
+
|
|
93
95
|
schema.response = Object.keys(route.response.statuses).reduce(
|
|
94
96
|
(acc, status) => {
|
|
95
97
|
const statusConfig = route.response.statuses[status];
|
|
96
98
|
if (statusConfig.schema) {
|
|
97
|
-
acc[status] = {};
|
|
98
|
-
if (statusConfig.schema.query) {
|
|
99
|
-
acc[status].querystring = statusConfig.schema.query;
|
|
100
|
-
}
|
|
101
99
|
if (statusConfig.schema.body) {
|
|
102
|
-
acc[status]
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
acc[status].headers = statusConfig.schema.headers;
|
|
100
|
+
acc[status] = statusConfig.schema.body;
|
|
101
|
+
} else {
|
|
102
|
+
acc[status] = {};
|
|
106
103
|
}
|
|
107
104
|
}
|
|
108
105
|
return acc;
|
|
@@ -111,72 +108,126 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
111
108
|
);
|
|
112
109
|
|
|
113
110
|
app.route({
|
|
114
|
-
method: route.request.method,
|
|
115
|
-
url:
|
|
111
|
+
method: route.request.method as any,
|
|
112
|
+
url: translatedPath,
|
|
116
113
|
schema,
|
|
117
|
-
handler: async (request, reply) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
? this.ctx.expandValue(response.status, { result })
|
|
143
|
-
: response.status;
|
|
144
|
-
if (response.status) {
|
|
145
|
-
reply.code(status);
|
|
146
|
-
}
|
|
147
|
-
const statusConfig = response.statuses[response.status];
|
|
148
|
-
if (!statusConfig) {
|
|
149
|
-
return reply.code(500).send({ error: "Invalid response status configuration" });
|
|
150
|
-
}
|
|
151
|
-
// Map headers if specified
|
|
152
|
-
if (statusConfig.headers) {
|
|
153
|
-
reply.headers(this.ctx.expandValue(statusConfig.headers, { result }));
|
|
154
|
-
}
|
|
114
|
+
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
115
|
+
try {
|
|
116
|
+
// Normalize headers to lowercase
|
|
117
|
+
const normalizedHeaders = normalizeHeaders(request.headers);
|
|
118
|
+
|
|
119
|
+
// Construct standardized Telo request object
|
|
120
|
+
const requestPayload = {
|
|
121
|
+
method: request.method,
|
|
122
|
+
path: request.url,
|
|
123
|
+
params: request.params || {},
|
|
124
|
+
query: request.query || {},
|
|
125
|
+
headers: normalizedHeaders,
|
|
126
|
+
body: request.body,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Wrap in "request" object as per spec
|
|
130
|
+
const teloRequestContext = { request: requestPayload };
|
|
131
|
+
|
|
132
|
+
const result = handler
|
|
133
|
+
? await this.ctx.invoke(
|
|
134
|
+
handler.kind,
|
|
135
|
+
handler.name,
|
|
136
|
+
resolveHandlerInputs(route.handler, teloRequestContext),
|
|
137
|
+
)
|
|
138
|
+
: undefined;
|
|
155
139
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
140
|
+
const response = route.response;
|
|
141
|
+
|
|
142
|
+
// Determine final status code
|
|
143
|
+
let statusCode = response.status;
|
|
144
|
+
if (typeof statusCode === "string") {
|
|
145
|
+
statusCode = this.ctx.expandValue(statusCode, { result }) as number;
|
|
163
146
|
}
|
|
164
|
-
return reply.send(mappedBody);
|
|
165
|
-
}
|
|
166
147
|
|
|
167
|
-
|
|
168
|
-
|
|
148
|
+
// Convert status to string for lookup
|
|
149
|
+
const statusKey = String(statusCode);
|
|
150
|
+
const statusConfig = response.statuses[statusKey];
|
|
151
|
+
|
|
152
|
+
if (!statusConfig) {
|
|
153
|
+
reply.code(500);
|
|
154
|
+
return reply.send({
|
|
155
|
+
error: "InternalServerError",
|
|
156
|
+
message: "Response status configuration not found",
|
|
157
|
+
status: 500,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Set HTTP status code
|
|
162
|
+
reply.code(statusCode as number);
|
|
163
|
+
|
|
164
|
+
// Map and set response headers if specified
|
|
165
|
+
if (statusConfig.headers) {
|
|
166
|
+
const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
|
|
167
|
+
Object.entries(mappedHeaders).forEach(([key, value]) => {
|
|
168
|
+
reply.header(key, value as string);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Map and send response body if specified
|
|
173
|
+
if (statusConfig.body !== undefined) {
|
|
174
|
+
const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
|
|
175
|
+
|
|
176
|
+
// Validate response body if schema is specified
|
|
177
|
+
if (statusConfig.schema && statusConfig.schema.body) {
|
|
178
|
+
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return reply.send(mappedBody);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// No body mapping, send result as-is
|
|
185
|
+
return reply.send(result);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// Let the error handler deal with all errors
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
169
190
|
},
|
|
170
191
|
});
|
|
171
192
|
}
|
|
172
193
|
}
|
|
173
194
|
|
|
174
|
-
export async function create(
|
|
175
|
-
|
|
176
|
-
ctx: ResourceContext,
|
|
177
|
-
): Promise<HttpServerApi> {
|
|
195
|
+
export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
|
|
196
|
+
// First validate with a permissive schema (handler can be any shape)
|
|
178
197
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
179
|
-
|
|
198
|
+
// Process routes and register unnamed handlers as child resources
|
|
199
|
+
let handlerCounter = 0;
|
|
200
|
+
const processedRoutes = (resource.routes || []).map((route: any) => {
|
|
201
|
+
if (!route.handler) {
|
|
202
|
+
return route;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check if handler is unnamed (inline handler)
|
|
206
|
+
if (typeof route.handler === "object" && !route.handler.name) {
|
|
207
|
+
// Use resolveChildren to register the unnamed handler and get its normalized reference
|
|
208
|
+
const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
|
|
209
|
+
|
|
210
|
+
// Return route with the resolved handler reference
|
|
211
|
+
return {
|
|
212
|
+
...route,
|
|
213
|
+
handler: {
|
|
214
|
+
kind: resolvedHandler.kind,
|
|
215
|
+
name: resolvedHandler.name,
|
|
216
|
+
inputs: route.handler.inputs,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return route;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Create the API instance with processed routes
|
|
225
|
+
const processedResource: HttpApiManifest = {
|
|
226
|
+
...resource,
|
|
227
|
+
routes: processedRoutes,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return new HttpServerApi(ctx, processedResource);
|
|
180
231
|
}
|
|
181
232
|
|
|
182
233
|
function resolveHandlerName(handler: any): { kind: string; name: string } {
|
|
@@ -184,29 +235,26 @@ function resolveHandlerName(handler: any): { kind: string; name: string } {
|
|
|
184
235
|
const [kind, name] = handler.split("/");
|
|
185
236
|
return { kind, name };
|
|
186
237
|
}
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
) {
|
|
193
|
-
return { name: handler.name, kind: handler.kind };
|
|
238
|
+
if (handler && typeof handler === "object" && typeof handler.kind === "string") {
|
|
239
|
+
// name should always be present after create() processes the routes
|
|
240
|
+
// but fallback gracefully if it's not
|
|
241
|
+
const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
|
|
242
|
+
return { name, kind: handler.kind };
|
|
194
243
|
}
|
|
195
|
-
throw new Error("Unable to resolve handler");
|
|
244
|
+
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
196
245
|
}
|
|
197
246
|
|
|
198
|
-
function resolveHandlerInputs(handler: any,
|
|
247
|
+
function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
|
|
199
248
|
if (typeof handler === "string") {
|
|
200
|
-
return
|
|
249
|
+
return requestContext;
|
|
201
250
|
}
|
|
202
251
|
if (!handler || typeof handler !== "object") {
|
|
203
|
-
return
|
|
252
|
+
return requestContext;
|
|
204
253
|
}
|
|
205
254
|
if (!handler.inputs) {
|
|
206
|
-
return
|
|
255
|
+
return requestContext;
|
|
207
256
|
}
|
|
208
|
-
|
|
209
|
-
return resolveTemplateInputs(handler.inputs, context);
|
|
257
|
+
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
210
258
|
}
|
|
211
259
|
|
|
212
260
|
function resolveTemplateInputs(value: any, context: Record<string, any>): any {
|
|
@@ -241,3 +289,78 @@ function resolveTemplatePath(pathExpression: string, context: Record<string, any
|
|
|
241
289
|
}
|
|
242
290
|
return current;
|
|
243
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
295
|
+
* Example: /api/v1/users/{userId} -> /api/v1/users/:userId
|
|
296
|
+
*/
|
|
297
|
+
function translateOpenApiPath(openApiPath: string): string {
|
|
298
|
+
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Normalizes all header keys to lowercase as per Telo spec
|
|
303
|
+
*/
|
|
304
|
+
function normalizeHeaders(headers: Record<string, any>): Record<string, any> {
|
|
305
|
+
const normalized: Record<string, any> = {};
|
|
306
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
307
|
+
normalized[key.toLowerCase()] = value;
|
|
308
|
+
}
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Legacy function - kept for compatibility but not used
|
|
314
|
+
* Converts framework-specific validation errors to standardized Telo format
|
|
315
|
+
* Returns null if the error is not a validation error
|
|
316
|
+
*/
|
|
317
|
+
function convertValidationError(error: any): Record<string, any> | null {
|
|
318
|
+
// Check if this is a Fastify/AJV validation error
|
|
319
|
+
if (!error || typeof error !== "object") {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Fastify validation errors have a statusCode of 400 and validation array
|
|
324
|
+
if (error.statusCode === 400 && Array.isArray(error.validation)) {
|
|
325
|
+
const details = error.validation.map((err: any) => {
|
|
326
|
+
const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
|
|
327
|
+
|
|
328
|
+
// Determine location from keyword/message context
|
|
329
|
+
let location = "body"; // default
|
|
330
|
+
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
331
|
+
location = determinLocationFromContext(err);
|
|
332
|
+
} else {
|
|
333
|
+
location = determinLocationFromContext(err);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
location,
|
|
338
|
+
path: path || err.params?.missingProperty || "",
|
|
339
|
+
message: err.message || "Validation failed",
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
error: "ValidationError",
|
|
345
|
+
message: "Request validation failed",
|
|
346
|
+
status: 400,
|
|
347
|
+
details,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Helper to determine the location (body, query, params, headers) from validation error context
|
|
356
|
+
*/
|
|
357
|
+
function determinLocationFromContext(err: any): string {
|
|
358
|
+
// AJV validation errors in Fastify include parent keyword context
|
|
359
|
+
if (err.parentSchema && err.instancePath) {
|
|
360
|
+
const path = err.instancePath;
|
|
361
|
+
// This is a simplified check; in practice, Fastify provides better context
|
|
362
|
+
// For now, default to "body" for general validation errors
|
|
363
|
+
return "body";
|
|
364
|
+
}
|
|
365
|
+
return "body";
|
|
366
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import swagger from "@fastify/swagger";
|
|
2
2
|
import apiReference from "@scalar/fastify-api-reference";
|
|
3
3
|
import type { ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
|
|
4
|
+
import addFormats from "ajv-formats";
|
|
4
5
|
import Fastify, { FastifyInstance } from "fastify";
|
|
5
6
|
import { HttpServerApi } from "./http-api-controller.js";
|
|
6
7
|
|
|
@@ -86,15 +87,25 @@ class HttpServer implements ResourceInstance {
|
|
|
86
87
|
if (!this.port) {
|
|
87
88
|
throw new Error("Http.Server port is required");
|
|
88
89
|
}
|
|
89
|
-
this.app = Fastify({ logger: true });
|
|
90
|
+
this.app = Fastify({ logger: true, ajv: { plugins: [addFormats.default as any] } });
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
async init() {
|
|
93
|
-
this.setupPlugins();
|
|
94
|
+
await this.setupPlugins();
|
|
94
95
|
this.setupRoutes();
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
private async setupPlugins() {
|
|
99
|
+
// Register custom error handler for validation errors
|
|
100
|
+
this.app.setErrorHandler((error, request, reply) => {
|
|
101
|
+
const mappedError = convertFastifyValidationError(error);
|
|
102
|
+
if (mappedError) {
|
|
103
|
+
reply.code(400);
|
|
104
|
+
return reply.send(mappedError);
|
|
105
|
+
}
|
|
106
|
+
// Let Fastify handle other errors normally
|
|
107
|
+
throw error;
|
|
108
|
+
});
|
|
98
109
|
if (this.resource.openapi) {
|
|
99
110
|
const servers = [];
|
|
100
111
|
// const routesByName = new Map<string, HttpRouteResource>();
|
|
@@ -128,7 +139,7 @@ class HttpServer implements ResourceInstance {
|
|
|
128
139
|
const { kind, name } = parseType(type);
|
|
129
140
|
const prefix = mount.path || "";
|
|
130
141
|
|
|
131
|
-
const api
|
|
142
|
+
const api = this.ctx.moduleContext.getInvokable(name) as unknown as HttpServerApi;
|
|
132
143
|
|
|
133
144
|
if (!api) {
|
|
134
145
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
@@ -181,3 +192,69 @@ function parseType(type: string): { kind: string; name: string } {
|
|
|
181
192
|
}
|
|
182
193
|
return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
|
|
183
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Converts Fastify validation errors to standardized Telo format
|
|
198
|
+
* Returns null if the error is not a validation error
|
|
199
|
+
*/
|
|
200
|
+
function convertFastifyValidationError(error: any): Record<string, any> | null {
|
|
201
|
+
// Check if this is a Fastify validation error
|
|
202
|
+
if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const message = error.message || "";
|
|
207
|
+
const details = [];
|
|
208
|
+
|
|
209
|
+
// Parse Fastify validation error message to extract location and field
|
|
210
|
+
// Format examples:
|
|
211
|
+
// "querystring must have required property 'name'"
|
|
212
|
+
// "body must be object"
|
|
213
|
+
// "params.userId must be string"
|
|
214
|
+
|
|
215
|
+
let location = "body"; // default
|
|
216
|
+
let fieldPath = "";
|
|
217
|
+
let validationMessage = "Validation failed";
|
|
218
|
+
|
|
219
|
+
// Try to extract location from message
|
|
220
|
+
if (message.includes("querystring")) {
|
|
221
|
+
location = "query";
|
|
222
|
+
} else if (message.includes("params")) {
|
|
223
|
+
location = "params";
|
|
224
|
+
} else if (message.includes("headers")) {
|
|
225
|
+
location = "headers";
|
|
226
|
+
} else if (message.includes("body")) {
|
|
227
|
+
location = "body";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Extract field name from "must have required property 'fieldName'" pattern
|
|
231
|
+
const requiredMatch = message.match(/must have required property '([^']+)'/);
|
|
232
|
+
if (requiredMatch) {
|
|
233
|
+
fieldPath = requiredMatch[1];
|
|
234
|
+
validationMessage = `is a required property`;
|
|
235
|
+
} else {
|
|
236
|
+
// Extract field from "fieldName must be" pattern
|
|
237
|
+
const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
|
|
238
|
+
if (fieldMatch) {
|
|
239
|
+
fieldPath = fieldMatch[1];
|
|
240
|
+
}
|
|
241
|
+
validationMessage = message
|
|
242
|
+
.replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
|
|
243
|
+
.replace(" must ", " ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (fieldPath || message) {
|
|
247
|
+
details.push({
|
|
248
|
+
location,
|
|
249
|
+
path: fieldPath,
|
|
250
|
+
message: validationMessage,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
error: "ValidationError",
|
|
256
|
+
message: "Request validation failed",
|
|
257
|
+
status: 400,
|
|
258
|
+
details,
|
|
259
|
+
};
|
|
260
|
+
}
|
package/dist/openapi.js
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import swagger from '@fastify/swagger';
|
|
2
|
-
import apiReference from '@scalar/fastify-api-reference';
|
|
3
|
-
function getResourceConfig(resource) {
|
|
4
|
-
return resource;
|
|
5
|
-
}
|
|
6
|
-
export function register(ctx) { }
|
|
7
|
-
export function create(resource, ctx) {
|
|
8
|
-
const openApiResource = resource;
|
|
9
|
-
const config = getResourceConfig(openApiResource);
|
|
10
|
-
const apiRefs = config.apis || [];
|
|
11
|
-
if (apiRefs.length === 0) {
|
|
12
|
-
throw new Error(`OpenApi.Spec "${resource.metadata.name}" is missing apis`);
|
|
13
|
-
}
|
|
14
|
-
const handler = (payload) => {
|
|
15
|
-
const serverResource = payload?.resource;
|
|
16
|
-
const app = payload?.app;
|
|
17
|
-
if (!serverResource || !app) {
|
|
18
|
-
throw new Error(`OpenApi.Spec handler missing Http.Server resource or Fastify app`);
|
|
19
|
-
}
|
|
20
|
-
const matchedApis = resolveApis(apiRefs, ctx);
|
|
21
|
-
const mounts = getResourceConfig(serverResource).mounts || [];
|
|
22
|
-
const servers = buildServers(serverResource, mounts, matchedApis);
|
|
23
|
-
if (servers.length === 0) {
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
const paths = buildPaths(matchedApis, mounts);
|
|
27
|
-
const info = config.info && typeof config.info === 'object'
|
|
28
|
-
? config.info
|
|
29
|
-
: {
|
|
30
|
-
title: resource.metadata.name,
|
|
31
|
-
version: resource.version || '1.0.0',
|
|
32
|
-
};
|
|
33
|
-
const routePrefix = config.path || `/openapi/${resource.metadata.name}`;
|
|
34
|
-
app.register(swagger, {
|
|
35
|
-
openapi: {
|
|
36
|
-
openapi: '3.0.0',
|
|
37
|
-
info,
|
|
38
|
-
servers,
|
|
39
|
-
paths,
|
|
40
|
-
},
|
|
41
|
-
routePrefix,
|
|
42
|
-
});
|
|
43
|
-
app.register(apiReference, {
|
|
44
|
-
routePrefix,
|
|
45
|
-
});
|
|
46
|
-
};
|
|
47
|
-
const httpServers = ctx.getResources('Http.Server');
|
|
48
|
-
return {
|
|
49
|
-
init: async () => {
|
|
50
|
-
// Register listeners after all resources are initialized
|
|
51
|
-
for (const server of httpServers) {
|
|
52
|
-
ctx.on('Http.Server.Ready', server.metadata.name, handler);
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
teardown: () => {
|
|
56
|
-
for (const server of httpServers) {
|
|
57
|
-
ctx.offResourceEvent('Http.Server', server.metadata.name, 'Ready', handler);
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
function resolveApis(apiRefs, ctx) {
|
|
63
|
-
const apis = [];
|
|
64
|
-
for (const ref of apiRefs) {
|
|
65
|
-
const { kind, name } = parseRef(ref);
|
|
66
|
-
if (!kind || !name) {
|
|
67
|
-
throw new Error(`Reference not found: ${ref}`);
|
|
68
|
-
}
|
|
69
|
-
if (kind !== 'Http.Api' && kind !== 'Http.Route') {
|
|
70
|
-
throw new Error(`Reference not supported: ${ref}`);
|
|
71
|
-
}
|
|
72
|
-
const resource = ctx.kernel.registry.get(kind)?.get(name);
|
|
73
|
-
if (!resource) {
|
|
74
|
-
throw new Error(`Reference not found: ${ref}`);
|
|
75
|
-
}
|
|
76
|
-
apis.push(resource);
|
|
77
|
-
}
|
|
78
|
-
return apis;
|
|
79
|
-
}
|
|
80
|
-
function buildServers(server, mounts, apis) {
|
|
81
|
-
const config = getResourceConfig(server);
|
|
82
|
-
const host = config.host || '0.0.0.0';
|
|
83
|
-
const port = Number(config.port || 0);
|
|
84
|
-
if (!port) {
|
|
85
|
-
return [];
|
|
86
|
-
}
|
|
87
|
-
// Server URL should be the base URL without mount paths
|
|
88
|
-
// Paths will include the mount prefix
|
|
89
|
-
return [{ url: `http://${host}:${port}` }];
|
|
90
|
-
}
|
|
91
|
-
function buildPaths(apis, mounts) {
|
|
92
|
-
const paths = {};
|
|
93
|
-
const mountPrefixByType = new Map();
|
|
94
|
-
for (const mount of mounts) {
|
|
95
|
-
if (mount.type) {
|
|
96
|
-
mountPrefixByType.set(mount.type, mount.path || '');
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
for (const api of apis) {
|
|
100
|
-
const prefix = mountPrefixByType.get(`${api.kind}.${api.metadata.name}`) || '';
|
|
101
|
-
if (api.kind === 'Http.Route') {
|
|
102
|
-
const config = getResourceConfig(api);
|
|
103
|
-
const path = joinPath(prefix, api.metadata?.path || config.path || '');
|
|
104
|
-
const method = (api.metadata?.method ||
|
|
105
|
-
config.method ||
|
|
106
|
-
'GET').toLowerCase();
|
|
107
|
-
if (!path) {
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (!paths[path]) {
|
|
111
|
-
paths[path] = {};
|
|
112
|
-
}
|
|
113
|
-
paths[path][method] = { responses: { '200': { description: 'OK' } } };
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
const routes = getResourceConfig(api).routes || [];
|
|
117
|
-
for (const route of routes) {
|
|
118
|
-
if (typeof route === 'string') {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
const request = route.request || {};
|
|
122
|
-
const path = joinPath(prefix, request.path || '');
|
|
123
|
-
const method = (request.method || 'GET').toLowerCase();
|
|
124
|
-
if (!path) {
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (!paths[path]) {
|
|
128
|
-
paths[path] = {};
|
|
129
|
-
}
|
|
130
|
-
paths[path][method] = { responses: { '200': { description: 'OK' } } };
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return paths;
|
|
134
|
-
}
|
|
135
|
-
function parseRef(ref) {
|
|
136
|
-
const separator = ref.lastIndexOf('.');
|
|
137
|
-
if (separator <= 0 || separator === ref.length - 1) {
|
|
138
|
-
return { kind: '', name: '' };
|
|
139
|
-
}
|
|
140
|
-
return { kind: ref.slice(0, separator), name: ref.slice(separator + 1) };
|
|
141
|
-
}
|
|
142
|
-
function joinPath(prefix, path) {
|
|
143
|
-
if (!prefix) {
|
|
144
|
-
return path;
|
|
145
|
-
}
|
|
146
|
-
if (!path) {
|
|
147
|
-
return prefix;
|
|
148
|
-
}
|
|
149
|
-
const trimmedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
150
|
-
const trimmedPath = path.startsWith('/') ? path : `/${path}`;
|
|
151
|
-
return `${trimmedPrefix}${trimmedPath}`;
|
|
152
|
-
}
|