@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,26 +1,22 @@
1
1
  import swagger from "@fastify/swagger";
2
2
  import apiReference from "@scalar/fastify-api-reference";
3
- import type { ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
3
+ import type {
4
+ Invocable,
5
+ KindRef,
6
+ ResourceContext,
7
+ ResourceInstance,
8
+ RuntimeResource,
9
+ } from "@telorun/sdk";
10
+ import addFormats from "ajv-formats";
4
11
  import Fastify, { FastifyInstance } from "fastify";
5
- import { HttpServerApi } from "./http-api-controller.js";
6
-
7
- type HttpRouteResource = RuntimeResource & {
8
- metadata?: { path?: string; method?: string };
9
- path?: string;
10
- method?: string;
11
- handler?: HttpHandlerSpec;
12
- request?: HttpRequestSchema;
13
- response?: {
14
- status?: number;
15
- headers?: Record<string, string>;
16
- body?: any;
17
- };
18
- };
12
+ import { dispatchResponse, HttpServerApi, ResponseEntry } from "./http-api-controller.js";
19
13
 
20
14
  type HttpServerResource = RuntimeResource & {
21
15
  host?: string;
22
16
  port?: number;
23
17
  baseUrl?: string;
18
+ logger?: boolean;
19
+ contentTypeParsers?: Array<{ contentType: string; parser?: Invocable }>;
24
20
  openapi?: {
25
21
  info: {
26
22
  title: string;
@@ -31,70 +27,83 @@ type HttpServerResource = RuntimeResource & {
31
27
  path?: string;
32
28
  type?: string;
33
29
  }>;
30
+ notFoundHandler?: {
31
+ invoke: KindRef<Invocable>;
32
+ response?: ResponseEntry[];
33
+ };
34
34
  };
35
35
 
36
- type HttpApiResource = RuntimeResource & {
37
- routes?: Array<
38
- | string
39
- | {
40
- request?: {
41
- path?: string;
42
- method?: string;
43
- query?: Record<string, any>;
44
- body?: Record<string, any>;
45
- headers?: Record<string, any>;
46
- };
47
- handler?: HttpHandlerSpec;
48
- response?: {
49
- status?: number;
50
- headers?: Record<string, string>;
51
- body?: any;
52
- };
53
- }
54
- >;
55
- };
56
-
57
- type HttpHandlerSpec =
58
- | string
59
- | {
60
- name?: string;
61
- inputs?: Record<string, any>;
62
- };
63
-
64
- type HttpRequestSchema = {
65
- query?: Record<string, any>;
66
- body?: Record<string, any>;
67
- headers?: Record<string, any>;
36
+ type ResolvedHandler = {
37
+ kind: string;
38
+ name: string;
39
+ inputs: Record<string, any>;
40
+ response?: ResponseEntry[];
68
41
  };
69
42
 
70
43
  class HttpServer implements ResourceInstance {
71
44
  private releaseHold: (() => void) | null = null;
45
+ private pluginsInitialized = false;
72
46
  private readonly app: FastifyInstance;
73
47
  private readonly host: string;
74
48
  private readonly port: number;
75
49
  private readonly baseUrl: string;
76
50
  private readonly resource: HttpServerResource;
77
51
  private readonly ctx: ResourceContext;
52
+ private readonly resolvedNotFoundHandler: ResolvedHandler | null;
78
53
 
79
- constructor(resource: HttpServerResource, ctx: ResourceContext) {
54
+ constructor(
55
+ resource: HttpServerResource,
56
+ ctx: ResourceContext,
57
+ resolvedNotFoundHandler: ResolvedHandler | null = null,
58
+ ) {
80
59
  this.resource = resource;
81
60
  this.ctx = ctx;
82
61
  this.host = resource.host || "0.0.0.0";
83
62
  this.port = Number(resource.port || 0);
84
63
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
64
+ this.resolvedNotFoundHandler = resolvedNotFoundHandler;
85
65
 
86
66
  if (!this.port) {
87
67
  throw new Error("Http.Server port is required");
88
68
  }
89
- this.app = Fastify({ logger: true });
69
+ this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default as any] } });
90
70
  }
91
71
 
92
72
  async init() {
93
- this.setupPlugins();
73
+ if (!this.pluginsInitialized) {
74
+ await this.setupPlugins();
75
+ this.pluginsInitialized = true;
76
+ }
94
77
  this.setupRoutes();
95
78
  }
96
79
 
97
80
  private async setupPlugins() {
81
+ for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
82
+ if (parser) {
83
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
84
+ try {
85
+ done(null, await parser.invoke({ body }));
86
+ } catch (err) {
87
+ done(err as Error, undefined);
88
+ }
89
+ });
90
+ } else {
91
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, (_req, body, done) => {
92
+ done(null, body);
93
+ });
94
+ }
95
+ }
96
+
97
+ // Register custom error handler for validation errors
98
+ this.app.setErrorHandler((error, request, reply) => {
99
+ const mappedError = convertFastifyValidationError(error);
100
+ if (mappedError) {
101
+ reply.code(400);
102
+ return reply.send(mappedError);
103
+ }
104
+ // Let Fastify handle other errors normally
105
+ throw error;
106
+ });
98
107
  if (this.resource.openapi) {
99
108
  const servers = [];
100
109
  // const routesByName = new Map<string, HttpRouteResource>();
@@ -128,13 +137,52 @@ class HttpServer implements ResourceInstance {
128
137
  const { kind, name } = parseType(type);
129
138
  const prefix = mount.path || "";
130
139
 
131
- const api: HttpServerApi = this.ctx.getResourcesByName(kind, name) as any;
140
+ const api = this.ctx.moduleContext.getInvocable(name) as unknown as HttpServerApi;
132
141
 
133
142
  if (!api) {
134
143
  throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
135
144
  }
136
145
  api.register(this.app, prefix);
137
146
  }
147
+
148
+ if (this.resolvedNotFoundHandler) {
149
+ const handler = this.resolvedNotFoundHandler;
150
+ this.app.setNotFoundHandler(async (request, reply) => {
151
+ const normalizedHeaders: Record<string, any> = {};
152
+ for (const [key, value] of Object.entries(request.headers)) {
153
+ normalizedHeaders[key.toLowerCase()] = value;
154
+ }
155
+ const requestContext = {
156
+ request: {
157
+ method: request.method,
158
+ path: request.url,
159
+ params: request.params || {},
160
+ query: request.query || {},
161
+ headers: normalizedHeaders,
162
+ body: request.body,
163
+ },
164
+ };
165
+ const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
166
+ if (handler.response) {
167
+ return dispatchResponse(
168
+ handler.response,
169
+ result,
170
+ requestContext,
171
+ this.ctx.moduleContext,
172
+ this.ctx.validateSchema.bind(this.ctx),
173
+ reply,
174
+ );
175
+ }
176
+ const status = result?.status ?? 200;
177
+ reply.code(status);
178
+ if (result?.headers) {
179
+ Object.entries(result.headers).forEach(([key, value]) =>
180
+ reply.header(key, value as string),
181
+ );
182
+ }
183
+ return reply.send(result?.body ?? result);
184
+ });
185
+ }
138
186
  }
139
187
 
140
188
  async run(): Promise<void> {
@@ -167,11 +215,21 @@ class HttpServer implements ResourceInstance {
167
215
  }
168
216
  }
169
217
 
170
- export function create(
218
+ export async function create(
171
219
  resource: HttpServerResource,
172
220
  ctx: ResourceContext,
173
- ): ResourceInstance | null {
174
- return new HttpServer(resource, ctx);
221
+ ): Promise<ResourceInstance | null> {
222
+ let resolvedNotFoundHandler: ResolvedHandler | null = null;
223
+ if (resource.notFoundHandler) {
224
+ const resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
225
+ resolvedNotFoundHandler = {
226
+ kind: resolved.kind,
227
+ name: resolved.name,
228
+ inputs: (resource.notFoundHandler.invoke as any).inputs ?? {},
229
+ response: resource.notFoundHandler.response,
230
+ };
231
+ }
232
+ return new HttpServer(resource, ctx, resolvedNotFoundHandler);
175
233
  }
176
234
 
177
235
  function parseType(type: string): { kind: string; name: string } {
@@ -181,3 +239,69 @@ function parseType(type: string): { kind: string; name: string } {
181
239
  }
182
240
  return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
183
241
  }
242
+
243
+ /**
244
+ * Converts Fastify validation errors to standardized Telo format
245
+ * Returns null if the error is not a validation error
246
+ */
247
+ function convertFastifyValidationError(error: any): Record<string, any> | null {
248
+ // Check if this is a Fastify validation error
249
+ if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
250
+ return null;
251
+ }
252
+
253
+ const message = error.message || "";
254
+ const details = [];
255
+
256
+ // Parse Fastify validation error message to extract location and field
257
+ // Format examples:
258
+ // "querystring must have required property 'name'"
259
+ // "body must be object"
260
+ // "params.userId must be string"
261
+
262
+ let location = "body"; // default
263
+ let fieldPath = "";
264
+ let validationMessage = "Validation failed";
265
+
266
+ // Try to extract location from message
267
+ if (message.includes("querystring")) {
268
+ location = "query";
269
+ } else if (message.includes("params")) {
270
+ location = "params";
271
+ } else if (message.includes("headers")) {
272
+ location = "headers";
273
+ } else if (message.includes("body")) {
274
+ location = "body";
275
+ }
276
+
277
+ // Extract field name from "must have required property 'fieldName'" pattern
278
+ const requiredMatch = message.match(/must have required property '([^']+)'/);
279
+ if (requiredMatch) {
280
+ fieldPath = requiredMatch[1];
281
+ validationMessage = `is a required property`;
282
+ } else {
283
+ // Extract field from "fieldName must be" pattern
284
+ const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
285
+ if (fieldMatch) {
286
+ fieldPath = fieldMatch[1];
287
+ }
288
+ validationMessage = message
289
+ .replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
290
+ .replace(" must ", " ");
291
+ }
292
+
293
+ if (fieldPath || message) {
294
+ details.push({
295
+ location,
296
+ path: fieldPath,
297
+ message: validationMessage,
298
+ });
299
+ }
300
+
301
+ return {
302
+ error: "ValidationError",
303
+ message: "Request validation failed",
304
+ status: 400,
305
+ details,
306
+ };
307
+ }
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
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,36 +0,0 @@
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
- }