@telorun/http-server 0.1.2 → 0.1.3
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 +8 -0
- package/dist/http-api-controller.d.ts +3 -7
- package/dist/http-api-controller.js +242 -60
- package/dist/test-validation.d.ts +1 -0
- package/dist/test-validation.js +36 -0
- package/package.json +2 -2
- package/src/http-api-controller.ts +283 -78
package/CHANGELOG.md
CHANGED
|
@@ -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({
|
|
@@ -41,6 +37,16 @@ export class HttpServerApi {
|
|
|
41
37
|
}
|
|
42
38
|
async init() { }
|
|
43
39
|
register(app, prefix = "") {
|
|
40
|
+
// Register custom error handler for validation errors
|
|
41
|
+
app.setErrorHandler((error, request, reply) => {
|
|
42
|
+
const mappedError = convertFastifyValidationError(error);
|
|
43
|
+
if (mappedError) {
|
|
44
|
+
reply.code(400);
|
|
45
|
+
return reply.send(mappedError);
|
|
46
|
+
}
|
|
47
|
+
// Let Fastify handle other errors normally
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
44
50
|
if (prefix) {
|
|
45
51
|
app.register(async (scoped) => {
|
|
46
52
|
this.registerRoutes(scoped);
|
|
@@ -58,7 +64,10 @@ export class HttpServerApi {
|
|
|
58
64
|
}
|
|
59
65
|
registerRoute(app, route) {
|
|
60
66
|
const handler = route.handler ? resolveHandlerName(route.handler) : null;
|
|
61
|
-
const
|
|
67
|
+
const translatedPath = translateOpenApiPath(route.request.path);
|
|
68
|
+
const schema = {
|
|
69
|
+
response: {},
|
|
70
|
+
};
|
|
62
71
|
if (route.request.schema?.query) {
|
|
63
72
|
schema.querystring = route.request.schema?.query;
|
|
64
73
|
}
|
|
@@ -89,84 +98,128 @@ export class HttpServerApi {
|
|
|
89
98
|
}, {});
|
|
90
99
|
app.route({
|
|
91
100
|
method: route.request.method,
|
|
92
|
-
url:
|
|
101
|
+
url: translatedPath,
|
|
93
102
|
schema,
|
|
94
103
|
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
|
-
const statusConfig = response.statuses[response.status];
|
|
118
|
-
if (!statusConfig) {
|
|
119
|
-
return reply.code(500).send({ error: "Invalid response status configuration" });
|
|
120
|
-
}
|
|
121
|
-
// Map headers if specified
|
|
122
|
-
if (statusConfig.headers) {
|
|
123
|
-
reply.headers(this.ctx.expandValue(statusConfig.headers, { result }));
|
|
124
|
-
}
|
|
125
|
-
// Map body if specified
|
|
126
|
-
if (statusConfig.body !== undefined) {
|
|
127
|
-
const mappedBody = this.ctx.expandValue(statusConfig.body, {
|
|
128
|
-
result,
|
|
129
|
-
});
|
|
130
|
-
if (statusConfig.schema && statusConfig.schema.body) {
|
|
131
|
-
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
104
|
+
try {
|
|
105
|
+
// Normalize headers to lowercase
|
|
106
|
+
const normalizedHeaders = normalizeHeaders(request.headers);
|
|
107
|
+
// Construct standardized Telo request object
|
|
108
|
+
const requestPayload = {
|
|
109
|
+
method: request.method,
|
|
110
|
+
path: request.url,
|
|
111
|
+
params: request.params || {},
|
|
112
|
+
query: request.query || {},
|
|
113
|
+
headers: normalizedHeaders,
|
|
114
|
+
body: request.body,
|
|
115
|
+
};
|
|
116
|
+
// Wrap in "request" object as per spec
|
|
117
|
+
const teloRequestContext = { request: requestPayload };
|
|
118
|
+
const result = handler
|
|
119
|
+
? await this.ctx.invoke(handler.kind, handler.name, resolveHandlerInputs(route.handler, teloRequestContext))
|
|
120
|
+
: undefined;
|
|
121
|
+
const response = route.response;
|
|
122
|
+
// Determine final status code
|
|
123
|
+
let statusCode = response.status;
|
|
124
|
+
if (typeof statusCode === "string") {
|
|
125
|
+
statusCode = this.ctx.expandValue(statusCode, { result });
|
|
132
126
|
}
|
|
133
|
-
|
|
127
|
+
// Convert status to string for lookup
|
|
128
|
+
const statusKey = String(statusCode);
|
|
129
|
+
const statusConfig = response.statuses[statusKey];
|
|
130
|
+
if (!statusConfig) {
|
|
131
|
+
reply.code(500);
|
|
132
|
+
return reply.send({
|
|
133
|
+
error: "InternalServerError",
|
|
134
|
+
message: "Response status configuration not found",
|
|
135
|
+
status: 500,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Set HTTP status code
|
|
139
|
+
reply.code(statusCode);
|
|
140
|
+
// Map and set response headers if specified
|
|
141
|
+
if (statusConfig.headers) {
|
|
142
|
+
const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
|
|
143
|
+
Object.entries(mappedHeaders).forEach(([key, value]) => {
|
|
144
|
+
reply.header(key, value);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// Map and send response body if specified
|
|
148
|
+
if (statusConfig.body !== undefined) {
|
|
149
|
+
const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
|
|
150
|
+
// Validate response body if schema is specified
|
|
151
|
+
if (statusConfig.schema && statusConfig.schema.body) {
|
|
152
|
+
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
153
|
+
}
|
|
154
|
+
return reply.send(mappedBody);
|
|
155
|
+
}
|
|
156
|
+
// No body mapping, send result as-is
|
|
157
|
+
return reply.send(result);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// Let the error handler deal with all errors
|
|
161
|
+
throw error;
|
|
134
162
|
}
|
|
135
|
-
// No body mapping, send result as-is
|
|
136
|
-
return reply.send(result);
|
|
137
163
|
},
|
|
138
164
|
});
|
|
139
165
|
}
|
|
140
166
|
}
|
|
141
167
|
export async function create(resource, ctx) {
|
|
168
|
+
// First validate with a permissive schema (handler can be any shape)
|
|
142
169
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
143
|
-
|
|
170
|
+
// Process routes and register unnamed handlers as child resources
|
|
171
|
+
let handlerCounter = 0;
|
|
172
|
+
const processedRoutes = (resource.routes || []).map((route) => {
|
|
173
|
+
if (!route.handler) {
|
|
174
|
+
return route;
|
|
175
|
+
}
|
|
176
|
+
// Check if handler is unnamed (inline handler)
|
|
177
|
+
if (typeof route.handler === "object" && !route.handler.name) {
|
|
178
|
+
// Use resolveChildren to register the unnamed handler and get its normalized reference
|
|
179
|
+
const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
|
|
180
|
+
// Return route with the resolved handler reference
|
|
181
|
+
return {
|
|
182
|
+
...route,
|
|
183
|
+
handler: {
|
|
184
|
+
kind: resolvedHandler.kind,
|
|
185
|
+
name: resolvedHandler.name,
|
|
186
|
+
inputs: route.handler.inputs,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return route;
|
|
191
|
+
});
|
|
192
|
+
// Create the API instance with processed routes
|
|
193
|
+
const processedResource = {
|
|
194
|
+
...resource,
|
|
195
|
+
routes: processedRoutes,
|
|
196
|
+
};
|
|
197
|
+
return new HttpServerApi(ctx, processedResource);
|
|
144
198
|
}
|
|
145
199
|
function resolveHandlerName(handler) {
|
|
146
200
|
if (typeof handler === "string") {
|
|
147
201
|
const [kind, name] = handler.split("/");
|
|
148
202
|
return { kind, name };
|
|
149
203
|
}
|
|
150
|
-
if (handler &&
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return { name
|
|
204
|
+
if (handler && typeof handler === "object" && typeof handler.kind === "string") {
|
|
205
|
+
// name should always be present after create() processes the routes
|
|
206
|
+
// but fallback gracefully if it's not
|
|
207
|
+
const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
|
|
208
|
+
return { name, kind: handler.kind };
|
|
155
209
|
}
|
|
156
|
-
throw new Error("Unable to resolve handler");
|
|
210
|
+
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
157
211
|
}
|
|
158
|
-
function resolveHandlerInputs(handler,
|
|
212
|
+
function resolveHandlerInputs(handler, requestContext) {
|
|
159
213
|
if (typeof handler === "string") {
|
|
160
|
-
return
|
|
214
|
+
return requestContext;
|
|
161
215
|
}
|
|
162
216
|
if (!handler || typeof handler !== "object") {
|
|
163
|
-
return
|
|
217
|
+
return requestContext;
|
|
164
218
|
}
|
|
165
219
|
if (!handler.inputs) {
|
|
166
|
-
return
|
|
220
|
+
return requestContext;
|
|
167
221
|
}
|
|
168
|
-
|
|
169
|
-
return resolveTemplateInputs(handler.inputs, context);
|
|
222
|
+
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
170
223
|
}
|
|
171
224
|
function resolveTemplateInputs(value, context) {
|
|
172
225
|
if (typeof value === "string") {
|
|
@@ -199,3 +252,132 @@ function resolveTemplatePath(pathExpression, context) {
|
|
|
199
252
|
}
|
|
200
253
|
return current;
|
|
201
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
257
|
+
* Example: /api/v1/users/{userId} -> /api/v1/users/:userId
|
|
258
|
+
*/
|
|
259
|
+
function translateOpenApiPath(openApiPath) {
|
|
260
|
+
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Normalizes all header keys to lowercase as per Telo spec
|
|
264
|
+
*/
|
|
265
|
+
function normalizeHeaders(headers) {
|
|
266
|
+
const normalized = {};
|
|
267
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
268
|
+
normalized[key.toLowerCase()] = value;
|
|
269
|
+
}
|
|
270
|
+
return normalized;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Converts Fastify validation errors to standardized Telo format
|
|
274
|
+
* Returns null if the error is not a validation error
|
|
275
|
+
*/
|
|
276
|
+
function convertFastifyValidationError(error) {
|
|
277
|
+
// Check if this is a Fastify validation error
|
|
278
|
+
if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const message = error.message || "";
|
|
282
|
+
const details = [];
|
|
283
|
+
// Parse Fastify validation error message to extract location and field
|
|
284
|
+
// Format examples:
|
|
285
|
+
// "querystring must have required property 'name'"
|
|
286
|
+
// "body must be object"
|
|
287
|
+
// "params.userId must be string"
|
|
288
|
+
let location = "body"; // default
|
|
289
|
+
let fieldPath = "";
|
|
290
|
+
let validationMessage = "Validation failed";
|
|
291
|
+
// Try to extract location from message
|
|
292
|
+
if (message.includes("querystring")) {
|
|
293
|
+
location = "query";
|
|
294
|
+
}
|
|
295
|
+
else if (message.includes("params")) {
|
|
296
|
+
location = "params";
|
|
297
|
+
}
|
|
298
|
+
else if (message.includes("headers")) {
|
|
299
|
+
location = "headers";
|
|
300
|
+
}
|
|
301
|
+
else if (message.includes("body")) {
|
|
302
|
+
location = "body";
|
|
303
|
+
}
|
|
304
|
+
// Extract field name from "must have required property 'fieldName'" pattern
|
|
305
|
+
const requiredMatch = message.match(/must have required property '([^']+)'/);
|
|
306
|
+
if (requiredMatch) {
|
|
307
|
+
fieldPath = requiredMatch[1];
|
|
308
|
+
validationMessage = `is a required property`;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// Extract field from "fieldName must be" pattern
|
|
312
|
+
const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
|
|
313
|
+
if (fieldMatch) {
|
|
314
|
+
fieldPath = fieldMatch[1];
|
|
315
|
+
}
|
|
316
|
+
validationMessage = message
|
|
317
|
+
.replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
|
|
318
|
+
.replace(" must ", " ");
|
|
319
|
+
}
|
|
320
|
+
if (fieldPath || message) {
|
|
321
|
+
details.push({
|
|
322
|
+
location,
|
|
323
|
+
path: fieldPath,
|
|
324
|
+
message: validationMessage,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
error: "ValidationError",
|
|
329
|
+
message: "Request validation failed",
|
|
330
|
+
status: 400,
|
|
331
|
+
details,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Legacy function - kept for compatibility but not used
|
|
336
|
+
* Converts framework-specific validation errors to standardized Telo format
|
|
337
|
+
* Returns null if the error is not a validation error
|
|
338
|
+
*/
|
|
339
|
+
function convertValidationError(error) {
|
|
340
|
+
// Check if this is a Fastify/AJV validation error
|
|
341
|
+
if (!error || typeof error !== "object") {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
// Fastify validation errors have a statusCode of 400 and validation array
|
|
345
|
+
if (error.statusCode === 400 && Array.isArray(error.validation)) {
|
|
346
|
+
const details = error.validation.map((err) => {
|
|
347
|
+
const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
|
|
348
|
+
// Determine location from keyword/message context
|
|
349
|
+
let location = "body"; // default
|
|
350
|
+
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
351
|
+
location = determinLocationFromContext(err);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
location = determinLocationFromContext(err);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
location,
|
|
358
|
+
path: path || err.params?.missingProperty || "",
|
|
359
|
+
message: err.message || "Validation failed",
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
error: "ValidationError",
|
|
364
|
+
message: "Request validation failed",
|
|
365
|
+
status: 400,
|
|
366
|
+
details,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Helper to determine the location (body, query, params, headers) from validation error context
|
|
373
|
+
*/
|
|
374
|
+
function determinLocationFromContext(err) {
|
|
375
|
+
// AJV validation errors in Fastify include parent keyword context
|
|
376
|
+
if (err.parentSchema && err.instancePath) {
|
|
377
|
+
const path = err.instancePath;
|
|
378
|
+
// This is a simplified check; in practice, Fastify provides better context
|
|
379
|
+
// For now, default to "body" for general validation errors
|
|
380
|
+
return "body";
|
|
381
|
+
}
|
|
382
|
+
return "body";
|
|
383
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Test file to validate error conversion logic
|
|
2
|
+
import { convertFastifyValidationError } from "./http-api-controller.js";
|
|
3
|
+
function testValidationError() {
|
|
4
|
+
// Test case 1: Querystring validation error
|
|
5
|
+
const error1 = {
|
|
6
|
+
code: "FST_ERR_VALIDATION",
|
|
7
|
+
message: "querystring must have required property 'name'",
|
|
8
|
+
statusCode: 400,
|
|
9
|
+
error: "Bad Request",
|
|
10
|
+
};
|
|
11
|
+
const result1 = convertFastifyValidationError(error1);
|
|
12
|
+
console.log("Test 1 - Querystring required field:", JSON.stringify(result1, null, 2));
|
|
13
|
+
console.log("✓ Location:", result1?.details[0].location === "query" ? "PASS" : "FAIL");
|
|
14
|
+
console.log("✓ Path:", result1?.details[0].path === "name" ? "PASS" : "FAIL");
|
|
15
|
+
console.log("✓ Message:", result1?.details[0].message.includes("required") ? "PASS" : "FAIL");
|
|
16
|
+
// Test case 2: Body validation error
|
|
17
|
+
const error2 = {
|
|
18
|
+
code: "FST_ERR_VALIDATION",
|
|
19
|
+
message: "body must be object",
|
|
20
|
+
statusCode: 400,
|
|
21
|
+
};
|
|
22
|
+
const result2 = convertFastifyValidationError(error2);
|
|
23
|
+
console.log("\nTest 2 - Body type error:", JSON.stringify(result2, null, 2));
|
|
24
|
+
console.log("✓ Location:", result2?.details[0].location === "body" ? "PASS" : "FAIL");
|
|
25
|
+
// Test case 3: Non-validation error (should return null)
|
|
26
|
+
const error3 = {
|
|
27
|
+
code: "ERR_OTHER",
|
|
28
|
+
message: "Some other error",
|
|
29
|
+
};
|
|
30
|
+
const result3 = convertFastifyValidationError(error3);
|
|
31
|
+
console.log("\nTest 3 - Non-validation error:", result3 === null ? "PASS (null)" : "FAIL (not null)");
|
|
32
|
+
}
|
|
33
|
+
// Only run if this file is executed directly
|
|
34
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
35
|
+
testValidationError();
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"fastify": "^5.7.2",
|
|
23
23
|
"@fastify/swagger": "^9.6.1",
|
|
24
24
|
"@scalar/fastify-api-reference": "^1.44.6",
|
|
25
|
-
"@telorun/sdk": "0.2.
|
|
25
|
+
"@telorun/sdk": "0.2.4"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@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(
|
|
@@ -56,6 +52,17 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
56
52
|
async init() {}
|
|
57
53
|
|
|
58
54
|
register(app: FastifyInstance, prefix = "") {
|
|
55
|
+
// Register custom error handler for validation errors
|
|
56
|
+
app.setErrorHandler((error, request, reply) => {
|
|
57
|
+
const mappedError = convertFastifyValidationError(error);
|
|
58
|
+
if (mappedError) {
|
|
59
|
+
reply.code(400);
|
|
60
|
+
return reply.send(mappedError);
|
|
61
|
+
}
|
|
62
|
+
// Let Fastify handle other errors normally
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
65
|
+
|
|
59
66
|
if (prefix) {
|
|
60
67
|
app.register(
|
|
61
68
|
async (scoped) => {
|
|
@@ -77,7 +84,12 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
77
84
|
|
|
78
85
|
private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
|
|
79
86
|
const handler = route.handler ? resolveHandlerName(route.handler) : null;
|
|
80
|
-
const
|
|
87
|
+
const translatedPath = translateOpenApiPath(route.request.path);
|
|
88
|
+
|
|
89
|
+
const schema: any = {
|
|
90
|
+
response: {},
|
|
91
|
+
};
|
|
92
|
+
|
|
81
93
|
if (route.request.schema?.query) {
|
|
82
94
|
schema.querystring = route.request.schema?.query;
|
|
83
95
|
}
|
|
@@ -90,6 +102,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
90
102
|
if (route.request.schema?.headers) {
|
|
91
103
|
schema.headers = route.request.schema?.headers;
|
|
92
104
|
}
|
|
105
|
+
|
|
93
106
|
schema.response = Object.keys(route.response.statuses).reduce(
|
|
94
107
|
(acc, status) => {
|
|
95
108
|
const statusConfig = route.response.statuses[status];
|
|
@@ -111,72 +124,126 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
111
124
|
);
|
|
112
125
|
|
|
113
126
|
app.route({
|
|
114
|
-
method: route.request.method,
|
|
115
|
-
url:
|
|
127
|
+
method: route.request.method as any,
|
|
128
|
+
url: translatedPath,
|
|
116
129
|
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
|
-
}
|
|
130
|
+
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
131
|
+
try {
|
|
132
|
+
// Normalize headers to lowercase
|
|
133
|
+
const normalizedHeaders = normalizeHeaders(request.headers);
|
|
134
|
+
|
|
135
|
+
// Construct standardized Telo request object
|
|
136
|
+
const requestPayload = {
|
|
137
|
+
method: request.method,
|
|
138
|
+
path: request.url,
|
|
139
|
+
params: request.params || {},
|
|
140
|
+
query: request.query || {},
|
|
141
|
+
headers: normalizedHeaders,
|
|
142
|
+
body: request.body,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Wrap in "request" object as per spec
|
|
146
|
+
const teloRequestContext = { request: requestPayload };
|
|
147
|
+
|
|
148
|
+
const result = handler
|
|
149
|
+
? await this.ctx.invoke(
|
|
150
|
+
handler.kind,
|
|
151
|
+
handler.name,
|
|
152
|
+
resolveHandlerInputs(route.handler, teloRequestContext),
|
|
153
|
+
)
|
|
154
|
+
: undefined;
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
156
|
+
const response = route.response;
|
|
157
|
+
|
|
158
|
+
// Determine final status code
|
|
159
|
+
let statusCode = response.status;
|
|
160
|
+
if (typeof statusCode === "string") {
|
|
161
|
+
statusCode = this.ctx.expandValue(statusCode, { result }) as number;
|
|
163
162
|
}
|
|
164
|
-
return reply.send(mappedBody);
|
|
165
|
-
}
|
|
166
163
|
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
// Convert status to string for lookup
|
|
165
|
+
const statusKey = String(statusCode);
|
|
166
|
+
const statusConfig = response.statuses[statusKey];
|
|
167
|
+
|
|
168
|
+
if (!statusConfig) {
|
|
169
|
+
reply.code(500);
|
|
170
|
+
return reply.send({
|
|
171
|
+
error: "InternalServerError",
|
|
172
|
+
message: "Response status configuration not found",
|
|
173
|
+
status: 500,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Set HTTP status code
|
|
178
|
+
reply.code(statusCode as number);
|
|
179
|
+
|
|
180
|
+
// Map and set response headers if specified
|
|
181
|
+
if (statusConfig.headers) {
|
|
182
|
+
const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
|
|
183
|
+
Object.entries(mappedHeaders).forEach(([key, value]) => {
|
|
184
|
+
reply.header(key, value as string);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Map and send response body if specified
|
|
189
|
+
if (statusConfig.body !== undefined) {
|
|
190
|
+
const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
|
|
191
|
+
|
|
192
|
+
// Validate response body if schema is specified
|
|
193
|
+
if (statusConfig.schema && statusConfig.schema.body) {
|
|
194
|
+
this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return reply.send(mappedBody);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// No body mapping, send result as-is
|
|
201
|
+
return reply.send(result);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// Let the error handler deal with all errors
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
169
206
|
},
|
|
170
207
|
});
|
|
171
208
|
}
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
export async function create(
|
|
175
|
-
|
|
176
|
-
ctx: ResourceContext,
|
|
177
|
-
): Promise<HttpServerApi> {
|
|
211
|
+
export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
|
|
212
|
+
// First validate with a permissive schema (handler can be any shape)
|
|
178
213
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
179
|
-
|
|
214
|
+
// Process routes and register unnamed handlers as child resources
|
|
215
|
+
let handlerCounter = 0;
|
|
216
|
+
const processedRoutes = (resource.routes || []).map((route: any) => {
|
|
217
|
+
if (!route.handler) {
|
|
218
|
+
return route;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check if handler is unnamed (inline handler)
|
|
222
|
+
if (typeof route.handler === "object" && !route.handler.name) {
|
|
223
|
+
// Use resolveChildren to register the unnamed handler and get its normalized reference
|
|
224
|
+
const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
|
|
225
|
+
|
|
226
|
+
// Return route with the resolved handler reference
|
|
227
|
+
return {
|
|
228
|
+
...route,
|
|
229
|
+
handler: {
|
|
230
|
+
kind: resolvedHandler.kind,
|
|
231
|
+
name: resolvedHandler.name,
|
|
232
|
+
inputs: route.handler.inputs,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return route;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Create the API instance with processed routes
|
|
241
|
+
const processedResource: HttpApiManifest = {
|
|
242
|
+
...resource,
|
|
243
|
+
routes: processedRoutes,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return new HttpServerApi(ctx, processedResource);
|
|
180
247
|
}
|
|
181
248
|
|
|
182
249
|
function resolveHandlerName(handler: any): { kind: string; name: string } {
|
|
@@ -184,29 +251,26 @@ function resolveHandlerName(handler: any): { kind: string; name: string } {
|
|
|
184
251
|
const [kind, name] = handler.split("/");
|
|
185
252
|
return { kind, name };
|
|
186
253
|
}
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
) {
|
|
193
|
-
return { name: handler.name, kind: handler.kind };
|
|
254
|
+
if (handler && typeof handler === "object" && typeof handler.kind === "string") {
|
|
255
|
+
// name should always be present after create() processes the routes
|
|
256
|
+
// but fallback gracefully if it's not
|
|
257
|
+
const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
|
|
258
|
+
return { name, kind: handler.kind };
|
|
194
259
|
}
|
|
195
|
-
throw new Error("Unable to resolve handler");
|
|
260
|
+
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
196
261
|
}
|
|
197
262
|
|
|
198
|
-
function resolveHandlerInputs(handler: any,
|
|
263
|
+
function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
|
|
199
264
|
if (typeof handler === "string") {
|
|
200
|
-
return
|
|
265
|
+
return requestContext;
|
|
201
266
|
}
|
|
202
267
|
if (!handler || typeof handler !== "object") {
|
|
203
|
-
return
|
|
268
|
+
return requestContext;
|
|
204
269
|
}
|
|
205
270
|
if (!handler.inputs) {
|
|
206
|
-
return
|
|
271
|
+
return requestContext;
|
|
207
272
|
}
|
|
208
|
-
|
|
209
|
-
return resolveTemplateInputs(handler.inputs, context);
|
|
273
|
+
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
210
274
|
}
|
|
211
275
|
|
|
212
276
|
function resolveTemplateInputs(value: any, context: Record<string, any>): any {
|
|
@@ -241,3 +305,144 @@ function resolveTemplatePath(pathExpression: string, context: Record<string, any
|
|
|
241
305
|
}
|
|
242
306
|
return current;
|
|
243
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
311
|
+
* Example: /api/v1/users/{userId} -> /api/v1/users/:userId
|
|
312
|
+
*/
|
|
313
|
+
function translateOpenApiPath(openApiPath: string): string {
|
|
314
|
+
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Normalizes all header keys to lowercase as per Telo spec
|
|
319
|
+
*/
|
|
320
|
+
function normalizeHeaders(headers: Record<string, any>): Record<string, any> {
|
|
321
|
+
const normalized: Record<string, any> = {};
|
|
322
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
323
|
+
normalized[key.toLowerCase()] = value;
|
|
324
|
+
}
|
|
325
|
+
return normalized;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Converts Fastify validation errors to standardized Telo format
|
|
330
|
+
* Returns null if the error is not a validation error
|
|
331
|
+
*/
|
|
332
|
+
function convertFastifyValidationError(error: any): Record<string, any> | null {
|
|
333
|
+
// Check if this is a Fastify validation error
|
|
334
|
+
if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const message = error.message || "";
|
|
339
|
+
const details = [];
|
|
340
|
+
|
|
341
|
+
// Parse Fastify validation error message to extract location and field
|
|
342
|
+
// Format examples:
|
|
343
|
+
// "querystring must have required property 'name'"
|
|
344
|
+
// "body must be object"
|
|
345
|
+
// "params.userId must be string"
|
|
346
|
+
|
|
347
|
+
let location = "body"; // default
|
|
348
|
+
let fieldPath = "";
|
|
349
|
+
let validationMessage = "Validation failed";
|
|
350
|
+
|
|
351
|
+
// Try to extract location from message
|
|
352
|
+
if (message.includes("querystring")) {
|
|
353
|
+
location = "query";
|
|
354
|
+
} else if (message.includes("params")) {
|
|
355
|
+
location = "params";
|
|
356
|
+
} else if (message.includes("headers")) {
|
|
357
|
+
location = "headers";
|
|
358
|
+
} else if (message.includes("body")) {
|
|
359
|
+
location = "body";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Extract field name from "must have required property 'fieldName'" pattern
|
|
363
|
+
const requiredMatch = message.match(/must have required property '([^']+)'/);
|
|
364
|
+
if (requiredMatch) {
|
|
365
|
+
fieldPath = requiredMatch[1];
|
|
366
|
+
validationMessage = `is a required property`;
|
|
367
|
+
} else {
|
|
368
|
+
// Extract field from "fieldName must be" pattern
|
|
369
|
+
const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
|
|
370
|
+
if (fieldMatch) {
|
|
371
|
+
fieldPath = fieldMatch[1];
|
|
372
|
+
}
|
|
373
|
+
validationMessage = message
|
|
374
|
+
.replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
|
|
375
|
+
.replace(" must ", " ");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (fieldPath || message) {
|
|
379
|
+
details.push({
|
|
380
|
+
location,
|
|
381
|
+
path: fieldPath,
|
|
382
|
+
message: validationMessage,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
error: "ValidationError",
|
|
388
|
+
message: "Request validation failed",
|
|
389
|
+
status: 400,
|
|
390
|
+
details,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Legacy function - kept for compatibility but not used
|
|
396
|
+
* Converts framework-specific validation errors to standardized Telo format
|
|
397
|
+
* Returns null if the error is not a validation error
|
|
398
|
+
*/
|
|
399
|
+
function convertValidationError(error: any): Record<string, any> | null {
|
|
400
|
+
// Check if this is a Fastify/AJV validation error
|
|
401
|
+
if (!error || typeof error !== "object") {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Fastify validation errors have a statusCode of 400 and validation array
|
|
406
|
+
if (error.statusCode === 400 && Array.isArray(error.validation)) {
|
|
407
|
+
const details = error.validation.map((err: any) => {
|
|
408
|
+
const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
|
|
409
|
+
|
|
410
|
+
// Determine location from keyword/message context
|
|
411
|
+
let location = "body"; // default
|
|
412
|
+
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
413
|
+
location = determinLocationFromContext(err);
|
|
414
|
+
} else {
|
|
415
|
+
location = determinLocationFromContext(err);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
location,
|
|
420
|
+
path: path || err.params?.missingProperty || "",
|
|
421
|
+
message: err.message || "Validation failed",
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
error: "ValidationError",
|
|
427
|
+
message: "Request validation failed",
|
|
428
|
+
status: 400,
|
|
429
|
+
details,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Helper to determine the location (body, query, params, headers) from validation error context
|
|
438
|
+
*/
|
|
439
|
+
function determinLocationFromContext(err: any): string {
|
|
440
|
+
// AJV validation errors in Fastify include parent keyword context
|
|
441
|
+
if (err.parentSchema && err.instancePath) {
|
|
442
|
+
const path = err.instancePath;
|
|
443
|
+
// This is a simplified check; in practice, Fastify provides better context
|
|
444
|
+
// For now, default to "body" for general validation errors
|
|
445
|
+
return "body";
|
|
446
|
+
}
|
|
447
|
+
return "body";
|
|
448
|
+
}
|