diff --git a/bun.lockb b/bun.lockb index f6dcbf0..c835533 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 64d94f0..ede595c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@sveltejs/kit": "^2.3.5", + "swc": "^1.0.11", "typescript": "^5.3.3", "zod": "^3.22.4" }, diff --git a/src/fetch.ts b/src/fetch.ts index d230b03..5bddeb0 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -26,7 +26,7 @@ const apiFetch = function >( export function createApiObject(fetch: Fetch) { function createRequest(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 >(url: U, init: PartialInit) => apiFetch(method, url, init, fetch); } @@ -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 >( + method: M, + url: U, + init: Init, + fetch: Fetch +): Promise> { + const body = init.body ? JSON.stringify((init as RequestInit).body) : undefined; + const routeParams = init.routeParams ? Object.entries(init.routeParams) : []; + const searchParams = init.searchParams ? Object.entries(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(method: M) { + // MM is required to make sure that TypeScript doesn't replace the AllowedUrl with never + return >( + url: U, + init: PartialInit + ): Promise> => rpcFetch(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][], @@ -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, +> = "returns" extends keyof ProjectAPI[M][U] ? ProjectAPI[M][U]["returns"] : never; + type Init> = Omit< RequestInit, "body" | "method" diff --git a/src/index.ts b/src/index.ts index 8fdd0a6..1dd7b54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/vite/helpers.ts b/src/vite/helpers.ts index e7b27c0..d4ec434 100644 --- a/src/vite/helpers.ts +++ b/src/vite/helpers.ts @@ -7,7 +7,14 @@ export function generateUrlType(fields: Record) { 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) => { @@ -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, 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); @@ -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 { diff --git a/src/vite/plugin.ts b/src/vite/plugin.ts index 1610fe2..e950524 100644 --- a/src/vite/plugin.ts +++ b/src/vite/plugin.ts @@ -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 ( @@ -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; } } }); @@ -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; } } } @@ -154,6 +157,7 @@ export function typesafeApi(): Plugin { body: allBodies[method], routeParams: routeParams, searchParams: allSearchParams[method], + returns: allReturnTypes[method], }); } }