@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 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").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({
@@ -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 schema = {};
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].body = statusConfig.schema.body;
77
+ acc[status] = statusConfig.schema.body;
83
78
  }
84
- if (statusConfig.schema.headers) {
85
- acc[status].headers = statusConfig.schema.headers;
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: route.request.path,
87
+ url: translatedPath,
93
88
  schema,
94
89
  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);
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
- return reply.send(mappedBody);
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
- return new HttpServerApi(ctx, resource);
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
- typeof handler === "object" &&
152
- typeof handler.name === "string" &&
153
- typeof handler.kind === "string") {
154
- return { name: handler.name, kind: handler.kind };
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, requestPayload) {
198
+ function resolveHandlerInputs(handler, requestContext) {
159
199
  if (typeof handler === "string") {
160
- return requestPayload;
200
+ return requestContext;
161
201
  }
162
202
  if (!handler || typeof handler !== "object") {
163
- return requestPayload;
203
+ return requestContext;
164
204
  }
165
205
  if (!handler.inputs) {
166
- return requestPayload;
206
+ return requestContext;
167
207
  }
168
- const context = { request: requestPayload, ...requestPayload };
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.getResourcesByName(kind, name);
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.2",
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
- "@fastify/swagger": "^9.6.1",
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 { 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(
@@ -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 schema: any = {};
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].body = statusConfig.schema.body;
103
- }
104
- if (statusConfig.schema.headers) {
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: route.request.path,
111
+ method: route.request.method as any,
112
+ url: translatedPath,
116
113
  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
- }
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
- // 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);
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
- // No body mapping, send result as-is
168
- return reply.send(result);
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
- resource: HttpApiManifest,
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
- return new HttpServerApi(ctx, resource);
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
- handler &&
189
- typeof handler === "object" &&
190
- typeof handler.name === "string" &&
191
- typeof handler.kind === "string"
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, requestPayload: Record<string, any>): any {
247
+ function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
199
248
  if (typeof handler === "string") {
200
- return requestPayload;
249
+ return requestContext;
201
250
  }
202
251
  if (!handler || typeof handler !== "object") {
203
- return requestPayload;
252
+ return requestContext;
204
253
  }
205
254
  if (!handler.inputs) {
206
- return requestPayload;
255
+ return requestContext;
207
256
  }
208
- const context = { request: requestPayload, ...requestPayload };
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: HttpServerApi = this.ctx.getResourcesByName(kind, name) as any;
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
- }