@aklinker1/zeta 1.3.2 → 2.0.0
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/README.md +21 -613
- package/package.json +8 -7
- package/src/adapters/zod-schema-adapter.ts +1 -18
- package/src/app.ts +63 -115
- package/src/client.ts +5 -4
- package/src/errors.ts +0 -14
- package/src/internal/compile-fetch-function.ts +157 -0
- package/src/internal/compile-route-handler.ts +190 -0
- package/src/internal/context.ts +47 -0
- package/src/internal/serialization.ts +30 -31
- package/src/internal/utils.ts +77 -46
- package/src/open-api.ts +33 -18
- package/src/schema.ts +2 -2
- package/src/types.ts +22 -24
- package/src/internal/call-handler.ts +0 -139
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { MaybePromise } from "elysia";
|
|
2
|
+
import type {
|
|
3
|
+
CompiledRouteHandler,
|
|
4
|
+
LifeCycleHookName,
|
|
5
|
+
LifeCycleHooks,
|
|
6
|
+
OnBeforeHandleContext,
|
|
7
|
+
RouteDef,
|
|
8
|
+
SchemaAdapter,
|
|
9
|
+
ServerSideFetch,
|
|
10
|
+
} from "../types";
|
|
11
|
+
import { smartDeserialize, smartSerialize } from "./serialization";
|
|
12
|
+
import {
|
|
13
|
+
cleanupCompiledWhitespace,
|
|
14
|
+
IsStatusResult,
|
|
15
|
+
validateInputSchema,
|
|
16
|
+
validateOutputSchema,
|
|
17
|
+
} from "./utils";
|
|
18
|
+
import { getMeta } from "../meta";
|
|
19
|
+
|
|
20
|
+
export function compileRouteHandler(
|
|
21
|
+
options: CompileOptions,
|
|
22
|
+
): CompiledRouteHandler {
|
|
23
|
+
if (options.fetch) {
|
|
24
|
+
return new Function(`
|
|
25
|
+
return (request, ctx) => ctx.matchedRoute.data.fetch(request)
|
|
26
|
+
//#sourceURL=${getSourceUrl(options)}
|
|
27
|
+
`)();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const responseContentTypeMap = getResponseContentTypeMap(options);
|
|
31
|
+
|
|
32
|
+
const js = `
|
|
33
|
+
return async (request, ctx) => {
|
|
34
|
+
${options.method === "GET" ? "" : ADD_CTX_BODY}
|
|
35
|
+
|
|
36
|
+
${options.hooks.onTransform?.length ? compileCtxModifierHookCall("onTransform", options.hooks.onTransform.length) : ""}
|
|
37
|
+
|
|
38
|
+
${options.def?.body ? "ctx.body = utils.validateInputSchema(ctx.matchedRoute.data.def.body, ctx.body);" : ""}
|
|
39
|
+
${options.def?.params ? "ctx.params = utils.validateInputSchema(ctx.matchedRoute.data.def.params, ctx.params);" : ""}
|
|
40
|
+
${options.def?.query ? "ctx.query = utils.validateInputSchema(ctx.matchedRoute.data.def.query, ctx.query);" : ""}
|
|
41
|
+
|
|
42
|
+
${options.hooks.onBeforeHandle?.length ? compileCtxModifierHookCall("onBeforeHandle", options.hooks.onBeforeHandle.length) : ""}
|
|
43
|
+
|
|
44
|
+
ctx.response = await ctx.matchedRoute.data.handler(ctx);
|
|
45
|
+
if (ctx.response) {
|
|
46
|
+
if (ctx.response[utils.IsStatusResult]) {
|
|
47
|
+
ctx.set.status = ctx.response.status;
|
|
48
|
+
ctx.response = ctx.response.body;
|
|
49
|
+
}
|
|
50
|
+
if (typeof ctx.response?.body?.bytes === utils.FUNCTION) return ctx.response;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
${compileValidateResponse(options)}
|
|
54
|
+
|
|
55
|
+
${options.hooks.onAfterHandle?.length ? compileResponseModifierHookCall("onAfterHandle", options.hooks.onAfterHandle.length) : ""}
|
|
56
|
+
|
|
57
|
+
${options.hooks.onMapResponse?.length ? compileResponseModifierHookCall("onMapResponse", options.hooks.onMapResponse.length) : ""}
|
|
58
|
+
|
|
59
|
+
if (ctx.response == null) {
|
|
60
|
+
return new Response(undefined, {
|
|
61
|
+
status: ctx.set.status,
|
|
62
|
+
headers: ctx.set.headers,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const serialized = utils.smartSerialize(ctx.response);
|
|
67
|
+
if (!ctx.set.headers["Content-Type"]) ctx.set.headers["Content-Type"] = ${responseContentTypeMap ? "responseContentTypeMap[ctx.set.status] ??" : ""} serialized.contentType
|
|
68
|
+
return new Response(serialized.value, {
|
|
69
|
+
status: ctx.set.status,
|
|
70
|
+
headers: ctx.set.headers,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
//#sourceURL=${getSourceUrl(options)}
|
|
74
|
+
`;
|
|
75
|
+
return new Function(
|
|
76
|
+
"utils",
|
|
77
|
+
"responseContentTypeMap",
|
|
78
|
+
cleanupCompiledWhitespace(js),
|
|
79
|
+
)(UTILS, responseContentTypeMap);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// These functions are available in the generated code via the "utils" object.
|
|
83
|
+
const UTILS = {
|
|
84
|
+
smartDeserialize,
|
|
85
|
+
smartSerialize,
|
|
86
|
+
FUNCTION: "function",
|
|
87
|
+
IsStatusResult,
|
|
88
|
+
validateInputSchema,
|
|
89
|
+
validateOutputSchema,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type CompileOptions = {
|
|
93
|
+
schemaAdapter: SchemaAdapter | undefined;
|
|
94
|
+
def: RouteDef | undefined;
|
|
95
|
+
method: string;
|
|
96
|
+
route: string;
|
|
97
|
+
hooks: LifeCycleHooks;
|
|
98
|
+
fetch?: ServerSideFetch;
|
|
99
|
+
handler?: (ctx: OnBeforeHandleContext) => MaybePromise<any>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function getSourceUrl(options: CompileOptions) {
|
|
103
|
+
return `zeta-jit-generated://${options.method.toLowerCase()}-${options.route.replace(/\s/gm, "").replaceAll("/", "-")}.js`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ADD_CTX_BODY = `
|
|
107
|
+
ctx.body = utils.smartDeserialize(request);
|
|
108
|
+
if (ctx.body) ctx.body = await ctx.body;
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
function compileCtxModifierHookCall(
|
|
112
|
+
hook: LifeCycleHookName,
|
|
113
|
+
hookCount: number,
|
|
114
|
+
): string {
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < hookCount; i++) {
|
|
118
|
+
const resultVar = `${hook}Res${i}`;
|
|
119
|
+
lines.push(
|
|
120
|
+
` const ${resultVar} = await ctx.matchedRoute.data.hooks.${hook}[${i}].callback(ctx);`,
|
|
121
|
+
` if (${resultVar})`,
|
|
122
|
+
` if (typeof ${resultVar}.body?.bytes === utils.FUNCTION)`,
|
|
123
|
+
` return ${resultVar};`,
|
|
124
|
+
` else`,
|
|
125
|
+
` for (const key of Object.keys(${resultVar}))`,
|
|
126
|
+
` ctx[key] = ${resultVar}[key];`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function compileResponseModifierHookCall(
|
|
134
|
+
hook: LifeCycleHookName,
|
|
135
|
+
hookCount: number,
|
|
136
|
+
): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < hookCount; i++) {
|
|
140
|
+
const resultVar = `${hook}Res${i}`;
|
|
141
|
+
lines.push(
|
|
142
|
+
` const ${resultVar} = await ctx.matchedRoute.data.hooks.${hook}[${i}].callback(ctx);`,
|
|
143
|
+
` if (${resultVar}) ctx.response = ${resultVar};`,
|
|
144
|
+
` if (typeof ${resultVar}.body?.bytes === utils.FUNCTION)`,
|
|
145
|
+
` return ${resultVar};`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compileValidateResponse(options: CompileOptions): string {
|
|
153
|
+
// No schemas defined
|
|
154
|
+
if (!options.def?.responses) return "";
|
|
155
|
+
|
|
156
|
+
// One schema defined
|
|
157
|
+
if ("~standard" in options.def.responses)
|
|
158
|
+
return "ctx.response = utils.validateOutputSchema(ctx.matchedRoute.data.def.responses, ctx.response);";
|
|
159
|
+
|
|
160
|
+
// Multiple schemas based on the status code
|
|
161
|
+
return "ctx.response = utils.validateOutputSchema(ctx.matchedRoute.data.def.responses[ctx.set.status], ctx.response);";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getResponseContentTypeMap(
|
|
165
|
+
options: CompileOptions,
|
|
166
|
+
): Record<number, string> | undefined {
|
|
167
|
+
// No schemas defined
|
|
168
|
+
if (!options.def?.responses) return;
|
|
169
|
+
|
|
170
|
+
// One schema defined
|
|
171
|
+
if ("~standard" in options.def.responses) {
|
|
172
|
+
const { contentType } = getMeta(
|
|
173
|
+
options.schemaAdapter,
|
|
174
|
+
options.def.responses,
|
|
175
|
+
);
|
|
176
|
+
if (!contentType) return;
|
|
177
|
+
|
|
178
|
+
return { [200]: contentType };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Multiple schemas based on the status code
|
|
182
|
+
const map: Record<number, string> = {};
|
|
183
|
+
for (const [status, schema] of Object.entries(options.def.responses)) {
|
|
184
|
+
const { contentType } = getMeta(options.schemaAdapter, schema);
|
|
185
|
+
map[Number(status)] = contentType;
|
|
186
|
+
}
|
|
187
|
+
if (Object.keys(map).length === 0) return;
|
|
188
|
+
|
|
189
|
+
return map;
|
|
190
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { MatchedRoute } from "rou3";
|
|
2
|
+
import { HttpStatus } from "../status";
|
|
3
|
+
import type { RouterData, StatusResult } from "../types";
|
|
4
|
+
import { getRawParams, getRawQuery, IsStatusResult } from "./utils";
|
|
5
|
+
|
|
6
|
+
export class Context {
|
|
7
|
+
set = {
|
|
8
|
+
status: HttpStatus.Ok,
|
|
9
|
+
headers: {},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
matchedRoute: MatchedRoute<RouterData> | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
public request: Request,
|
|
16
|
+
public path: string,
|
|
17
|
+
public origin: string,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
get url(): URL {
|
|
21
|
+
return new URL(this.request.url, this.origin);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get params(): Record<string, string> {
|
|
25
|
+
return this.matchedRoute?.params ? getRawParams(this.matchedRoute) : {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get query(): Record<string, string> {
|
|
29
|
+
return this.request.url.includes("?") ? getRawQuery(this.request) : {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get route(): string | undefined {
|
|
33
|
+
return this.matchedRoute?.data.route;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get method(): string {
|
|
37
|
+
return this.request.method;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
status(status: number, body?: unknown): StatusResult {
|
|
41
|
+
return {
|
|
42
|
+
[IsStatusResult]: true,
|
|
43
|
+
status,
|
|
44
|
+
body,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -1,72 +1,71 @@
|
|
|
1
1
|
export function smartSerialize(value: unknown):
|
|
2
2
|
| {
|
|
3
3
|
contentType: string | undefined;
|
|
4
|
-
|
|
4
|
+
value: BodyInit;
|
|
5
5
|
}
|
|
6
6
|
| undefined {
|
|
7
7
|
if (value == null) return undefined;
|
|
8
8
|
|
|
9
|
+
switch (typeof value) {
|
|
10
|
+
case "string":
|
|
11
|
+
return { contentType: "text/plain", value };
|
|
12
|
+
case "number":
|
|
13
|
+
case "boolean":
|
|
14
|
+
case "bigint":
|
|
15
|
+
return { contentType: "text/plain", value: String(value) };
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
if (value instanceof FormData) {
|
|
10
19
|
return {
|
|
11
20
|
contentType: undefined, // Let fetch set the content type with a boundary
|
|
12
|
-
|
|
21
|
+
value,
|
|
13
22
|
};
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
if (value instanceof File) {
|
|
17
|
-
const
|
|
18
|
-
|
|
26
|
+
const form = new FormData();
|
|
27
|
+
form.append("file", value);
|
|
19
28
|
return {
|
|
20
29
|
contentType: undefined,
|
|
21
|
-
|
|
30
|
+
value: form,
|
|
22
31
|
};
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
if (typeof FileList !== "undefined" && value instanceof FileList) {
|
|
26
|
-
const
|
|
35
|
+
const form = new FormData();
|
|
27
36
|
for (let i = 0; i < value.length; i++) {
|
|
28
|
-
|
|
37
|
+
form.append("files", value.item(i)!);
|
|
29
38
|
}
|
|
30
39
|
return {
|
|
31
40
|
contentType: undefined,
|
|
32
|
-
|
|
41
|
+
value: form,
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
if (value instanceof Blob) {
|
|
37
46
|
return {
|
|
38
47
|
contentType: value.type,
|
|
39
|
-
|
|
48
|
+
value,
|
|
40
49
|
};
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
case "string":
|
|
48
|
-
return { contentType: "text/plain", serialized: String(value) };
|
|
49
|
-
case "object":
|
|
50
|
-
return {
|
|
51
|
-
contentType: "application/json",
|
|
52
|
-
serialized: JSON.stringify(value),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
throw Error(
|
|
57
|
-
"Could not serialize object for request: " + JSON.stringify(value),
|
|
58
|
-
);
|
|
52
|
+
return {
|
|
53
|
+
contentType: "application/json",
|
|
54
|
+
value: JSON.stringify(value),
|
|
55
|
+
};
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
export
|
|
58
|
+
export function smartDeserialize(
|
|
62
59
|
arg: Response | Request,
|
|
63
|
-
): Promise<unknown> {
|
|
60
|
+
): Promise<unknown> | undefined {
|
|
61
|
+
if (arg instanceof Request && arg.method === "GET") return;
|
|
62
|
+
|
|
64
63
|
const contentType = arg.headers.get("content-type");
|
|
65
64
|
if (contentType == null) return;
|
|
66
65
|
|
|
67
66
|
// JSON
|
|
68
67
|
if (contentType.startsWith("application/json")) {
|
|
69
|
-
return
|
|
68
|
+
return arg.json();
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
// Forms
|
|
@@ -74,17 +73,17 @@ export async function smartDeserialize(
|
|
|
74
73
|
contentType.startsWith("application/x-www-form-urlencoded") ||
|
|
75
74
|
contentType.startsWith("multipart/form-data")
|
|
76
75
|
) {
|
|
77
|
-
return
|
|
76
|
+
return arg.formData();
|
|
78
77
|
}
|
|
79
78
|
|
|
80
79
|
// Text
|
|
81
80
|
if (contentType.startsWith("text/")) {
|
|
82
|
-
return
|
|
81
|
+
return arg.text();
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
// Binary
|
|
86
85
|
if (contentType.startsWith("application/octet-stream")) {
|
|
87
|
-
return
|
|
86
|
+
return arg.arrayBuffer();
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
// Unknown
|
package/src/internal/utils.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
-
import {
|
|
2
|
+
import type { MatchedRoute } from "rou3";
|
|
3
|
+
import { HttpError } from "../errors";
|
|
4
|
+
import type { ErrorResponse } from "../schema";
|
|
3
5
|
import { HttpStatus } from "../status";
|
|
6
|
+
import { createBunTransport } from "../transports/bun-transport";
|
|
7
|
+
import { createDenoTransport } from "../transports/deno-transport";
|
|
4
8
|
import type {
|
|
5
9
|
App,
|
|
6
10
|
LifeCycleHook,
|
|
@@ -9,38 +13,28 @@ import type {
|
|
|
9
13
|
StatusResult,
|
|
10
14
|
Transport,
|
|
11
15
|
} from "../types";
|
|
12
|
-
import type { MatchedRoute } from "rou3";
|
|
13
|
-
import type { ErrorResponse } from "../schema";
|
|
14
|
-
import { createBunTransport } from "../transports/bun-transport";
|
|
15
|
-
import { createDenoTransport } from "../transports/deno-transport";
|
|
16
16
|
|
|
17
17
|
export function validateSchema<T>(
|
|
18
18
|
schema: StandardSchemaV1<T, T>,
|
|
19
19
|
input: unknown,
|
|
20
|
+
status: number,
|
|
21
|
+
message: string,
|
|
20
22
|
): T {
|
|
21
|
-
|
|
23
|
+
const res = schema["~standard"].validate(input);
|
|
22
24
|
if (res instanceof Promise) throw Error("Async validation not supported");
|
|
23
25
|
|
|
24
|
-
if (res.issues)
|
|
26
|
+
if (res.issues)
|
|
27
|
+
throw new HttpError(status, message, {
|
|
28
|
+
issues: res.issues,
|
|
29
|
+
input: input,
|
|
30
|
+
});
|
|
25
31
|
|
|
26
32
|
return res.value;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
function createHttpSchemaValidator(status:
|
|
30
|
-
return <T>(schema: StandardSchemaV1<T, T>, input: unknown): T =>
|
|
31
|
-
|
|
32
|
-
return validateSchema<T>(schema, input);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
if (err instanceof ValidationGlobalError) {
|
|
35
|
-
throw new HttpError(status, message, {
|
|
36
|
-
issues: err.issues,
|
|
37
|
-
input: err.input,
|
|
38
|
-
});
|
|
39
|
-
} else {
|
|
40
|
-
throw err;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
};
|
|
35
|
+
function createHttpSchemaValidator(status: number, message: string) {
|
|
36
|
+
return <T>(schema: StandardSchemaV1<T, T>, input: unknown): T =>
|
|
37
|
+
validateSchema<T>(schema, input, status, message);
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
export const validateInputSchema = createHttpSchemaValidator(
|
|
@@ -56,32 +50,55 @@ export function isApp(obj: unknown): obj is App<any> {
|
|
|
56
50
|
return (obj as any)[Symbol.toStringTag] === "ZetaApp";
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
export function
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
53
|
+
export function getRawPathname(request: Request): string {
|
|
54
|
+
// Fast path for common case: http://host/path
|
|
55
|
+
const start = request.url.indexOf("/", 8); // Skip 'http://' or 'https://'
|
|
56
|
+
if (start === -1) return "/";
|
|
57
|
+
|
|
58
|
+
// Find end of pathname (before ? or #)
|
|
59
|
+
for (let i = start + 1; i < request.url.length; i++) {
|
|
60
|
+
if (request.url[i] === "?" || request.url[i] === "#") {
|
|
61
|
+
return request.url.slice(start, i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return request.url.slice(start);
|
|
65
|
+
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
export function getRawQuery(request: Request): Record<string, string> {
|
|
68
|
+
let index = request.url.indexOf("?");
|
|
69
|
+
if (index === -1) return {};
|
|
70
|
+
|
|
71
|
+
const res: Record<string, string> = {};
|
|
72
|
+
const str = request.url;
|
|
73
|
+
const len = str.length;
|
|
74
|
+
let start = index + 1;
|
|
75
|
+
|
|
76
|
+
for (let i = start; i < len; i++) {
|
|
77
|
+
if (str[i] === "&" || i === len - 1) {
|
|
78
|
+
const end = i === len - 1 ? len : i;
|
|
79
|
+
const eqIndex = str.indexOf("=", start);
|
|
80
|
+
if (eqIndex !== -1 && eqIndex < end) {
|
|
81
|
+
res[str.slice(start, eqIndex)] = str.slice(eqIndex + 1, end);
|
|
82
|
+
}
|
|
83
|
+
start = i + 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return res;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
export function getRawParams(
|
|
69
90
|
route: MatchedRoute<RouterData>,
|
|
70
91
|
): Record<string, string> {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const params = route.params;
|
|
93
|
+
if (!params) return {};
|
|
94
|
+
|
|
95
|
+
const res: Record<string, string> = {};
|
|
96
|
+
for (const key in params) {
|
|
97
|
+
// Rename rou3's _ to ** to match type-system
|
|
98
|
+
const outKey = key === "_" ? "**" : key;
|
|
99
|
+
res[outKey] = decodeURIComponent(params[key]);
|
|
76
100
|
}
|
|
77
|
-
|
|
78
|
-
// Decode all values automatically
|
|
79
|
-
return Object.fromEntries(
|
|
80
|
-
Object.entries(rawParams).map(([key, value]) => [
|
|
81
|
-
key,
|
|
82
|
-
decodeURIComponent(value),
|
|
83
|
-
]),
|
|
84
|
-
);
|
|
101
|
+
return res;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
export function getErrorStack(err: Error): string[] | undefined {
|
|
@@ -122,15 +139,17 @@ export function serializeErrorResponse(err: unknown): ErrorResponse {
|
|
|
122
139
|
|
|
123
140
|
export async function callCtxModifierHooks(
|
|
124
141
|
ctx: any,
|
|
125
|
-
hooks:
|
|
126
|
-
(ctx: any) => MaybePromise<Record<string, any> | void
|
|
127
|
-
|
|
142
|
+
hooks:
|
|
143
|
+
| LifeCycleHook<(ctx: any) => MaybePromise<Record<string, any> | void>>[]
|
|
144
|
+
| undefined,
|
|
128
145
|
): Promise<Response | undefined> {
|
|
146
|
+
if (!hooks) return;
|
|
147
|
+
|
|
129
148
|
for (const hook of hooks) {
|
|
130
149
|
let res = hook.callback(ctx);
|
|
131
|
-
|
|
150
|
+
if (res instanceof Promise) res = await res;
|
|
132
151
|
if (res instanceof Response) return res;
|
|
133
|
-
if (res) Object.assign(ctx, res);
|
|
152
|
+
if (res) Object.assign(ctx, res); // TODO: Replace with manual property setting for performance?
|
|
134
153
|
}
|
|
135
154
|
}
|
|
136
155
|
|
|
@@ -158,3 +177,15 @@ export function detectTransport(): Transport {
|
|
|
158
177
|
app.listen();
|
|
159
178
|
---`);
|
|
160
179
|
}
|
|
180
|
+
|
|
181
|
+
export function cleanupCompiledWhitespace(code: string): string {
|
|
182
|
+
return (
|
|
183
|
+
code
|
|
184
|
+
// Remove lines only containing spaces
|
|
185
|
+
.replace(/^ +$/gm, "")
|
|
186
|
+
// Reduce multiple newlines to one
|
|
187
|
+
.replace(/\n\n+/gm, "\n\n")
|
|
188
|
+
// Remove blank lines after curly braces
|
|
189
|
+
.replaceAll("{\n\n", "{\n")
|
|
190
|
+
);
|
|
191
|
+
}
|
package/src/open-api.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
1
2
|
import type { OpenAPI } from "openapi-types";
|
|
2
|
-
import
|
|
3
|
+
import { titleCase } from "scule";
|
|
3
4
|
import type { CreateAppOptions } from "./app";
|
|
4
|
-
import
|
|
5
|
-
import { getHttpStatusName } from "./status";
|
|
5
|
+
import { getMeta } from "./meta";
|
|
6
6
|
import {
|
|
7
7
|
ErrorResponseJsonSchema,
|
|
8
8
|
isZetaSchema,
|
|
9
9
|
type ZetaSchema,
|
|
10
10
|
} from "./schema";
|
|
11
|
-
import {
|
|
11
|
+
import { getHttpStatusName } from "./status";
|
|
12
|
+
import type { App, BasePath, SchemaAdapter } from "./types";
|
|
12
13
|
|
|
13
14
|
export function buildOpenApiDocs(
|
|
14
15
|
options: CreateAppOptions<any> | undefined,
|
|
@@ -39,11 +40,14 @@ export function buildOpenApiDocs(
|
|
|
39
40
|
};
|
|
40
41
|
for (const [method, methodEntry] of Object.entries(app["~zeta"].routes)) {
|
|
41
42
|
for (const [path, routerData] of Object.entries(methodEntry)) {
|
|
42
|
-
const openApiPath =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const openApiPath =
|
|
44
|
+
path
|
|
45
|
+
// Replace parameters with OpenAPI format
|
|
46
|
+
.replace(/\/:([^/]+)/g, "/{$1}")
|
|
47
|
+
// Remove trailing slash
|
|
48
|
+
.replace(/\/$/, "") ||
|
|
49
|
+
// Convert "" -> "/"
|
|
50
|
+
"/";
|
|
47
51
|
const { headers, params, query, body, responses, ...openApiOperation } =
|
|
48
52
|
routerData.def ?? {};
|
|
49
53
|
docs.paths ??= {};
|
|
@@ -51,6 +55,11 @@ export function buildOpenApiDocs(
|
|
|
51
55
|
|
|
52
56
|
(docs.paths[openApiPath] as any)[method.toLowerCase()] = {
|
|
53
57
|
...openApiOperation,
|
|
58
|
+
summary:
|
|
59
|
+
openApiOperation.summary ??
|
|
60
|
+
(openApiOperation.operationId
|
|
61
|
+
? titleCase(openApiOperation.operationId)
|
|
62
|
+
: undefined),
|
|
54
63
|
requestBody: body
|
|
55
64
|
? {
|
|
56
65
|
content: {
|
|
@@ -143,19 +152,25 @@ export function buildScalarHtml(
|
|
|
143
152
|
function mapParameters(
|
|
144
153
|
adapter: SchemaAdapter,
|
|
145
154
|
schema: StandardSchemaV1 | undefined,
|
|
146
|
-
|
|
155
|
+
in_: "query" | "path" | "header",
|
|
147
156
|
): OpenAPI.Parameters {
|
|
148
157
|
if (!schema) return [];
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
const openApiSchema = adapter.toJsonSchema(schema);
|
|
160
|
+
if (openApiSchema.type !== "object")
|
|
161
|
+
throw Error(
|
|
162
|
+
`Param in ${in_} must have { "type": "object", ... }, but got ${JSON.stringify(openApiSchema, null, 2)}`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return Object.entries(openApiSchema.properties).map(
|
|
166
|
+
([name, def]: [string, any]) => ({
|
|
153
167
|
name,
|
|
154
|
-
in:
|
|
155
|
-
description,
|
|
156
|
-
schema:
|
|
157
|
-
required:
|
|
158
|
-
})
|
|
168
|
+
in: in_,
|
|
169
|
+
description: def.description,
|
|
170
|
+
schema: def,
|
|
171
|
+
required: !!openApiSchema.required?.includes(name),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
function buildResponse(
|
package/src/schema.ts
CHANGED
|
@@ -222,8 +222,8 @@ export const UploadFileBody: ZetaSchema<File> = createZetaSchema<File>(
|
|
|
222
222
|
},
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
-
export const UploadFilesBody: ZetaSchema<
|
|
226
|
-
|
|
225
|
+
export const UploadFilesBody: ZetaSchema<FileList, File[]> = createZetaSchema<
|
|
226
|
+
FileList,
|
|
227
227
|
File[]
|
|
228
228
|
>(
|
|
229
229
|
"UploadFilesBody",
|