@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.
- package/CHANGELOG.md +16 -0
- package/dist/http-api-controller.d.ts +44 -14
- package/dist/http-api-controller.js +82 -285
- package/dist/http-server-controller.d.ts +12 -2
- package/dist/http-server-controller.js +142 -6
- package/package.json +5 -4
- package/src/http-api-controller.ts +117 -332
- package/src/http-server-controller.ts +178 -54
- package/dist/openapi.js +0 -152
- package/dist/test-validation.d.ts +0 -1
- package/dist/test-validation.js +0 -36
|
@@ -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
|
-
|
|
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:
|
|
26
|
+
this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default] } });
|
|
22
27
|
}
|
|
23
28
|
async init() {
|
|
24
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
+
"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
|
-
"@
|
|
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",
|