Skip to content

Commit

Permalink
Dynamic web client (#299)
Browse files Browse the repository at this point in the history
  • Loading branch information
wilmveel authored Nov 26, 2024
1 parent c72287c commit e72c8e7
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 14 deletions.
2 changes: 1 addition & 1 deletion examples/npm-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"clean:generated": "npx rimraf ./src/gen",
"generate": "wirespec compile -d ./wirespec -o ./src/gen -l TypeScript -p ''",
"test": "npm run test:client && npm run test:server",
"test:client": "ts-node src/client.ts",
"test:client": "ts-node src/clientSimple.ts && ts-node src/clientProxy.ts",
"test:server": "ts-node src/server.ts",
"update": "npx --yes update-ruecksichtslos"
},
Expand Down
97 changes: 97 additions & 0 deletions examples/npm-typescript/src/clientProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { GetTodoById, GetTodos, PostTodo, Wirespec } from "./gen/Todo";
import * as assert from "node:assert";
import Client = Wirespec.Client;

const serialization: Wirespec.Serialization = {
deserialize<T>(raw: string | undefined): T {
if (raw === undefined) {
return undefined;
} else {
return JSON.parse(raw) as T;
}
},
serialize<T>(type: T): string {
if (typeof type === "string") {
return type;
} else {
return JSON.stringify(type);
}
}
};

const body = [
{ id: "1", name: "Do it now", done: true },
{ id: "2", name: "Do it tomorrow", done: false }
];

const mock = (method: Wirespec.Method, path: string[], status: number, headers: Record<string, string>, body: any) => ({
method,
path,
status,
headers,
body
});

const mocks = [
mock("GET", ["api", "todos"], 200, {}, JSON.stringify(body)),
mock("GET", ["api", "todos", "1"], 200, {}, JSON.stringify(body[0])),
mock("POST", ["api", "todos"], 200, {}, JSON.stringify({ id: "3", name: "Do more", done: true }))
];

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type Api<Han extends any> = Wirespec.Api<Wirespec.Request<any>, Wirespec.Response<any>, Han>
type WebClient = <Apis extends Api<any>[]>(...apis: Apis) => UnionToIntersection<Apis[number] extends Api<infer Han> ? Han : never>;

const webClient:WebClient = (...apis) => {
const activeClients:Record<string, ReturnType<Client<Wirespec.Request<any>, Wirespec.Response<any>, any>>> = apis.reduce((acc, cur) => ({...acc, [cur.name] : cur.client(serialization)}), {})
const proxy = new Proxy({}, {
get: (_, prop) => {
const client = activeClients[prop as keyof typeof activeClients];
return (req:Wirespec.Request<unknown>) => {
const rawRequest = client.to(req);
const rawResponse: Wirespec.RawResponse = mocks.find(it =>
it.method === rawRequest.method &&
it.path.join("/") === rawRequest.path.join("/")
);
assert.notEqual(rawResponse, undefined);
return Promise.resolve(client.from(rawResponse));
}
},
});
return proxy as ReturnType<WebClient>
}

const api = webClient(PostTodo.api, GetTodos.api, GetTodoById.api)

const testGetTodos = async () => {
const request: GetTodos.Request = GetTodos.request();
const response = await api.getTodos(request);
const expected = { status: 200, headers: {}, body };
assert.deepEqual(response, expected);
};

const testGetTodoById = async () => {
const request: GetTodoById.Request = GetTodoById.request({ id: "1" });
const response = await api.getTodoById(request);
const expected = GetTodoById.response200({ body: body[0] });
assert.deepEqual(response, expected);
};

const testPostTodo = async () => {
const request: PostTodo.Request = {
method: "POST",
path: {},
queries: {},
headers: {},
body: { name: "Do more", done: true }
};
const response = await api.postTodo(request);
const expected = PostTodo.response200({ body: { id: "3", name: "Do more", done: true } });
assert.deepEqual(response, expected);
};

Promise.all([
testGetTodos(),
testGetTodoById(),
testPostTodo()
]);
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Api =
GetTodoById.Handler &
PostTodo.Handler

const handleFetch = <Req extends Wirespec.Request<any>, Res extends Wirespec.Response<any>>(client: Wirespec.Client<Req, Res>) => (request: Req): Promise<Res> => {
const handleFetch = <Req extends Wirespec.Request<any>, Res extends Wirespec.Response<any>>(client: Wirespec.Client<Req, Res, unknown>) => (request: Req): Promise<Res> => {
const mock = (method: Wirespec.Method, path: string[], status: number, headers: Record<string, string>, body: any) => ({
method,
path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package community.flock.wirespec.compiler.core.emit
import community.flock.wirespec.compiler.core.emit.common.DefinitionModelEmitter
import community.flock.wirespec.compiler.core.emit.common.Emitted
import community.flock.wirespec.compiler.core.emit.common.Emitter
import community.flock.wirespec.compiler.core.emit.common.Emitter.Companion.firstToLower
import community.flock.wirespec.compiler.core.emit.common.Spacer
import community.flock.wirespec.compiler.core.emit.shared.TypeScriptShared
import community.flock.wirespec.compiler.core.parse.AST
Expand Down Expand Up @@ -102,6 +103,13 @@ open class TypeScriptEmitter(logger: Logger = noLogger) : DefinitionModelEmitter
|${Spacer}}
|${endpoint.emitClient().prependIndent(Spacer(1))}
|${endpoint.emitServer().prependIndent(Spacer(1))}
|${Spacer}export const api: Wirespec.Api<Request, Response, Handler> = {
|${Spacer(2)}name: "${endpoint.identifier.sanitizeSymbol().firstToLower()}",
|${Spacer(2)}method: "${endpoint.method.name}",
|${Spacer(2)}path: "${endpoint.path.joinToString("/") { it.emit() }}",
|${Spacer(2)}server,
|${Spacer(2)}client
|${Spacer}}
|}
|
""".trimMargin()
Expand Down Expand Up @@ -179,7 +187,7 @@ open class TypeScriptEmitter(logger: Logger = noLogger) : DefinitionModelEmitter
.joinToString("")

private fun Endpoint.emitClient() = """
|export const client: Wirespec.Client<Request, Response> = (serialization: Wirespec.Serialization) => ({
|export const client: Wirespec.Client<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
|${emitClientTo().prependIndent(Spacer(1))},
|${emitClientFrom().prependIndent(Spacer(1))}
|})
Expand Down Expand Up @@ -222,7 +230,7 @@ open class TypeScriptEmitter(logger: Logger = noLogger) : DefinitionModelEmitter
""".trimMargin()

private fun Endpoint.emitServer() = """
|export const server:Wirespec.Server<Request, Response> = (serialization: Wirespec.Serialization) => ({
|export const server:Wirespec.Server<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
|${emitServerFrom().prependIndent(Spacer(1))},
|${emitServerTo().prependIndent(Spacer(1))}
|})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ data object TypeScriptShared : Shared {
|${Spacer}export type Request<T> = { path: Record<string, unknown>, method: Method, query?: Record<string, unknown>, headers?: Record<string, unknown>, content?:Content<T> }
|${Spacer}export type Response<T> = { status:number, headers?: Record<string, unknown[]>, content?:Content<T> }
|${Spacer}export type Serialization = { serialize: <T>(type: T) => string; deserialize: <T>(raw: string | undefined) => T }
|${Spacer}export type Client<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
|${Spacer}export type Server<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
|${Spacer}export type Client<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
|${Spacer}export type Server<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
|${Spacer}export type Api<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = { name: string; method: Method, path: string, client: Client<REQ, RES, HAN>; server: Server<REQ, RES, HAN> }
|}
""".trimMargin()
}
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,9 @@ class CompileFullEndpointTest {
| export type Request<T> = { path: Record<string, unknown>, method: Method, query?: Record<string, unknown>, headers?: Record<string, unknown>, content?:Content<T> }
| export type Response<T> = { status:number, headers?: Record<string, unknown[]>, content?:Content<T> }
| export type Serialization = { serialize: <T>(type: T) => string; deserialize: <T>(raw: string | undefined) => T }
| export type Client<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
| export type Server<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
| export type Client<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
| export type Server<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
| export type Api<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = { name: string; method: Method, path: string, client: Client<REQ, RES, HAN>; server: Server<REQ, RES, HAN> }
|}
|export namespace PutTodo {
| type Path = {
Expand Down Expand Up @@ -391,7 +392,7 @@ class CompileFullEndpointTest {
| export type Handler = {
| putTodo: (request:Request) => Promise<Response>
| }
| export const client: Wirespec.Client<Request, Response> = (serialization: Wirespec.Serialization) => ({
| export const client: Wirespec.Client<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
| to: (request) => ({
| method: "PUT",
| path: ["todos", serialization.serialize(request.path.id)],
Expand All @@ -418,7 +419,7 @@ class CompileFullEndpointTest {
| }
| }
| })
| export const server:Wirespec.Server<Request, Response> = (serialization: Wirespec.Serialization) => ({
| export const server:Wirespec.Server<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
| from: (request) => {
| return {
| method: "PUT",
Expand All @@ -440,6 +441,13 @@ class CompileFullEndpointTest {
| body: serialization.serialize(response.body),
| })
| })
| export const api: Wirespec.Api<Request, Response, Handler> = {
| name: "putTodo",
| method: "PUT",
| path: "todos/{id}",
| server,
| client
| }
|}
|
|export type PotentialTodoDto = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,9 @@ class CompileMinimalEndpointTest {
| export type Request<T> = { path: Record<string, unknown>, method: Method, query?: Record<string, unknown>, headers?: Record<string, unknown>, content?:Content<T> }
| export type Response<T> = { status:number, headers?: Record<string, unknown[]>, content?:Content<T> }
| export type Serialization = { serialize: <T>(type: T) => string; deserialize: <T>(raw: string | undefined) => T }
| export type Client<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
| export type Server<REQ extends Request<unknown>, RES extends Response<unknown>> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
| export type Client<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { to: (request: REQ) => RawRequest; from: (response: RawResponse) => RES }
| export type Server<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = (serialization: Serialization) => { from: (request: RawRequest) => REQ; to: (response: RES) => RawResponse }
| export type Api<REQ extends Request<unknown>, RES extends Response<unknown>, HAN> = { name: string; method: Method, path: string, client: Client<REQ, RES, HAN>; server: Server<REQ, RES, HAN> }
|}
|export namespace GetTodos {
| type Path = {}
Expand Down Expand Up @@ -273,7 +274,7 @@ class CompileMinimalEndpointTest {
| export type Handler = {
| getTodos: (request:Request) => Promise<Response>
| }
| export const client: Wirespec.Client<Request, Response> = (serialization: Wirespec.Serialization) => ({
| export const client: Wirespec.Client<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
| to: (request) => ({
| method: "GET",
| path: ["todos"],
Expand All @@ -294,7 +295,7 @@ class CompileMinimalEndpointTest {
| }
| }
| })
| export const server:Wirespec.Server<Request, Response> = (serialization: Wirespec.Serialization) => ({
| export const server:Wirespec.Server<Request, Response, Handler> = (serialization: Wirespec.Serialization) => ({
| from: (request) => {
| return {
| method: "GET",
Expand All @@ -316,6 +317,13 @@ class CompileMinimalEndpointTest {
| body: serialization.serialize(response.body),
| })
| })
| export const api: Wirespec.Api<Request, Response, Handler> = {
| name: "getTodos",
| method: "GET",
| path: "todos",
| server,
| client
| }
|}
|
|export type TodoDto = {
Expand Down

0 comments on commit e72c8e7

Please sign in to comment.