@telorun/http-server 0.1.3 → 0.1.5

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.
@@ -1,6 +1,8 @@
1
1
  import { Static, Type } from "@sinclair/typebox";
2
- import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { ControllerContext, Invocable, KindRef, Ref, ResourceContext, ResourceInstance } from "@telorun/sdk";
3
3
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
4
+ import { type Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
4
6
 
5
7
  const HttpApiRouteManifest = Type.Object({
6
8
  request: Type.Object({
@@ -15,24 +17,23 @@ const HttpApiRouteManifest = Type.Object({
15
17
  }),
16
18
  ),
17
19
  }),
18
- handler: Type.Optional(Type.Any()), // Any handler shape is allowed - will be processed in create()
19
- response: Type.Object({
20
- status: Type.Union([Type.Number({ minimum: 100, maximum: 599 }), Type.String()]),
21
- statuses: Type.Record(
22
- Type.String(),
23
- Type.Object({
24
- schema: Type.Optional(
25
- Type.Object({
26
- query: Type.Optional(Type.Any()),
27
- body: Type.Optional(Type.Any()),
28
- headers: Type.Optional(Type.Any()),
29
- }),
30
- ),
31
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
32
- body: Type.Optional(Type.Any()),
33
- }),
34
- ),
35
- }),
20
+ handler: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("kernel#Invocable"))),
21
+ response: Type.Array(
22
+ Type.Object({
23
+ status: Type.Integer({ minimum: 100, maximum: 599 }),
24
+ when: Type.Optional(Type.String()),
25
+ mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
26
+ schema: Type.Optional(
27
+ Type.Object({
28
+ query: Type.Optional(Type.Any()),
29
+ body: Type.Optional(Type.Any()),
30
+ headers: Type.Optional(Type.Any()),
31
+ }),
32
+ ),
33
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
34
+ body: Type.Optional(Type.Any()),
35
+ }),
36
+ ),
36
37
  });
37
38
  type HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
38
39
 
@@ -41,7 +42,74 @@ const HttpApiManifest = Type.Object({
41
42
  });
42
43
  type HttpApiManifest = Static<typeof HttpApiManifest>;
43
44
 
44
- export async function register(ctx: ControllerContext): Promise<void> {}
45
+ export async function register(_ctx: ControllerContext): Promise<void> {}
46
+
47
+ export type ResponseEntry = Static<
48
+ (typeof HttpApiRouteManifest)["properties"]["response"]["items"]
49
+ >;
50
+
51
+ export async function dispatchResponse(
52
+ response: ResponseEntry[],
53
+ result: unknown,
54
+ requestContext: Record<string, unknown>,
55
+ moduleContext: { expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown },
56
+ validateSchema: (value: unknown, schema: unknown) => void,
57
+ reply: FastifyReply,
58
+ ): Promise<void> {
59
+ let matched: ResponseEntry | undefined;
60
+ let fallback: ResponseEntry | undefined;
61
+
62
+ for (const entry of response) {
63
+ if (!entry.when) {
64
+ fallback ??= entry;
65
+ continue;
66
+ }
67
+ const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
68
+ if (condition === true) {
69
+ matched = entry;
70
+ break;
71
+ }
72
+ }
73
+
74
+ const statusEntry = matched ?? fallback;
75
+ if (!statusEntry) {
76
+ reply.code(500);
77
+ reply.send({
78
+ error: "InternalServerError",
79
+ message: "No matching response status entry",
80
+ status: 500,
81
+ });
82
+ return;
83
+ }
84
+
85
+ reply.code(statusEntry.status);
86
+
87
+ if (statusEntry.headers) {
88
+ const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
89
+ result,
90
+ ...requestContext,
91
+ }) as Record<string, unknown>;
92
+ Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value as string));
93
+ }
94
+
95
+ if (statusEntry.mode === "stream") {
96
+ reply.hijack();
97
+ reply.raw.writeHead(statusEntry.status, reply.getHeaders() as Record<string, string>);
98
+ await pipeline(result as Readable, reply.raw);
99
+ return;
100
+ }
101
+
102
+ if (statusEntry.body !== undefined) {
103
+ const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
104
+ if (statusEntry.schema?.body) {
105
+ validateSchema(mappedBody, statusEntry.schema.body);
106
+ }
107
+ reply.send(mappedBody);
108
+ return;
109
+ }
110
+
111
+ reply.send(result);
112
+ }
45
113
 
46
114
  export class HttpServerApi implements ResourceInstance {
47
115
  constructor(
@@ -52,17 +120,6 @@ export class HttpServerApi implements ResourceInstance {
52
120
  async init() {}
53
121
 
54
122
  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
-
66
123
  if (prefix) {
67
124
  app.register(
68
125
  async (scoped) => {
@@ -83,7 +140,8 @@ export class HttpServerApi implements ResourceInstance {
83
140
  }
84
141
 
85
142
  private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
86
- const handler = route.handler ? resolveHandlerName(route.handler) : null;
143
+ // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
144
+ const handler = route.handler as unknown as Invocable | undefined;
87
145
  const translatedPath = translateOpenApiPath(route.request.path);
88
146
 
89
147
  const schema: any = {
@@ -103,24 +161,16 @@ export class HttpServerApi implements ResourceInstance {
103
161
  schema.headers = route.request.schema?.headers;
104
162
  }
105
163
 
106
- schema.response = Object.keys(route.response.statuses).reduce(
107
- (acc, status) => {
108
- const statusConfig = route.response.statuses[status];
109
- if (statusConfig.schema) {
110
- acc[status] = {};
111
- if (statusConfig.schema.query) {
112
- acc[status].querystring = statusConfig.schema.query;
113
- }
114
- if (statusConfig.schema.body) {
115
- acc[status].body = statusConfig.schema.body;
116
- }
117
- if (statusConfig.schema.headers) {
118
- acc[status].headers = statusConfig.schema.headers;
119
- }
164
+ schema.response = route.response.reduce(
165
+ (acc, entry) => {
166
+ if (entry.schema?.body) {
167
+ acc[entry.status] = entry.schema.body;
168
+ } else if (entry.schema) {
169
+ acc[entry.status] = {};
120
170
  }
121
171
  return acc;
122
172
  },
123
- {} as Record<string, any>,
173
+ {} as Record<number, any>,
124
174
  );
125
175
 
126
176
  app.route({
@@ -129,76 +179,26 @@ export class HttpServerApi implements ResourceInstance {
129
179
  schema,
130
180
  handler: async (request: FastifyRequest, reply: FastifyReply) => {
131
181
  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,
182
+ const requestContext = {
183
+ request: {
184
+ method: request.method,
185
+ path: request.url,
186
+ params: request.params || {},
187
+ query: request.query || {},
188
+ headers: normalizeHeaders(request.headers),
189
+ body: request.body,
190
+ },
143
191
  };
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
-
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;
162
- }
163
-
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);
192
+ const result = handler ? await handler.invoke(requestContext) : undefined;
193
+
194
+ return dispatchResponse(
195
+ route.response,
196
+ result,
197
+ requestContext,
198
+ this.ctx.moduleContext,
199
+ this.ctx.validateSchema.bind(this.ctx),
200
+ reply,
201
+ );
202
202
  } catch (error) {
203
203
  // Let the error handler deal with all errors
204
204
  throw error;
@@ -209,101 +209,8 @@ export class HttpServerApi implements ResourceInstance {
209
209
  }
210
210
 
211
211
  export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
212
- // First validate with a permissive schema (handler can be any shape)
213
212
  ctx.validateSchema(resource, HttpApiManifest);
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);
247
- }
248
-
249
- function resolveHandlerName(handler: any): { kind: string; name: string } {
250
- if (typeof handler === "string") {
251
- const [kind, name] = handler.split("/");
252
- return { kind, name };
253
- }
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 };
259
- }
260
- throw new Error("Unable to resolve handler - handler must have a 'kind' property");
261
- }
262
-
263
- function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
264
- if (typeof handler === "string") {
265
- return requestContext;
266
- }
267
- if (!handler || typeof handler !== "object") {
268
- return requestContext;
269
- }
270
- if (!handler.inputs) {
271
- return requestContext;
272
- }
273
- return resolveTemplateInputs(handler.inputs, requestContext);
274
- }
275
-
276
- function resolveTemplateInputs(value: any, context: Record<string, any>): any {
277
- if (typeof value === "string") {
278
- const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
279
- if (match) {
280
- return resolveTemplatePath(match[1], context);
281
- }
282
- return value;
283
- }
284
- if (Array.isArray(value)) {
285
- return value.map((item) => resolveTemplateInputs(item, context));
286
- }
287
- if (value && typeof value === "object") {
288
- const resolved: Record<string, any> = {};
289
- for (const [key, entry] of Object.entries(value)) {
290
- resolved[key] = resolveTemplateInputs(entry, context);
291
- }
292
- return resolved;
293
- }
294
- return value;
295
- }
296
-
297
- function resolveTemplatePath(pathExpression: string, context: Record<string, any>): any {
298
- const parts = pathExpression.trim().split(".").filter(Boolean);
299
- let current: any = context;
300
- for (const part of parts) {
301
- if (!current || (typeof current !== "object" && typeof current !== "function")) {
302
- return undefined;
303
- }
304
- current = current[part];
305
- }
306
- return current;
213
+ return new HttpServerApi(ctx, resource);
307
214
  }
308
215
 
309
216
  /**
@@ -324,125 +231,3 @@ function normalizeHeaders(headers: Record<string, any>): Record<string, any> {
324
231
  }
325
232
  return normalized;
326
233
  }
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
- }