@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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Automated release.
8
+ - Updated dependencies
9
+ - @telorun/sdk@0.2.4
10
+
3
11
  ## 0.1.2
4
12
 
5
13
  ### 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").TObject<{
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: HttpApiManifest, ctx: ResourceContext): Promise<HttpServerApi>;
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.Object({
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 schema = {};
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: route.request.path,
101
+ url: translatedPath,
93
102
  schema,
94
103
  handler: async (request, reply) => {
95
- // const resolveSchema = createSchemaResolver(ctx);
96
- const requestPayload = {
97
- params: request.params,
98
- query: request.query,
99
- body: request.body,
100
- headers: request.headers,
101
- method: request.method,
102
- url: request.url,
103
- };
104
- // validateRequestSchemas(route.request, requestPayload, resolveSchema);
105
- const result = handler
106
- ? await this.ctx.invoke(handler.kind, handler.name, resolveHandlerInputs(route.handler, requestPayload))
107
- : undefined;
108
- // Handle response with body/headers mapping
109
- const response = route.response;
110
- // Set status
111
- const status = typeof response.status === "string"
112
- ? this.ctx.expandValue(response.status, { result })
113
- : response.status;
114
- if (response.status) {
115
- reply.code(status);
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
- return reply.send(mappedBody);
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
- return new HttpServerApi(ctx, resource);
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
- typeof handler === "object" &&
152
- typeof handler.name === "string" &&
153
- typeof handler.kind === "string") {
154
- return { name: handler.name, kind: handler.kind };
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, requestPayload) {
212
+ function resolveHandlerInputs(handler, requestContext) {
159
213
  if (typeof handler === "string") {
160
- return requestPayload;
214
+ return requestContext;
161
215
  }
162
216
  if (!handler || typeof handler !== "object") {
163
- return requestPayload;
217
+ return requestContext;
164
218
  }
165
219
  if (!handler.inputs) {
166
- return requestPayload;
220
+ return requestContext;
167
221
  }
168
- const context = { request: requestPayload, ...requestPayload };
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.2",
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.3"
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 { FastifyInstance } from "fastify";
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.Object({
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 schema: any = {};
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: route.request.path,
127
+ method: route.request.method as any,
128
+ url: translatedPath,
116
129
  schema,
117
- handler: async (request, reply) => {
118
- // const resolveSchema = createSchemaResolver(ctx);
119
- const requestPayload = {
120
- params: request.params,
121
- query: request.query,
122
- body: request.body,
123
- headers: request.headers,
124
- method: request.method,
125
- url: request.url,
126
- };
127
- // validateRequestSchemas(route.request, requestPayload, resolveSchema);
128
- const result = handler
129
- ? await this.ctx.invoke(
130
- handler.kind,
131
- handler.name,
132
- resolveHandlerInputs(route.handler, requestPayload),
133
- )
134
- : undefined;
135
- // Handle response with body/headers mapping
136
-
137
- const response = route.response;
138
-
139
- // Set status
140
- const status =
141
- typeof response.status === "string"
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
- // Map body if specified
157
- if (statusConfig.body !== undefined) {
158
- const mappedBody = this.ctx.expandValue(statusConfig.body, {
159
- result,
160
- });
161
- if (statusConfig.schema && statusConfig.schema.body) {
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
- // No body mapping, send result as-is
168
- return reply.send(result);
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
- resource: HttpApiManifest,
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
- return new HttpServerApi(ctx, resource);
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
- handler &&
189
- typeof handler === "object" &&
190
- typeof handler.name === "string" &&
191
- typeof handler.kind === "string"
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, requestPayload: Record<string, any>): any {
263
+ function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
199
264
  if (typeof handler === "string") {
200
- return requestPayload;
265
+ return requestContext;
201
266
  }
202
267
  if (!handler || typeof handler !== "object") {
203
- return requestPayload;
268
+ return requestContext;
204
269
  }
205
270
  if (!handler.inputs) {
206
- return requestPayload;
271
+ return requestContext;
207
272
  }
208
- const context = { request: requestPayload, ...requestPayload };
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
+ }