Skip to content

Commit 9d989b6

Browse files
authored
Add type-safe support for user-defined functions and API endpoints (#16)
1 parent 1b863c7 commit 9d989b6

11 files changed

Lines changed: 1344 additions & 1 deletion

File tree

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./error";
22
export * from "./functions/standalone";
3+
export * from "./query/api";
34
export * from "./query/batch";
45
export * from "./query/create";
56
export * from "./query/delete";

src/query/api.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import type { SurrealSession } from "surrealdb";
2+
import type {
3+
ApiEndpointSchema,
4+
ApiMethodDef,
5+
ApiMethods,
6+
HttpMethod,
7+
} from "../schema/api";
8+
import type { AbstractType } from "../types";
9+
10+
// ---------------------------------------------------------------------------
11+
// Type-level helpers
12+
// ---------------------------------------------------------------------------
13+
14+
/**
15+
* Builds a type-level map from endpoint path to its method definitions.
16+
* Given `[ApiEndpointSchema<"/users", M1>, ApiEndpointSchema<"/posts", M2>]`,
17+
* produces `{ "/users": M1; "/posts": M2 }`.
18+
*/
19+
type EndpointsMap<E extends ApiEndpointSchema[]> = {
20+
[K in E[number] as K["path"]]: K["methods"];
21+
};
22+
23+
/**
24+
* Extracts the set of endpoint paths that support a given HTTP method.
25+
*/
26+
type PathsForMethod<Map, M extends HttpMethod> = {
27+
[P in keyof Map]: M extends keyof Map[P] ? P : never;
28+
}[keyof Map] &
29+
string;
30+
31+
/**
32+
* Extracts the inferred response type for a given path and method.
33+
*/
34+
type ResponseType<
35+
Map,
36+
P extends string,
37+
M extends HttpMethod,
38+
> = P extends keyof Map
39+
? M extends keyof Map[P]
40+
? Map[P][M] extends { response: AbstractType }
41+
? Map[P][M]["response"]["infer"]
42+
: unknown
43+
: unknown
44+
: unknown;
45+
46+
/**
47+
* Extracts the inferred request body type for a given path and method.
48+
*/
49+
type RequestType<
50+
Map,
51+
P extends string,
52+
M extends HttpMethod,
53+
> = P extends keyof Map
54+
? M extends keyof Map[P]
55+
? Map[P][M] extends { request: AbstractType }
56+
? Map[P][M]["request"]["infer"]
57+
: undefined
58+
: undefined
59+
: undefined;
60+
61+
/**
62+
* The response shape returned by API endpoints.
63+
*/
64+
export interface ApiResponse<T> {
65+
body?: T;
66+
headers?: Record<string, string>;
67+
status?: number;
68+
}
69+
70+
// ---------------------------------------------------------------------------
71+
// ApiClient
72+
// ---------------------------------------------------------------------------
73+
74+
/**
75+
* A type-safe wrapper around the SurrealDB SDK's `SurrealApi` that validates
76+
* responses against Surqlize endpoint schemas.
77+
*
78+
* Created via `db.api()` on an {@link Orm} instance.
79+
*
80+
* @typeParam E - Tuple of {@link ApiEndpointSchema} types.
81+
*/
82+
export class ApiClient<E extends ApiEndpointSchema[] = ApiEndpointSchema[]> {
83+
private schemas: Map<string, ApiMethods>;
84+
85+
constructor(
86+
private readonly surreal: SurrealSession,
87+
endpoints: E,
88+
) {
89+
this.schemas = new Map();
90+
for (const ep of endpoints) {
91+
this.schemas.set(ep.path, ep.methods);
92+
}
93+
}
94+
95+
// -------------------------------------------------------------------
96+
// Internal helpers
97+
// -------------------------------------------------------------------
98+
99+
private getMethodDef(
100+
path: string,
101+
method: HttpMethod,
102+
): ApiMethodDef | undefined {
103+
return this.schemas.get(path)?.[method];
104+
}
105+
106+
private parseBody(body: unknown, path: string, method: HttpMethod): unknown {
107+
const def = this.getMethodDef(path, method);
108+
if (def?.response && body !== undefined) {
109+
return def.response.parse(body);
110+
}
111+
return body;
112+
}
113+
114+
private async invoke<T>(
115+
path: string,
116+
method: HttpMethod,
117+
body?: unknown,
118+
): Promise<ApiResponse<T>> {
119+
const sdkApi = this.surreal.api();
120+
const response = (await sdkApi.invoke(path, {
121+
method,
122+
body,
123+
})) as ApiResponse<unknown>;
124+
response.body = this.parseBody(response.body, path, method);
125+
return response as ApiResponse<T>;
126+
}
127+
128+
// -------------------------------------------------------------------
129+
// Public API
130+
// -------------------------------------------------------------------
131+
132+
/**
133+
* Invoke a user-defined GET API endpoint.
134+
*
135+
* @param path - The endpoint path.
136+
* @returns The typed API response.
137+
*/
138+
get<P extends PathsForMethod<EndpointsMap<E>, "get">>(
139+
path: P,
140+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "get">>> {
141+
return this.invoke(path, "get");
142+
}
143+
144+
/**
145+
* Invoke a user-defined POST API endpoint.
146+
*
147+
* @param path - The endpoint path.
148+
* @param body - The request body.
149+
* @returns The typed API response.
150+
*/
151+
post<P extends PathsForMethod<EndpointsMap<E>, "post">>(
152+
path: P,
153+
body?: RequestType<EndpointsMap<E>, P, "post">,
154+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "post">>> {
155+
return this.invoke(path, "post", body);
156+
}
157+
158+
/**
159+
* Invoke a user-defined PUT API endpoint.
160+
*
161+
* @param path - The endpoint path.
162+
* @param body - The request body.
163+
* @returns The typed API response.
164+
*/
165+
put<P extends PathsForMethod<EndpointsMap<E>, "put">>(
166+
path: P,
167+
body?: RequestType<EndpointsMap<E>, P, "put">,
168+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "put">>> {
169+
return this.invoke(path, "put", body);
170+
}
171+
172+
/**
173+
* Invoke a user-defined DELETE API endpoint.
174+
*
175+
* @param path - The endpoint path.
176+
* @param body - Optional request body.
177+
* @returns The typed API response.
178+
*/
179+
delete<P extends PathsForMethod<EndpointsMap<E>, "delete">>(
180+
path: P,
181+
body?: RequestType<EndpointsMap<E>, P, "delete">,
182+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "delete">>> {
183+
return this.invoke(path, "delete", body);
184+
}
185+
186+
/**
187+
* Invoke a user-defined PATCH API endpoint.
188+
*
189+
* @param path - The endpoint path.
190+
* @param body - The request body.
191+
* @returns The typed API response.
192+
*/
193+
patch<P extends PathsForMethod<EndpointsMap<E>, "patch">>(
194+
path: P,
195+
body?: RequestType<EndpointsMap<E>, P, "patch">,
196+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "patch">>> {
197+
return this.invoke(path, "patch", body);
198+
}
199+
200+
/**
201+
* Invoke a user-defined TRACE API endpoint.
202+
*
203+
* @param path - The endpoint path.
204+
* @param body - Optional request body.
205+
* @returns The typed API response.
206+
*/
207+
trace<P extends PathsForMethod<EndpointsMap<E>, "trace">>(
208+
path: P,
209+
body?: RequestType<EndpointsMap<E>, P, "trace">,
210+
): Promise<ApiResponse<ResponseType<EndpointsMap<E>, P, "trace">>> {
211+
return this.invoke(path, "trace", body);
212+
}
213+
}

src/schema/api.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { AbstractType } from "../types";
2+
3+
/** Supported HTTP methods for API endpoints. */
4+
export type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "trace";
5+
6+
/**
7+
* Defines the request and response types for a single HTTP method
8+
* on an API endpoint.
9+
*/
10+
export interface ApiMethodDef {
11+
/** The type of the request body (omit for methods with no body, e.g. GET). */
12+
request?: AbstractType;
13+
/** The type of the response body. */
14+
response?: AbstractType;
15+
}
16+
17+
/**
18+
* Maps HTTP methods to their request/response type definitions for
19+
* an API endpoint.
20+
*/
21+
export type ApiMethods = Partial<Record<HttpMethod, ApiMethodDef>>;
22+
23+
/**
24+
* Schema definition for a custom SurrealDB API endpoint (`DEFINE API`).
25+
* Stores the endpoint path and HTTP method definitions with typed
26+
* request/response bodies.
27+
*
28+
* Use the {@link api} factory function to create instances rather than
29+
* constructing this class directly.
30+
*
31+
* @typeParam Path - The endpoint path literal type.
32+
* @typeParam Methods - The HTTP method definitions.
33+
*/
34+
export class ApiEndpointSchema<
35+
Path extends string = string,
36+
Methods extends ApiMethods = ApiMethods,
37+
> {
38+
constructor(
39+
public readonly path: Path,
40+
public readonly methods: Methods,
41+
) {}
42+
}
43+
44+
/**
45+
* Define a custom SurrealDB API endpoint schema.
46+
*
47+
* @param path - The endpoint path (e.g. `"/users"`).
48+
* @param methods - An object mapping HTTP methods to their request/response type definitions.
49+
* @returns An {@link ApiEndpointSchema} instance.
50+
*
51+
* @example
52+
* ```ts
53+
* const usersEndpoint = api("/users", {
54+
* get: { response: t.array(user.schema) },
55+
* post: {
56+
* request: t.object({ name: t.string(), email: t.string() }),
57+
* response: user.schema,
58+
* },
59+
* });
60+
* ```
61+
*/
62+
export function api<Path extends string, Methods extends ApiMethods>(
63+
path: Path,
64+
methods: Methods,
65+
): ApiEndpointSchema<Path, Methods> {
66+
return new ApiEndpointSchema(path, methods);
67+
}

0 commit comments

Comments
 (0)