Skip to content

Typesafe responses (RPC Mode) #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
},
"dependencies": {
"@sveltejs/kit": "^2.3.5",
"swc": "^1.0.11",
"typescript": "^5.3.3",
"zod": "^3.22.4"
},
Expand Down
67 changes: 66 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const apiFetch = function <M extends AllowedMethod, U extends AllowedUrl<M>>(

export function createApiObject(fetch: Fetch) {
function createRequest<M extends AllowedMethod>(method: M) {
// MM is required to make sure that TypeScript doens't replace the AllowedUrl with never
// MM is required to make sure that TypeScript doesn't replace the AllowedUrl with never
return <MM extends M, U extends AllowedUrl<MM>>(url: U, init: PartialInit<MM, U>) =>
apiFetch(method, url, init, fetch);
}
Expand All @@ -41,6 +41,66 @@ export function createApiObject(fetch: Fetch) {
};
}

export class RpcFetchError extends Error {
response: Response;
constructor(response: Response) {
super(`rpcFetch failed: Received status ${response.status}. Data: '${response.text()}'`);
this.response = response;
}
}

const rpcFetch = async function <M extends AllowedMethod, U extends AllowedUrl<M>>(
method: M,
url: U,
init: Init<M, U>,
fetch: Fetch
): Promise<ApiReturnType<M, U>> {
const body = init.body ? JSON.stringify((init as RequestInit).body) : undefined;
const routeParams = init.routeParams ? Object.entries<string>(init.routeParams) : [];
const searchParams = init.searchParams ? Object.entries<string>(init.searchParams) : [];

const headers = {
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...(init ? init.headers : {}),
};

delete init.routeParams;
delete init.searchParams;

const result = await fetch(generateUrl(url as string, routeParams, searchParams), {
...init,
method,
headers,
body,
});

if (result.status != 200) {
throw new RpcFetchError(result);
}

return await result.json();
};

export function createRpcObject(fetch: Fetch) {
function createRequest<M extends AllowedMethod>(method: M) {
// MM is required to make sure that TypeScript doesn't replace the AllowedUrl with never
return <MM extends M, U extends AllowedUrl<MM>>(
url: U,
init: PartialInit<MM, U>
): Promise<ApiReturnType<MM, U>> => rpcFetch<MM, U>(method as MM, url, init, fetch);
}

return {
GET: createRequest("GET"),
POST: createRequest("POST"),
PATCH: createRequest("PATCH"),
PUT: createRequest("PUT"),
DELETE: createRequest("DELETE"),
OPTIONS: createRequest("OPTIONS"),
};
}

function generateUrl(
route: string,
routeParams: [string, string][],
Expand Down Expand Up @@ -92,6 +152,11 @@ type AllowedData<
Field extends string,
> = Field extends keyof ProjectAPI[M][U] ? ProjectAPI[M][U][Field] : never;

type ApiReturnType<
M extends AllowedMethod,
U extends AllowedUrl<M>,
> = "returns" extends keyof ProjectAPI[M][U] ? ProjectAPI[M][U]["returns"] : never;

type Init<M extends AllowedMethod, U extends AllowedUrl<M>> = Omit<
RequestInit,
"body" | "method"
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createApiObject } from "./fetch.js";
import { createApiObject, createRpcObject } from "./fetch.js";

export { RpcFetchError } from "./fetch.js";

export const api = createApiObject(fetch);
export const rpc = createRpcObject(fetch);
94 changes: 73 additions & 21 deletions src/vite/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export function generateUrlType(fields: Record<string, string | undefined>) {
return `{ ${entries.map(([name, type]) => `${name}: ${type}`).join("; ")} }`;
}

export function getSchemaFromFunction(func: ts.SignatureDeclaration) {
export function getSchemaFromFunction(typeChecker: ts.TypeChecker, func: ts.SignatureDeclaration) {
return {
schema: getSchema(func),
returnType: getReturnType(typeChecker, func),
};
}

function getSchema(func: ts.SignatureDeclaration) {
return ts.forEachChild(func, (node) => {
if (ts.isBlock(node)) {
return ts.forEachChild(node, (node) => {
Expand All @@ -28,31 +35,71 @@ export function getSchemaFromFunction(func: ts.SignatureDeclaration) {
});
}

function getReturnType(typeChecker: ts.TypeChecker, func: ts.SignatureDeclaration) {
let returnType: ts.Type | undefined;

ts.forEachChild(func, (node) => {
if (ts.isBlock(node)) {
ts.forEachChild(node, (child) => {
if (ts.isReturnStatement(child) && child.expression) {
if (ts.isCallExpression(child.expression)) {
const calledFunction = child.expression.expression;
const calledFunctionText = calledFunction.getText();

if (calledFunctionText === "json") {
const jsonArgument = child.expression.arguments[0];
if (jsonArgument) {
returnType = typeChecker.getTypeAtLocation(jsonArgument);
// TODO: Apply 'as const' effect to returnType (don't know how to)
// Workaround is to just write "as const" in your code
}
}
}

// If not a 'json' call, use any (would use unknown but that is not available)
if (!returnType) {
returnType = typeChecker.getAnyType();
}
}
});
}
});

return returnType ?? typeChecker.getVoidType();
}

/**
* Turns a schema (obtained via `getSchemaFromFunction`) and parses it into strings `body` and `searchParams`.
* @param data The schema from getSchemaFromFunction
* @param skipBody Won't output body, set this to True on methods that don't support a body (like GET)
* @returns Body and SearchParams, which are string representations of the types
*/
export function parseSchema(
typeChecker: ts.TypeChecker,
zodVariableDeclaration: ts.Node,
data: ReturnType<typeof getSchemaFromFunction>,
skipBody = false
) {
const zodInputType = typeChecker.getTypeAtLocation(zodVariableDeclaration);

const body = skipBody
? undefined
: `{ ${typeChecker
.getPropertiesOfType(zodInputType)
.map((property) => {
if (property.getName() === "searchParams") return null;
const outputType = getZodTypeToString(
typeChecker,
typeChecker.getTypeOfSymbol(property)
);
if (!outputType) return null;
return `${property.getName()}: ${outputType}`;
})
.filter((t) => t !== null)
.join("; ")} }`;
const zodInputType = data.schema ? typeChecker.getTypeAtLocation(data.schema) : undefined;

const body =
skipBody || !zodInputType
? undefined
: `{ ${typeChecker
.getPropertiesOfType(zodInputType)
.map((property) => {
if (property.getName() === "searchParams") return null;
const outputType = getZodTypeToString(
typeChecker,
typeChecker.getTypeOfSymbol(property)
);
if (!outputType) return null;
return `${property.getName()}: ${outputType}`;
})
.filter((t) => t !== null)
.join("; ")} }`;

let searchParams: string | undefined;
const searchParamsSymbol = zodInputType.getProperty("searchParams");
const searchParamsSymbol = zodInputType?.getProperty("searchParams");
if (searchParamsSymbol) {
const searchParamsType = typeChecker.getTypeOfSymbol(searchParamsSymbol);

Expand All @@ -62,7 +109,12 @@ export function parseSchema(
}
}

return { body: body != "{ }" ? body : undefined, searchParams };
let returnType: string | undefined;
if (data.returnType) {
returnType = typeChecker.typeToString(data.returnType);
}

return { body: body != "{ }" ? body : undefined, searchParams, returnType };
}

function getZodTypeToString(typeChecker: ts.TypeChecker, type: ts.Type): string {
Expand Down
16 changes: 10 additions & 6 deletions src/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function typesafeApi(): Plugin {
function parseFile(file: ts.SourceFile, typeChecker: ts.TypeChecker) {
const endpointsFound: Method[] = [];
const allBodies: EndpointData = {};
let allSearchParams: EndpointData = {};
const allSearchParams: EndpointData = {};
const allReturnTypes: EndpointData = {};

ts.forEachChild(file, (node) => {
if (
Expand All @@ -83,22 +84,23 @@ export function typesafeApi(): Plugin {
if (ts.isSatisfiesExpression(node)) {
return ts.forEachChild(node.expression, (node) => {
if (ts.isFunctionLike(node)) {
return getSchemaFromFunction(node);
return getSchemaFromFunction(typeChecker, node);
}
});
} else if (ts.isFunctionLike(node)) {
return getSchemaFromFunction(node);
return getSchemaFromFunction(typeChecker, node);
}
});

if (schema) {
const { body, searchParams } = parseSchema(
const { body, searchParams, returnType } = parseSchema(
typeChecker,
schema,
variableName.toUpperCase() === "GET"
);
allBodies[variableName as Method] = body;
allSearchParams[variableName as Method] = searchParams;
allReturnTypes[variableName as Method] = returnType;
}
}
});
Expand All @@ -110,15 +112,16 @@ export function typesafeApi(): Plugin {
if (methods.includes(variableName)) {
endpointsFound.push(variableName as Method);

const schema = getSchemaFromFunction(node);
const schema = getSchemaFromFunction(typeChecker, node);
if (schema) {
const { body, searchParams } = parseSchema(
const { body, searchParams, returnType } = parseSchema(
typeChecker,
schema,
variableName.toUpperCase() === "GET"
);
allBodies[variableName as Method] = body;
allSearchParams[variableName as Method] = searchParams;
allReturnTypes[variableName as Method] = returnType;
}
}
}
Expand Down Expand Up @@ -154,6 +157,7 @@ export function typesafeApi(): Plugin {
body: allBodies[method],
routeParams: routeParams,
searchParams: allSearchParams[method],
returns: allReturnTypes[method],
});
}
}
Expand Down