@aklinker1/zeta 1.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/LICENSE +21 -0
- package/README.md +655 -0
- package/package.json +60 -0
- package/src/adapters/zod-schema-adapter.ts +46 -0
- package/src/app.ts +453 -0
- package/src/client.ts +183 -0
- package/src/custom-responses.ts +166 -0
- package/src/errors.ts +543 -0
- package/src/index.ts +5 -0
- package/src/internal/call-handler.ts +132 -0
- package/src/internal/serialization.ts +72 -0
- package/src/internal/utils.ts +131 -0
- package/src/open-api.ts +234 -0
- package/src/status.ts +143 -0
- package/src/testing.ts +62 -0
- package/src/types.ts +1111 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main module used client-side in the same application. If you're frontend and
|
|
3
|
+
* backend are in separate projects, generate your client using the OpenAPI docs.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
BaseRoutes,
|
|
8
|
+
GetRequestParamsInput,
|
|
9
|
+
GetResponseOutput,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import type { ErrorResponse } from "./custom-responses";
|
|
12
|
+
import { smartDeserialize, smartSerialize } from "./internal/serialization";
|
|
13
|
+
import type {
|
|
14
|
+
GetAppRoutes,
|
|
15
|
+
App,
|
|
16
|
+
ApplyAppPrefix,
|
|
17
|
+
ApplyAppDataPrefix,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Type-safe client based on routes defined server-side.
|
|
22
|
+
*/
|
|
23
|
+
export interface AppClient<TRoutes extends BaseRoutes> {
|
|
24
|
+
fetch<TMethod extends keyof TRoutes, TRoute extends keyof TRoutes[TMethod]>(
|
|
25
|
+
method: TMethod,
|
|
26
|
+
route: TRoute,
|
|
27
|
+
inputs: GetRequestParamsInput<TRoutes, TMethod, TRoute>,
|
|
28
|
+
): Promise<GetResponseOutput<TRoutes, TMethod, TRoute>>;
|
|
29
|
+
fetch<TRoute extends keyof TRoutes["ANY"]>(
|
|
30
|
+
method: string,
|
|
31
|
+
route: TRoute,
|
|
32
|
+
inputs: GetRequestParamsInput<TRoutes, "ANY", TRoute>,
|
|
33
|
+
): Promise<GetResponseOutput<TRoutes, "ANY", TRoute>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a type-safe client based on the server-side app. This is only useful
|
|
38
|
+
* if your frontend is in the same TypeScript project as your backend, and you
|
|
39
|
+
* can reference it's types in the frontend.
|
|
40
|
+
*
|
|
41
|
+
* If that's not the case, generate your client using the OpenAPI docs.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* // Server-side:
|
|
46
|
+
* import { createApp } from "@aklinker1/zeta";
|
|
47
|
+
*
|
|
48
|
+
* const app = createApp();
|
|
49
|
+
* export type App = typeof app;
|
|
50
|
+
*
|
|
51
|
+
* // Client-side:
|
|
52
|
+
* import type { App } from "../server";
|
|
53
|
+
* // ^^^^ MAKE SURE TO ONLY IMPORT THE TYPE HERE
|
|
54
|
+
*
|
|
55
|
+
* const client = createAppClient<App>();
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function createAppClient<TApp extends App>(
|
|
59
|
+
options?: CreateAppClientOptions,
|
|
60
|
+
): AppClient<GetClientRoutes<TApp>> {
|
|
61
|
+
const {
|
|
62
|
+
baseUrl = location.origin,
|
|
63
|
+
fetch = globalThis.fetch,
|
|
64
|
+
headers = {},
|
|
65
|
+
} = options ?? {};
|
|
66
|
+
|
|
67
|
+
const buildSearchParams = (query: Record<string, unknown>) => {
|
|
68
|
+
return new URLSearchParams(
|
|
69
|
+
Object.entries(query)
|
|
70
|
+
.filter(([, value]) => value != null)
|
|
71
|
+
.map(([key, value]) => [key, String(value)]),
|
|
72
|
+
).toString();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const buildPath = (route: string, params: Record<string, unknown>) => {
|
|
76
|
+
return Object.entries(params).reduce(
|
|
77
|
+
(path, [key, value]) =>
|
|
78
|
+
path.replace(
|
|
79
|
+
key === "**" ? key : new RegExp(`\\*{2}:${key}|:${key}`),
|
|
80
|
+
encodeURIComponent(String(value)),
|
|
81
|
+
),
|
|
82
|
+
route,
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
async fetch(method: string, route: string, inputs: any) {
|
|
88
|
+
const searchParams =
|
|
89
|
+
inputs.query == null
|
|
90
|
+
? ""
|
|
91
|
+
: `?${buildSearchParams(inputs.query).toString()}`;
|
|
92
|
+
const path =
|
|
93
|
+
inputs.params == null ? route : buildPath(route, inputs.params);
|
|
94
|
+
const url = `${join(baseUrl, path)}${searchParams}`;
|
|
95
|
+
|
|
96
|
+
const init = {
|
|
97
|
+
body: undefined as BodyInit | undefined,
|
|
98
|
+
method: (method as string).toUpperCase(),
|
|
99
|
+
headers: { ...headers } as Record<string, string>,
|
|
100
|
+
} satisfies RequestInit;
|
|
101
|
+
|
|
102
|
+
const body =
|
|
103
|
+
inputs.body == null ? undefined : smartSerialize(inputs.body);
|
|
104
|
+
if (body) {
|
|
105
|
+
init.body = body.serialized;
|
|
106
|
+
init.headers["Content-Type"] = body.contentType;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(url, init);
|
|
111
|
+
const response = await smartDeserialize(res);
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
throw new RequestError(
|
|
114
|
+
(response as any)?.message ?? "Unknown error",
|
|
115
|
+
res.status,
|
|
116
|
+
response as ErrorResponse,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return response as any;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw Error("Fetch failed", { cause: err });
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Helper for converting an `App` to the routes it exposes.
|
|
129
|
+
*/
|
|
130
|
+
export type GetClientRoutes<TApp> =
|
|
131
|
+
TApp extends App<infer AppData>
|
|
132
|
+
? ApplyAppDataPrefix<AppData>["routes"]
|
|
133
|
+
: never;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Thrown by the client when the response is not OK. When an `HttpError` is
|
|
137
|
+
* thrown server-side, this is the error throw client-side.
|
|
138
|
+
*/
|
|
139
|
+
export class RequestError extends Error {
|
|
140
|
+
constructor(
|
|
141
|
+
message: string,
|
|
142
|
+
public status: number,
|
|
143
|
+
public response: ErrorResponse,
|
|
144
|
+
options?: ErrorOptions,
|
|
145
|
+
) {
|
|
146
|
+
super(message, options);
|
|
147
|
+
this.name = "RequestError";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Helper for converting an `App` type to `AppClient`.
|
|
153
|
+
*/
|
|
154
|
+
export type GetAppClient<TApp extends App> = App extends { prefix: string }
|
|
155
|
+
? GetAppClient<ApplyAppPrefix<TApp>>
|
|
156
|
+
: AppClient<
|
|
157
|
+
GetAppRoutes<TApp> extends BaseRoutes ? GetAppRoutes<TApp> : never
|
|
158
|
+
>;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Configure the client's behavior.
|
|
162
|
+
*/
|
|
163
|
+
export type CreateAppClientOptions = {
|
|
164
|
+
fetch?: typeof fetch;
|
|
165
|
+
/**
|
|
166
|
+
* Base URL used when making requests.
|
|
167
|
+
* @default location.origin
|
|
168
|
+
*/
|
|
169
|
+
baseUrl?: string;
|
|
170
|
+
/**
|
|
171
|
+
* List of headers to send on every request.
|
|
172
|
+
* @default {}
|
|
173
|
+
*/
|
|
174
|
+
headers?: Record<string, string>;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Join string together using `/` without double slashes. */
|
|
178
|
+
function join(...paths: string[]) {
|
|
179
|
+
return paths
|
|
180
|
+
.map((path) => path.replace(/^\/+|\/+$/g, ""))
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.join("/");
|
|
183
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { HttpStatus } from "./status";
|
|
3
|
+
|
|
4
|
+
export type ZetaSchema<Input = unknown, Output = Input> = StandardSchemaV1<
|
|
5
|
+
Input,
|
|
6
|
+
Output
|
|
7
|
+
> & {
|
|
8
|
+
"~zeta": {
|
|
9
|
+
type: string;
|
|
10
|
+
meta: Record<string, any>;
|
|
11
|
+
};
|
|
12
|
+
meta(meta?: Record<string, any>): ZetaSchema<Input, Output>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function createZetaSchema<Input = unknown, Output = Input>(
|
|
16
|
+
name: string,
|
|
17
|
+
validate: (value: unknown) => StandardSchemaV1.Result<Output>,
|
|
18
|
+
meta: Record<string, string> = {},
|
|
19
|
+
): ZetaSchema<Input, Output> {
|
|
20
|
+
const parentMeta = meta;
|
|
21
|
+
return {
|
|
22
|
+
"~zeta": {
|
|
23
|
+
type: name,
|
|
24
|
+
meta,
|
|
25
|
+
},
|
|
26
|
+
"~standard": {
|
|
27
|
+
vendor: "@aklinker/zeta",
|
|
28
|
+
version: 1,
|
|
29
|
+
validate,
|
|
30
|
+
},
|
|
31
|
+
meta(meta) {
|
|
32
|
+
return createZetaSchema(name, validate, {
|
|
33
|
+
...parentMeta,
|
|
34
|
+
...meta,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A schema for an error response. Use when defining additional status codes
|
|
42
|
+
* that an operation might return with:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { ErrorResponse } from '@aklinker/zeta';
|
|
46
|
+
*
|
|
47
|
+
* app.get(
|
|
48
|
+
* "/api/item/:itemId",
|
|
49
|
+
* {
|
|
50
|
+
* responses: {
|
|
51
|
+
* 200: Item.optional(),
|
|
52
|
+
* 404: ErrorResponse,
|
|
53
|
+
* }
|
|
54
|
+
* },
|
|
55
|
+
* () => {
|
|
56
|
+
* // ...
|
|
57
|
+
* }
|
|
58
|
+
* );
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export const ErrorResponse: ZetaSchema<unknown, ErrorResponse> =
|
|
62
|
+
createZetaSchema<unknown, ErrorResponse>(
|
|
63
|
+
"ErrorResponse",
|
|
64
|
+
(value: unknown): StandardSchemaV1.Result<ErrorResponse> => {
|
|
65
|
+
if (value == null)
|
|
66
|
+
return {
|
|
67
|
+
issues: [{ message: `Expected an object, received ${value}` }],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (typeof value !== "object")
|
|
71
|
+
return {
|
|
72
|
+
issues: [{ message: `Expected an object, received ${typeof value}` }],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const issues: StandardSchemaV1.Issue[] = [];
|
|
76
|
+
if (typeof (value as any).name !== "string") {
|
|
77
|
+
issues.push({
|
|
78
|
+
message: `Expected a string, received ${typeof (value as any).name}`,
|
|
79
|
+
path: ["name"],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (typeof (value as any).message !== "string") {
|
|
83
|
+
issues.push({
|
|
84
|
+
message: `Expected a string, received ${typeof (value as any).message}`,
|
|
85
|
+
path: ["message"],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (typeof (value as any).status !== "number") {
|
|
89
|
+
issues.push({
|
|
90
|
+
message: `Expected a number, received ${typeof (value as any).status}`,
|
|
91
|
+
path: ["status"],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (issues.length > 0) return { issues };
|
|
95
|
+
|
|
96
|
+
return { value: value as ErrorResponse };
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The actual type an error response conforms to.
|
|
102
|
+
*/
|
|
103
|
+
export type ErrorResponse = {
|
|
104
|
+
[additionalInfo: string]: any;
|
|
105
|
+
name: string;
|
|
106
|
+
message: string;
|
|
107
|
+
status: HttpStatus;
|
|
108
|
+
stack?: string[];
|
|
109
|
+
cause?: ErrorResponse;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const ErrorResponseJsonSchema = {
|
|
113
|
+
type: "object" as const,
|
|
114
|
+
properties: {
|
|
115
|
+
status: {
|
|
116
|
+
type: "number" as const,
|
|
117
|
+
description: "HTTP status code",
|
|
118
|
+
example: 400,
|
|
119
|
+
},
|
|
120
|
+
name: {
|
|
121
|
+
type: "string" as const,
|
|
122
|
+
description: "The error's name",
|
|
123
|
+
example: "Bad Request",
|
|
124
|
+
},
|
|
125
|
+
message: {
|
|
126
|
+
type: "string" as const,
|
|
127
|
+
description: "User-facing error message",
|
|
128
|
+
example: "Property 'name' is required",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ["status", "name", "message"],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A schema for when you want to not return a response. Use when defining
|
|
136
|
+
* additional status codes that an operation might return with:
|
|
137
|
+
*
|
|
138
|
+
* ```ts
|
|
139
|
+
* import { NoResponse } from '@aklinker/zeta';
|
|
140
|
+
*
|
|
141
|
+
* app.get(
|
|
142
|
+
* "/api/item/:itemId",
|
|
143
|
+
* {
|
|
144
|
+
* responses: {
|
|
145
|
+
* [HttpStatus.Accepted]: NoResponse,
|
|
146
|
+
* }
|
|
147
|
+
* },
|
|
148
|
+
* () => {
|
|
149
|
+
* // ...
|
|
150
|
+
* }
|
|
151
|
+
* );
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export const NoResponse: ZetaSchema<undefined | null | void, void> =
|
|
155
|
+
createZetaSchema<undefined | null | void, void>(
|
|
156
|
+
"NoResponse",
|
|
157
|
+
(value: unknown): StandardSchemaV1.Result<void> => {
|
|
158
|
+
return value != null
|
|
159
|
+
? {
|
|
160
|
+
issues: [
|
|
161
|
+
{ message: `Expected undefined or null, got ${typeof value}` },
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
: { value: undefined };
|
|
165
|
+
},
|
|
166
|
+
);
|