Skip to content

Instantly share code, notes, and snippets.

@gimenete
Created November 28, 2023 14:31
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gimenete/4561347dfbcbc96aecc0df977e27a4c7 to your computer and use it in GitHub Desktop.
Save gimenete/4561347dfbcbc96aecc0df977e27a4c7 to your computer and use it in GitHub Desktop.
// src/app/client.ts
import { z } from "zod";
import { api } from "./api/routes";
import {
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { API, Endpoint } from "./api/openapi";
type ParamsOptions<E extends Endpoint<any, any, any>> = E extends {
params: any;
}
? { params: z.infer<E["params"]> }
: Record<string, never>;
type QueryOptions<E extends Endpoint<any, any, any>> = ParamsOptions<E>;
// type InfiniteQueryOptions<
// S extends z.ZodObject<any>,
// E extends Endpoint<any, any, S>,
// > = QueryOptions<E> & {
// initialPageParam: unknown;
// getNextPageParam: GetNextPageParamFunction<unknown, z.infer<S>>;
// getPreviousPageParam: GetPreviousPageParamFunction<unknown, z.infer<S>>;
// };
type MutationOptions<E extends Endpoint<any, any, any>> = ParamsOptions<E> & {
body: z.infer<E["body"]>;
};
type APIClient<A extends API> = {
[K in keyof A]: A[K]["method"] extends "GET"
? {
useQuery: (
options: QueryOptions<A[K]> &
Omit<UseQueryOptions<A[K]["response"]>, "queryKey">,
) => UseQueryResult<z.infer<A[K]["response"]>>;
// useInfiniteQuery: (
// options: InfiniteQueryOptions<A[K]["response"], A[K]>,
// ) => UseInfiniteQueryResult<z.infer<A[K]["response"]>>;
}
: A[K]["method"] extends "POST" | "PUT" | "PATCH" | "DELETE"
? {
useMutation: (
options: MutationOptions<A[K]> &
UseMutationOptions<A[K]["response"]>,
) => UseMutationResult<z.infer<A[K]["response"]>>;
}
: never;
};
function buildClient<A extends API>(api: A): APIClient<A> {
const proxy = new Proxy(
{},
{
get(target, prop, receiver) {
return {
useQuery: (options: QueryOptions<any>) => {
const endpoint = api[prop as keyof A];
let path = endpoint.path;
if (options.params) {
for (const [key, value] of Object.entries(options.params)) {
path = path.replace(`:${key}`, String(value));
}
}
// TODO: query params
return useQuery({
queryKey: [path],
queryFn: () => {
return fetch(`/api${path}`, {
method: endpoint.method,
})
.then((res) => res.json())
.then((json) => json);
},
});
},
useMutation: (options: MutationOptions<any>) => {
const endpoint = api[prop as keyof A];
let path = endpoint.path;
if (options.params) {
for (const [key, value] of Object.entries(options.params)) {
path = path.replace(`:${key}`, String(value));
}
}
// TODO: query params
return useQuery({
queryKey: [path],
queryFn: () => {
return fetch(`/api${path}`, {
method: endpoint.method,
})
.then((res) => res.json())
.then((json) => json);
},
});
},
};
},
},
);
return proxy as APIClient<A>;
}
export const client = buildClient(api);
// src/app/api/openapi.ts
import z from "zod";
export type Endpoint<
P extends z.ZodObject<any>,
B extends z.ZodObject<any> | undefined,
S extends z.ZodObject<any>,
> = {
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
path: string;
params?: P;
body?: B;
response?: S;
};
export type API = {
[key: string]: Endpoint<any, any, any>;
};
export function buildEndpoint<
P extends z.ZodObject<any>,
B extends z.ZodObject<any>,
S extends z.ZodObject<any>,
>(
endpoint: Endpoint<P, B, S>,
handler: (args: {
request: Request;
params: z.infer<P>;
body: z.infer<B>;
}) => z.infer<S> | Promise<z.infer<S>>,
) {
return (
request: Request,
ctx: { params: Record<string, string | string[]> },
) => {
const ctxParams = Object.fromEntries(
Object.entries(ctx.params || {}).map(([key, value]) => [
key.substring(1), // remove ":" from key
value,
]),
);
const params = endpoint.params?.parse(ctxParams) || {};
const body = endpoint.body?.parse(request.body ? request.json() : {}) || {};
const result = handler({ request, params, body });
return new Response(JSON.stringify(result), {
headers: {
"content-type": "application/json",
},
});
};
}
// src/app/page.tsx
import { client } from "./client";
export default function Home() {
const { data } = client.storiesRetrieve.useQuery({
params: {
storyId: "home",
},
});
// ...
}
// src/app/api/routes.ts
import z from "zod";
import { API } from "./openapi";
export const api = {
storiesList: {
method: "GET",
path: "/stories",
response: z.object({
message: z.string(),
}),
},
storiesRetrieve: {
method: "GET",
path: "/stories/:storyId",
params: z.object({
storyId: z.string(),
}),
response: z.object({
message: z.string(),
}),
},
storiesCreate: {
method: "POST",
path: "/stories",
body: z.object({
foo: z.string(),
}),
response: z.object({
message: z.string(),
}),
},
} satisfies API;
export type APP = typeof api;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment