@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,30 +1,65 @@
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";
5
+ import { dispatchResponse } from "./http-api-controller.js";
4
6
  class HttpServer {
5
7
  releaseHold = null;
8
+ pluginsInitialized = false;
6
9
  app;
7
10
  host;
8
11
  port;
9
12
  baseUrl;
10
13
  resource;
11
14
  ctx;
12
- constructor(resource, ctx) {
15
+ resolvedNotFoundHandler;
16
+ constructor(resource, ctx, resolvedNotFoundHandler = null) {
13
17
  this.resource = resource;
14
18
  this.ctx = ctx;
15
19
  this.host = resource.host || "0.0.0.0";
16
20
  this.port = Number(resource.port || 0);
17
21
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
22
+ this.resolvedNotFoundHandler = resolvedNotFoundHandler;
18
23
  if (!this.port) {
19
24
  throw new Error("Http.Server port is required");
20
25
  }
21
- this.app = Fastify({ logger: true });
26
+ this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default] } });
22
27
  }
23
28
  async init() {
24
- this.setupPlugins();
29
+ if (!this.pluginsInitialized) {
30
+ await this.setupPlugins();
31
+ this.pluginsInitialized = true;
32
+ }
25
33
  this.setupRoutes();
26
34
  }
27
35
  async setupPlugins() {
36
+ for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
37
+ if (parser) {
38
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
39
+ try {
40
+ done(null, await parser.invoke({ body }));
41
+ }
42
+ catch (err) {
43
+ done(err, undefined);
44
+ }
45
+ });
46
+ }
47
+ else {
48
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, (_req, body, done) => {
49
+ done(null, body);
50
+ });
51
+ }
52
+ }
53
+ // Register custom error handler for validation errors
54
+ this.app.setErrorHandler((error, request, reply) => {
55
+ const mappedError = convertFastifyValidationError(error);
56
+ if (mappedError) {
57
+ reply.code(400);
58
+ return reply.send(mappedError);
59
+ }
60
+ // Let Fastify handle other errors normally
61
+ throw error;
62
+ });
28
63
  if (this.resource.openapi) {
29
64
  const servers = [];
30
65
  // const routesByName = new Map<string, HttpRouteResource>();
@@ -56,12 +91,41 @@ class HttpServer {
56
91
  const type = mount.type || "";
57
92
  const { kind, name } = parseType(type);
58
93
  const prefix = mount.path || "";
59
- const api = this.ctx.getResourcesByName(kind, name);
94
+ const api = this.ctx.moduleContext.getInvocable(name);
60
95
  if (!api) {
61
96
  throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
62
97
  }
63
98
  api.register(this.app, prefix);
64
99
  }
100
+ if (this.resolvedNotFoundHandler) {
101
+ const handler = this.resolvedNotFoundHandler;
102
+ this.app.setNotFoundHandler(async (request, reply) => {
103
+ const normalizedHeaders = {};
104
+ for (const [key, value] of Object.entries(request.headers)) {
105
+ normalizedHeaders[key.toLowerCase()] = value;
106
+ }
107
+ const requestContext = {
108
+ request: {
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
+ };
117
+ const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
118
+ if (handler.response) {
119
+ return dispatchResponse(handler.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
120
+ }
121
+ const status = result?.status ?? 200;
122
+ reply.code(status);
123
+ if (result?.headers) {
124
+ Object.entries(result.headers).forEach(([key, value]) => reply.header(key, value));
125
+ }
126
+ return reply.send(result?.body ?? result);
127
+ });
128
+ }
65
129
  }
66
130
  async run() {
67
131
  this.releaseHold = this.ctx.acquireHold();
@@ -92,8 +156,18 @@ class HttpServer {
92
156
  await this.app.close();
93
157
  }
94
158
  }
95
- export function create(resource, ctx) {
96
- return new HttpServer(resource, ctx);
159
+ export async function create(resource, ctx) {
160
+ let resolvedNotFoundHandler = null;
161
+ if (resource.notFoundHandler) {
162
+ const resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
163
+ resolvedNotFoundHandler = {
164
+ kind: resolved.kind,
165
+ name: resolved.name,
166
+ inputs: resource.notFoundHandler.invoke.inputs ?? {},
167
+ response: resource.notFoundHandler.response,
168
+ };
169
+ }
170
+ return new HttpServer(resource, ctx, resolvedNotFoundHandler);
97
171
  }
98
172
  function parseType(type) {
99
173
  const separator = type.lastIndexOf(".");
@@ -102,3 +176,65 @@ function parseType(type) {
102
176
  }
103
177
  return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
104
178
  }
179
+ /**
180
+ * Converts Fastify validation errors to standardized Telo format
181
+ * Returns null if the error is not a validation error
182
+ */
183
+ function convertFastifyValidationError(error) {
184
+ // Check if this is a Fastify validation error
185
+ if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
186
+ return null;
187
+ }
188
+ const message = error.message || "";
189
+ const details = [];
190
+ // Parse Fastify validation error message to extract location and field
191
+ // Format examples:
192
+ // "querystring must have required property 'name'"
193
+ // "body must be object"
194
+ // "params.userId must be string"
195
+ let location = "body"; // default
196
+ let fieldPath = "";
197
+ let validationMessage = "Validation failed";
198
+ // Try to extract location from message
199
+ if (message.includes("querystring")) {
200
+ location = "query";
201
+ }
202
+ else if (message.includes("params")) {
203
+ location = "params";
204
+ }
205
+ else if (message.includes("headers")) {
206
+ location = "headers";
207
+ }
208
+ else if (message.includes("body")) {
209
+ location = "body";
210
+ }
211
+ // Extract field name from "must have required property 'fieldName'" pattern
212
+ const requiredMatch = message.match(/must have required property '([^']+)'/);
213
+ if (requiredMatch) {
214
+ fieldPath = requiredMatch[1];
215
+ validationMessage = `is a required property`;
216
+ }
217
+ else {
218
+ // Extract field from "fieldName must be" pattern
219
+ const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
220
+ if (fieldMatch) {
221
+ fieldPath = fieldMatch[1];
222
+ }
223
+ validationMessage = message
224
+ .replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
225
+ .replace(" must ", " ");
226
+ }
227
+ if (fieldPath || message) {
228
+ details.push({
229
+ location,
230
+ path: fieldPath,
231
+ message: validationMessage,
232
+ });
233
+ }
234
+ return {
235
+ error: "ValidationError",
236
+ message: "Request validation failed",
237
+ status: 400,
238
+ details,
239
+ };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.4"
26
+ "@telorun/sdk": "0.2.6"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/node": "^20.0.0",