@telorun/http-server 0.1.4 → 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 +8 -0
- package/dist/http-api-controller.d.ts +44 -14
- package/dist/http-api-controller.js +82 -209
- package/dist/http-server-controller.d.ts +12 -2
- package/dist/http-server-controller.js +69 -6
- package/package.json +2 -2
- package/src/http-api-controller.ts +117 -250
- package/src/http-server-controller.ts +101 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import { Static } from "@sinclair/typebox";
|
|
2
|
-
import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
3
|
-
import { FastifyInstance } from "fastify";
|
|
2
|
+
import { ControllerContext, Invocable, KindRef, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
3
|
+
import { FastifyInstance, FastifyReply } from "fastify";
|
|
4
|
+
declare const HttpApiRouteManifest: import("@sinclair/typebox").TObject<{
|
|
5
|
+
request: import("@sinclair/typebox").TObject<{
|
|
6
|
+
path: import("@sinclair/typebox").TString;
|
|
7
|
+
method: import("@sinclair/typebox").TString;
|
|
8
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
9
|
+
params: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
10
|
+
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
11
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
12
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
13
|
+
}>>;
|
|
14
|
+
}>;
|
|
15
|
+
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
|
|
16
|
+
response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
17
|
+
status: import("@sinclair/typebox").TInteger;
|
|
18
|
+
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
19
|
+
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
|
|
20
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
21
|
+
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
22
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
23
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
24
|
+
}>>;
|
|
25
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
26
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
27
|
+
}>>;
|
|
28
|
+
}>;
|
|
29
|
+
type HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
|
|
4
30
|
declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
5
31
|
routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
6
32
|
request: import("@sinclair/typebox").TObject<{
|
|
@@ -13,23 +39,27 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
|
13
39
|
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
14
40
|
}>>;
|
|
15
41
|
}>;
|
|
16
|
-
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").
|
|
17
|
-
response: import("@sinclair/typebox").TObject<{
|
|
18
|
-
status: import("@sinclair/typebox").
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
24
|
-
}>>;
|
|
25
|
-
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
42
|
+
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
|
|
43
|
+
response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
44
|
+
status: import("@sinclair/typebox").TInteger;
|
|
45
|
+
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
46
|
+
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
|
|
47
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
48
|
+
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
26
49
|
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
50
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
27
51
|
}>>;
|
|
28
|
-
|
|
52
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
53
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
54
|
+
}>>;
|
|
29
55
|
}>>;
|
|
30
56
|
}>;
|
|
31
57
|
type HttpApiManifest = Static<typeof HttpApiManifest>;
|
|
32
|
-
export declare function register(
|
|
58
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
59
|
+
export type ResponseEntry = Static<(typeof HttpApiRouteManifest)["properties"]["response"]["items"]>;
|
|
60
|
+
export declare function dispatchResponse(response: ResponseEntry[], result: unknown, requestContext: Record<string, unknown>, moduleContext: {
|
|
61
|
+
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
62
|
+
}, validateSchema: (value: unknown, schema: unknown) => void, reply: FastifyReply): Promise<void>;
|
|
33
63
|
export declare class HttpServerApi implements ResourceInstance {
|
|
34
64
|
private readonly ctx;
|
|
35
65
|
readonly manifest: HttpApiManifest;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { Ref } from "@telorun/sdk";
|
|
3
|
+
import { pipeline } from "stream/promises";
|
|
2
4
|
const HttpApiRouteManifest = Type.Object({
|
|
3
5
|
request: Type.Object({
|
|
4
6
|
path: Type.String(),
|
|
@@ -10,24 +12,72 @@ const HttpApiRouteManifest = Type.Object({
|
|
|
10
12
|
headers: Type.Optional(Type.Any()),
|
|
11
13
|
})),
|
|
12
14
|
}),
|
|
13
|
-
handler: Type.Optional(Type.
|
|
14
|
-
response: Type.Object({
|
|
15
|
-
status: Type.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
headers: Type.Optional(Type.Any()),
|
|
21
|
-
})),
|
|
22
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
15
|
+
handler: Type.Optional(Type.Unsafe(Ref("kernel#Invocable"))),
|
|
16
|
+
response: Type.Array(Type.Object({
|
|
17
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
18
|
+
when: Type.Optional(Type.String()),
|
|
19
|
+
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
20
|
+
schema: Type.Optional(Type.Object({
|
|
21
|
+
query: Type.Optional(Type.Any()),
|
|
23
22
|
body: Type.Optional(Type.Any()),
|
|
23
|
+
headers: Type.Optional(Type.Any()),
|
|
24
24
|
})),
|
|
25
|
-
|
|
25
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
26
|
+
body: Type.Optional(Type.Any()),
|
|
27
|
+
})),
|
|
26
28
|
});
|
|
27
29
|
const HttpApiManifest = Type.Object({
|
|
28
30
|
routes: Type.Array(HttpApiRouteManifest),
|
|
29
31
|
});
|
|
30
|
-
export async function register(
|
|
32
|
+
export async function register(_ctx) { }
|
|
33
|
+
export async function dispatchResponse(response, result, requestContext, moduleContext, validateSchema, reply) {
|
|
34
|
+
let matched;
|
|
35
|
+
let fallback;
|
|
36
|
+
for (const entry of response) {
|
|
37
|
+
if (!entry.when) {
|
|
38
|
+
fallback ??= entry;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
|
|
42
|
+
if (condition === true) {
|
|
43
|
+
matched = entry;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const statusEntry = matched ?? fallback;
|
|
48
|
+
if (!statusEntry) {
|
|
49
|
+
reply.code(500);
|
|
50
|
+
reply.send({
|
|
51
|
+
error: "InternalServerError",
|
|
52
|
+
message: "No matching response status entry",
|
|
53
|
+
status: 500,
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
reply.code(statusEntry.status);
|
|
58
|
+
if (statusEntry.headers) {
|
|
59
|
+
const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
|
|
60
|
+
result,
|
|
61
|
+
...requestContext,
|
|
62
|
+
});
|
|
63
|
+
Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value));
|
|
64
|
+
}
|
|
65
|
+
if (statusEntry.mode === "stream") {
|
|
66
|
+
reply.hijack();
|
|
67
|
+
reply.raw.writeHead(statusEntry.status, reply.getHeaders());
|
|
68
|
+
await pipeline(result, reply.raw);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (statusEntry.body !== undefined) {
|
|
72
|
+
const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
|
|
73
|
+
if (statusEntry.schema?.body) {
|
|
74
|
+
validateSchema(mappedBody, statusEntry.schema.body);
|
|
75
|
+
}
|
|
76
|
+
reply.send(mappedBody);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
reply.send(result);
|
|
80
|
+
}
|
|
31
81
|
export class HttpServerApi {
|
|
32
82
|
ctx;
|
|
33
83
|
manifest;
|
|
@@ -53,7 +103,8 @@ export class HttpServerApi {
|
|
|
53
103
|
}
|
|
54
104
|
}
|
|
55
105
|
registerRoute(app, route) {
|
|
56
|
-
|
|
106
|
+
// After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
|
|
107
|
+
const handler = route.handler;
|
|
57
108
|
const translatedPath = translateOpenApiPath(route.request.path);
|
|
58
109
|
const schema = {
|
|
59
110
|
response: {},
|
|
@@ -70,15 +121,12 @@ export class HttpServerApi {
|
|
|
70
121
|
if (route.request.schema?.headers) {
|
|
71
122
|
schema.headers = route.request.schema?.headers;
|
|
72
123
|
}
|
|
73
|
-
schema.response =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
acc[status] = {};
|
|
81
|
-
}
|
|
124
|
+
schema.response = route.response.reduce((acc, entry) => {
|
|
125
|
+
if (entry.schema?.body) {
|
|
126
|
+
acc[entry.status] = entry.schema.body;
|
|
127
|
+
}
|
|
128
|
+
else if (entry.schema) {
|
|
129
|
+
acc[entry.status] = {};
|
|
82
130
|
}
|
|
83
131
|
return acc;
|
|
84
132
|
}, {});
|
|
@@ -88,59 +136,18 @@ export class HttpServerApi {
|
|
|
88
136
|
schema,
|
|
89
137
|
handler: async (request, reply) => {
|
|
90
138
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
body: request.body,
|
|
139
|
+
const requestContext = {
|
|
140
|
+
request: {
|
|
141
|
+
method: request.method,
|
|
142
|
+
path: request.url,
|
|
143
|
+
params: request.params || {},
|
|
144
|
+
query: request.query || {},
|
|
145
|
+
headers: normalizeHeaders(request.headers),
|
|
146
|
+
body: request.body,
|
|
147
|
+
},
|
|
101
148
|
};
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
}
|
|
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);
|
|
149
|
+
const result = handler ? await handler.invoke(requestContext) : undefined;
|
|
150
|
+
return dispatchResponse(route.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
144
151
|
}
|
|
145
152
|
catch (error) {
|
|
146
153
|
// Let the error handler deal with all errors
|
|
@@ -151,92 +158,8 @@ export class HttpServerApi {
|
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
export async function create(resource, ctx) {
|
|
154
|
-
// First validate with a permissive schema (handler can be any shape)
|
|
155
161
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
156
|
-
|
|
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);
|
|
184
|
-
}
|
|
185
|
-
function resolveHandlerName(handler) {
|
|
186
|
-
if (typeof handler === "string") {
|
|
187
|
-
const [kind, name] = handler.split("/");
|
|
188
|
-
return { kind, name };
|
|
189
|
-
}
|
|
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 };
|
|
195
|
-
}
|
|
196
|
-
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
197
|
-
}
|
|
198
|
-
function resolveHandlerInputs(handler, requestContext) {
|
|
199
|
-
if (typeof handler === "string") {
|
|
200
|
-
return requestContext;
|
|
201
|
-
}
|
|
202
|
-
if (!handler || typeof handler !== "object") {
|
|
203
|
-
return requestContext;
|
|
204
|
-
}
|
|
205
|
-
if (!handler.inputs) {
|
|
206
|
-
return requestContext;
|
|
207
|
-
}
|
|
208
|
-
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
209
|
-
}
|
|
210
|
-
function resolveTemplateInputs(value, context) {
|
|
211
|
-
if (typeof value === "string") {
|
|
212
|
-
const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
|
|
213
|
-
if (match) {
|
|
214
|
-
return resolveTemplatePath(match[1], context);
|
|
215
|
-
}
|
|
216
|
-
return value;
|
|
217
|
-
}
|
|
218
|
-
if (Array.isArray(value)) {
|
|
219
|
-
return value.map((item) => resolveTemplateInputs(item, context));
|
|
220
|
-
}
|
|
221
|
-
if (value && typeof value === "object") {
|
|
222
|
-
const resolved = {};
|
|
223
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
224
|
-
resolved[key] = resolveTemplateInputs(entry, context);
|
|
225
|
-
}
|
|
226
|
-
return resolved;
|
|
227
|
-
}
|
|
228
|
-
return value;
|
|
229
|
-
}
|
|
230
|
-
function resolveTemplatePath(pathExpression, context) {
|
|
231
|
-
const parts = pathExpression.trim().split(".").filter(Boolean);
|
|
232
|
-
let current = context;
|
|
233
|
-
for (const part of parts) {
|
|
234
|
-
if (!current || (typeof current !== "object" && typeof current !== "function")) {
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
237
|
-
current = current[part];
|
|
238
|
-
}
|
|
239
|
-
return current;
|
|
162
|
+
return new HttpServerApi(ctx, resource);
|
|
240
163
|
}
|
|
241
164
|
/**
|
|
242
165
|
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
@@ -255,53 +178,3 @@ function normalizeHeaders(headers) {
|
|
|
255
178
|
}
|
|
256
179
|
return normalized;
|
|
257
180
|
}
|
|
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,8 +1,14 @@
|
|
|
1
|
-
import type { ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
|
|
1
|
+
import type { Invocable, KindRef, ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
|
|
2
|
+
import { ResponseEntry } from "./http-api-controller.js";
|
|
2
3
|
type HttpServerResource = RuntimeResource & {
|
|
3
4
|
host?: string;
|
|
4
5
|
port?: number;
|
|
5
6
|
baseUrl?: string;
|
|
7
|
+
logger?: boolean;
|
|
8
|
+
contentTypeParsers?: Array<{
|
|
9
|
+
contentType: string;
|
|
10
|
+
parser?: Invocable;
|
|
11
|
+
}>;
|
|
6
12
|
openapi?: {
|
|
7
13
|
info: {
|
|
8
14
|
title: string;
|
|
@@ -13,6 +19,10 @@ type HttpServerResource = RuntimeResource & {
|
|
|
13
19
|
path?: string;
|
|
14
20
|
type?: string;
|
|
15
21
|
}>;
|
|
22
|
+
notFoundHandler?: {
|
|
23
|
+
invoke: KindRef<Invocable>;
|
|
24
|
+
response?: ResponseEntry[];
|
|
25
|
+
};
|
|
16
26
|
};
|
|
17
|
-
export declare function create(resource: HttpServerResource, ctx: ResourceContext): ResourceInstance | null
|
|
27
|
+
export declare function create(resource: HttpServerResource, ctx: ResourceContext): Promise<ResourceInstance | null>;
|
|
18
28
|
export {};
|
|
@@ -2,30 +2,54 @@ import swagger from "@fastify/swagger";
|
|
|
2
2
|
import apiReference from "@scalar/fastify-api-reference";
|
|
3
3
|
import addFormats from "ajv-formats";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
|
+
import { dispatchResponse } from "./http-api-controller.js";
|
|
5
6
|
class HttpServer {
|
|
6
7
|
releaseHold = null;
|
|
8
|
+
pluginsInitialized = false;
|
|
7
9
|
app;
|
|
8
10
|
host;
|
|
9
11
|
port;
|
|
10
12
|
baseUrl;
|
|
11
13
|
resource;
|
|
12
14
|
ctx;
|
|
13
|
-
|
|
15
|
+
resolvedNotFoundHandler;
|
|
16
|
+
constructor(resource, ctx, resolvedNotFoundHandler = null) {
|
|
14
17
|
this.resource = resource;
|
|
15
18
|
this.ctx = ctx;
|
|
16
19
|
this.host = resource.host || "0.0.0.0";
|
|
17
20
|
this.port = Number(resource.port || 0);
|
|
18
21
|
this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
|
|
22
|
+
this.resolvedNotFoundHandler = resolvedNotFoundHandler;
|
|
19
23
|
if (!this.port) {
|
|
20
24
|
throw new Error("Http.Server port is required");
|
|
21
25
|
}
|
|
22
|
-
this.app = Fastify({ logger:
|
|
26
|
+
this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default] } });
|
|
23
27
|
}
|
|
24
28
|
async init() {
|
|
25
|
-
|
|
29
|
+
if (!this.pluginsInitialized) {
|
|
30
|
+
await this.setupPlugins();
|
|
31
|
+
this.pluginsInitialized = true;
|
|
32
|
+
}
|
|
26
33
|
this.setupRoutes();
|
|
27
34
|
}
|
|
28
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
|
+
}
|
|
29
53
|
// Register custom error handler for validation errors
|
|
30
54
|
this.app.setErrorHandler((error, request, reply) => {
|
|
31
55
|
const mappedError = convertFastifyValidationError(error);
|
|
@@ -67,12 +91,41 @@ class HttpServer {
|
|
|
67
91
|
const type = mount.type || "";
|
|
68
92
|
const { kind, name } = parseType(type);
|
|
69
93
|
const prefix = mount.path || "";
|
|
70
|
-
const api = this.ctx.moduleContext.
|
|
94
|
+
const api = this.ctx.moduleContext.getInvocable(name);
|
|
71
95
|
if (!api) {
|
|
72
96
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
73
97
|
}
|
|
74
98
|
api.register(this.app, prefix);
|
|
75
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
|
+
}
|
|
76
129
|
}
|
|
77
130
|
async run() {
|
|
78
131
|
this.releaseHold = this.ctx.acquireHold();
|
|
@@ -103,8 +156,18 @@ class HttpServer {
|
|
|
103
156
|
await this.app.close();
|
|
104
157
|
}
|
|
105
158
|
}
|
|
106
|
-
export function create(resource, ctx) {
|
|
107
|
-
|
|
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);
|
|
108
171
|
}
|
|
109
172
|
function parseType(type) {
|
|
110
173
|
const separator = type.lastIndexOf(".");
|
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",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"ajv": "^8.17.1",
|
|
24
24
|
"ajv-formats": "^3.0.1",
|
|
25
25
|
"fastify": "^5.7.2",
|
|
26
|
-
"@telorun/sdk": "0.2.
|
|
26
|
+
"@telorun/sdk": "0.2.6"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^20.0.0",
|
|
@@ -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.
|
|
19
|
-
response: Type.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Type.String(),
|
|
23
|
-
Type.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
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(
|
|
@@ -72,7 +140,8 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
72
140
|
}
|
|
73
141
|
|
|
74
142
|
private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
|
|
75
|
-
|
|
143
|
+
// After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
|
|
144
|
+
const handler = route.handler as unknown as Invocable | undefined;
|
|
76
145
|
const translatedPath = translateOpenApiPath(route.request.path);
|
|
77
146
|
|
|
78
147
|
const schema: any = {
|
|
@@ -92,19 +161,16 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
92
161
|
schema.headers = route.request.schema?.headers;
|
|
93
162
|
}
|
|
94
163
|
|
|
95
|
-
schema.response =
|
|
96
|
-
(acc,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} else {
|
|
102
|
-
acc[status] = {};
|
|
103
|
-
}
|
|
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] = {};
|
|
104
170
|
}
|
|
105
171
|
return acc;
|
|
106
172
|
},
|
|
107
|
-
{} as Record<
|
|
173
|
+
{} as Record<number, any>,
|
|
108
174
|
);
|
|
109
175
|
|
|
110
176
|
app.route({
|
|
@@ -113,76 +179,26 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
113
179
|
schema,
|
|
114
180
|
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
115
181
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
headers: normalizedHeaders,
|
|
126
|
-
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
|
+
},
|
|
127
191
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
: undefined;
|
|
139
|
-
|
|
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;
|
|
146
|
-
}
|
|
147
|
-
|
|
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);
|
|
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
|
+
);
|
|
186
202
|
} catch (error) {
|
|
187
203
|
// Let the error handler deal with all errors
|
|
188
204
|
throw error;
|
|
@@ -193,101 +209,8 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
|
|
196
|
-
// First validate with a permissive schema (handler can be any shape)
|
|
197
212
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
198
|
-
|
|
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);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function resolveHandlerName(handler: any): { kind: string; name: string } {
|
|
234
|
-
if (typeof handler === "string") {
|
|
235
|
-
const [kind, name] = handler.split("/");
|
|
236
|
-
return { kind, name };
|
|
237
|
-
}
|
|
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 };
|
|
243
|
-
}
|
|
244
|
-
throw new Error("Unable to resolve handler - handler must have a 'kind' property");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
|
|
248
|
-
if (typeof handler === "string") {
|
|
249
|
-
return requestContext;
|
|
250
|
-
}
|
|
251
|
-
if (!handler || typeof handler !== "object") {
|
|
252
|
-
return requestContext;
|
|
253
|
-
}
|
|
254
|
-
if (!handler.inputs) {
|
|
255
|
-
return requestContext;
|
|
256
|
-
}
|
|
257
|
-
return resolveTemplateInputs(handler.inputs, requestContext);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function resolveTemplateInputs(value: any, context: Record<string, any>): any {
|
|
261
|
-
if (typeof value === "string") {
|
|
262
|
-
const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
|
|
263
|
-
if (match) {
|
|
264
|
-
return resolveTemplatePath(match[1], context);
|
|
265
|
-
}
|
|
266
|
-
return value;
|
|
267
|
-
}
|
|
268
|
-
if (Array.isArray(value)) {
|
|
269
|
-
return value.map((item) => resolveTemplateInputs(item, context));
|
|
270
|
-
}
|
|
271
|
-
if (value && typeof value === "object") {
|
|
272
|
-
const resolved: Record<string, any> = {};
|
|
273
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
274
|
-
resolved[key] = resolveTemplateInputs(entry, context);
|
|
275
|
-
}
|
|
276
|
-
return resolved;
|
|
277
|
-
}
|
|
278
|
-
return value;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function resolveTemplatePath(pathExpression: string, context: Record<string, any>): any {
|
|
282
|
-
const parts = pathExpression.trim().split(".").filter(Boolean);
|
|
283
|
-
let current: any = context;
|
|
284
|
-
for (const part of parts) {
|
|
285
|
-
if (!current || (typeof current !== "object" && typeof current !== "function")) {
|
|
286
|
-
return undefined;
|
|
287
|
-
}
|
|
288
|
-
current = current[part];
|
|
289
|
-
}
|
|
290
|
-
return current;
|
|
213
|
+
return new HttpServerApi(ctx, resource);
|
|
291
214
|
}
|
|
292
215
|
|
|
293
216
|
/**
|
|
@@ -308,59 +231,3 @@ function normalizeHeaders(headers: Record<string, any>): Record<string, any> {
|
|
|
308
231
|
}
|
|
309
232
|
return normalized;
|
|
310
233
|
}
|
|
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,27 +1,22 @@
|
|
|
1
1
|
import swagger from "@fastify/swagger";
|
|
2
2
|
import apiReference from "@scalar/fastify-api-reference";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Invocable,
|
|
5
|
+
KindRef,
|
|
6
|
+
ResourceContext,
|
|
7
|
+
ResourceInstance,
|
|
8
|
+
RuntimeResource,
|
|
9
|
+
} from "@telorun/sdk";
|
|
4
10
|
import addFormats from "ajv-formats";
|
|
5
11
|
import Fastify, { FastifyInstance } from "fastify";
|
|
6
|
-
import { HttpServerApi } from "./http-api-controller.js";
|
|
7
|
-
|
|
8
|
-
type HttpRouteResource = RuntimeResource & {
|
|
9
|
-
metadata?: { path?: string; method?: string };
|
|
10
|
-
path?: string;
|
|
11
|
-
method?: string;
|
|
12
|
-
handler?: HttpHandlerSpec;
|
|
13
|
-
request?: HttpRequestSchema;
|
|
14
|
-
response?: {
|
|
15
|
-
status?: number;
|
|
16
|
-
headers?: Record<string, string>;
|
|
17
|
-
body?: any;
|
|
18
|
-
};
|
|
19
|
-
};
|
|
12
|
+
import { dispatchResponse, HttpServerApi, ResponseEntry } from "./http-api-controller.js";
|
|
20
13
|
|
|
21
14
|
type HttpServerResource = RuntimeResource & {
|
|
22
15
|
host?: string;
|
|
23
16
|
port?: number;
|
|
24
17
|
baseUrl?: string;
|
|
18
|
+
logger?: boolean;
|
|
19
|
+
contentTypeParsers?: Array<{ contentType: string; parser?: Invocable }>;
|
|
25
20
|
openapi?: {
|
|
26
21
|
info: {
|
|
27
22
|
title: string;
|
|
@@ -32,70 +27,73 @@ type HttpServerResource = RuntimeResource & {
|
|
|
32
27
|
path?: string;
|
|
33
28
|
type?: string;
|
|
34
29
|
}>;
|
|
30
|
+
notFoundHandler?: {
|
|
31
|
+
invoke: KindRef<Invocable>;
|
|
32
|
+
response?: ResponseEntry[];
|
|
33
|
+
};
|
|
35
34
|
};
|
|
36
35
|
|
|
37
|
-
type
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
path?: string;
|
|
43
|
-
method?: string;
|
|
44
|
-
query?: Record<string, any>;
|
|
45
|
-
body?: Record<string, any>;
|
|
46
|
-
headers?: Record<string, any>;
|
|
47
|
-
};
|
|
48
|
-
handler?: HttpHandlerSpec;
|
|
49
|
-
response?: {
|
|
50
|
-
status?: number;
|
|
51
|
-
headers?: Record<string, string>;
|
|
52
|
-
body?: any;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
>;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
type HttpHandlerSpec =
|
|
59
|
-
| string
|
|
60
|
-
| {
|
|
61
|
-
name?: string;
|
|
62
|
-
inputs?: Record<string, any>;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
type HttpRequestSchema = {
|
|
66
|
-
query?: Record<string, any>;
|
|
67
|
-
body?: Record<string, any>;
|
|
68
|
-
headers?: Record<string, any>;
|
|
36
|
+
type ResolvedHandler = {
|
|
37
|
+
kind: string;
|
|
38
|
+
name: string;
|
|
39
|
+
inputs: Record<string, any>;
|
|
40
|
+
response?: ResponseEntry[];
|
|
69
41
|
};
|
|
70
42
|
|
|
71
43
|
class HttpServer implements ResourceInstance {
|
|
72
44
|
private releaseHold: (() => void) | null = null;
|
|
45
|
+
private pluginsInitialized = false;
|
|
73
46
|
private readonly app: FastifyInstance;
|
|
74
47
|
private readonly host: string;
|
|
75
48
|
private readonly port: number;
|
|
76
49
|
private readonly baseUrl: string;
|
|
77
50
|
private readonly resource: HttpServerResource;
|
|
78
51
|
private readonly ctx: ResourceContext;
|
|
52
|
+
private readonly resolvedNotFoundHandler: ResolvedHandler | null;
|
|
79
53
|
|
|
80
|
-
constructor(
|
|
54
|
+
constructor(
|
|
55
|
+
resource: HttpServerResource,
|
|
56
|
+
ctx: ResourceContext,
|
|
57
|
+
resolvedNotFoundHandler: ResolvedHandler | null = null,
|
|
58
|
+
) {
|
|
81
59
|
this.resource = resource;
|
|
82
60
|
this.ctx = ctx;
|
|
83
61
|
this.host = resource.host || "0.0.0.0";
|
|
84
62
|
this.port = Number(resource.port || 0);
|
|
85
63
|
this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
|
|
64
|
+
this.resolvedNotFoundHandler = resolvedNotFoundHandler;
|
|
86
65
|
|
|
87
66
|
if (!this.port) {
|
|
88
67
|
throw new Error("Http.Server port is required");
|
|
89
68
|
}
|
|
90
|
-
this.app = Fastify({ logger:
|
|
69
|
+
this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default as any] } });
|
|
91
70
|
}
|
|
92
71
|
|
|
93
72
|
async init() {
|
|
94
|
-
|
|
73
|
+
if (!this.pluginsInitialized) {
|
|
74
|
+
await this.setupPlugins();
|
|
75
|
+
this.pluginsInitialized = true;
|
|
76
|
+
}
|
|
95
77
|
this.setupRoutes();
|
|
96
78
|
}
|
|
97
79
|
|
|
98
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
|
+
|
|
99
97
|
// Register custom error handler for validation errors
|
|
100
98
|
this.app.setErrorHandler((error, request, reply) => {
|
|
101
99
|
const mappedError = convertFastifyValidationError(error);
|
|
@@ -139,13 +137,52 @@ class HttpServer implements ResourceInstance {
|
|
|
139
137
|
const { kind, name } = parseType(type);
|
|
140
138
|
const prefix = mount.path || "";
|
|
141
139
|
|
|
142
|
-
const api = this.ctx.moduleContext.
|
|
140
|
+
const api = this.ctx.moduleContext.getInvocable(name) as unknown as HttpServerApi;
|
|
143
141
|
|
|
144
142
|
if (!api) {
|
|
145
143
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
146
144
|
}
|
|
147
145
|
api.register(this.app, prefix);
|
|
148
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
|
+
}
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
async run(): Promise<void> {
|
|
@@ -178,11 +215,21 @@ class HttpServer implements ResourceInstance {
|
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
|
|
181
|
-
export function create(
|
|
218
|
+
export async function create(
|
|
182
219
|
resource: HttpServerResource,
|
|
183
220
|
ctx: ResourceContext,
|
|
184
|
-
): ResourceInstance | null {
|
|
185
|
-
|
|
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);
|
|
186
233
|
}
|
|
187
234
|
|
|
188
235
|
function parseType(type: string): { kind: string; name: string } {
|