diff --git a/packages/typiclient/package.json b/packages/typiclient/package.json index d90929c..5c4596a 100644 --- a/packages/typiclient/package.json +++ b/packages/typiclient/package.json @@ -30,7 +30,7 @@ "dependencies": { "@repo/typiserver": "*", "superjson": "^2.2.2", - "zod": "^3.22.4" + "zod": "^3.25.6" }, "devDependencies": { "@repo/eslint-config": "*", diff --git a/packages/typiclient/src/client.ts b/packages/typiclient/src/client.ts index 08baa0a..8801e9b 100644 --- a/packages/typiclient/src/client.ts +++ b/packages/typiclient/src/client.ts @@ -1,5 +1,9 @@ import { deserialize } from "superjson"; -import type { RouteHandlerResponse, TypiRouter } from "@repo/typiserver"; +import type { + RouteHandlerErrorDataResponse, + RouteHandlerResponse, + TypiRouter, +} from "@repo/typiserver"; import { type HttpMethod, type HttpStatusCode, @@ -44,11 +48,11 @@ export class TypiClient { ); } }, - apply: (_, __, [input]) => { + apply: (_, __, [args]) => { const method = path[path.length - 1].toUpperCase() as HttpMethod; const urlWithoutMethod = this.path.slice(0, -1).join("/"); const url = `${this.baseUrl}/${urlWithoutMethod}`; - return this.executeRequest(url, method, input); + return this.executeRequest(url, method, args?.input, args?.options); }, }) as any; } @@ -79,11 +83,11 @@ export class TypiClient { .join("; "); } - private async buildHeaders(input: any) { + private async buildHeaders(input: any, hasBody: boolean, hasFiles: boolean) { const cookieHeader = this.buildCookieHeader(input?.cookies); let headers = { - "Content-Type": "application/json", + ...(hasBody && !hasFiles ? { "Content-Type": "application/json" } : {}), ...(input?.headers || {}), ...(cookieHeader ? { Cookie: cookieHeader } : {}), }; @@ -109,29 +113,86 @@ export class TypiClient { return headers; } - private async buildRequestConfig(method: HttpMethod, input: any) { - const headers = await this.buildHeaders(input); + private hasFileData(obj: any): boolean { + if (obj instanceof File || obj instanceof Blob) { + return true; + } + if (obj && typeof obj === "object") { + return Object.values(obj).some((value) => this.hasFileData(value)); + } + return false; + } + + private buildFormData(data: any): FormData { + const formData = new FormData(); + + const appendToFormData = (obj: any, prefix = "") => { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const value = obj[key]; + const formKey = prefix ? `${prefix}[${key}]` : key; + + if (value instanceof File || value instanceof Blob) { + formData.append(formKey, value); + } else if ( + value && + typeof value === "object" && + !(value instanceof Date) + ) { + appendToFormData(value, formKey); + } else if (value !== null && value !== undefined) { + formData.append(formKey, String(value)); + } + } + } + }; + + appendToFormData(data); + return formData; + } + + private async buildRequestConfig( + method: HttpMethod, + input: any, + options?: ClientOptions + ) { + const hasBody = method !== "get" && method !== "head" && input?.body; + const hasFiles = input?.body && this.hasFileData(input.body); + const headers = await this.buildHeaders(input, hasBody, hasFiles); + + let body: string | FormData | undefined; + + if (hasBody) { + if (hasFiles) body = this.buildFormData(input.body); + else body = JSON.stringify(input.body); + } return { - credentials: this.options?.credentials, + credentials: options?.credentials || this.options?.credentials, method: method, headers: headers, - body: - method !== "get" && method !== "head" && input?.body - ? JSON.stringify(input.body) + body: body, + signal: options?.timeout + ? AbortSignal.timeout(options.timeout) + : this.options?.timeout + ? AbortSignal.timeout(this.options.timeout) : undefined, - signal: this.options?.timeout - ? AbortSignal.timeout(this.options.timeout) - : undefined, } as RequestInit; } - private async executeInterceptors( - url: URL, - config: RequestInit, - response?: Response, - error?: any - ) { + private async executeInterceptors({ + url, + config, + response, + error, + options, + }: { + url: URL; + config: RequestInit; + response?: Response; + error?: any; + options?: ClientOptions; + }) { // Handle request interceptors if (!response && !error) { for (const requestInterceptor of this.interceptors?.onRequest || []) { @@ -152,7 +213,7 @@ export class TypiClient { path: url.pathname, config, response, - retry: () => this.makeRequest(url, config), + retry: () => this.makeRequest(url, config, options), }); if (isRouteHandlerResponse(result)) { return { result }; @@ -167,7 +228,7 @@ export class TypiClient { path: url.pathname, config, error, - retry: () => this.makeRequest(url, config), + retry: () => this.makeRequest(url, config, options), }); if (isRouteHandlerResponse(result)) { return { result }; @@ -178,15 +239,20 @@ export class TypiClient { return {}; } - private async makeRequest(url: URL, config: RequestInit): Promise { + private async makeRequest( + url: URL, + config: RequestInit, + options?: ClientOptions + ): Promise { try { const response = await fetch(url.toString(), config); - const interceptorResult = await this.executeInterceptors( + const interceptorResult = await this.executeInterceptors({ url, config, - response - ); + response, + options, + }); if (interceptorResult.result) { return interceptorResult.result; } @@ -194,10 +260,19 @@ export class TypiClient { const data = deserialize(await response.json()); const status = getStatus(response.status as HttpStatusCode).key; - console[status === "OK" ? "log" : "error"]( - `Request to ${url.toString()} returned status ${status}`, - data - ); + console[status === "OK" ? "log" : "warn"]({ + URL: url.toString(), + config: config, + status: status, + data: data, + }); + + if ( + status !== "OK" && + (options?.throwOnErrorStatus ?? this.options?.throwOnErrorStatus) + ) { + throw new Error((data as RouteHandlerErrorDataResponse).error.message); + } return { status: status, @@ -205,13 +280,17 @@ export class TypiClient { response: response, }; } catch (error) { - console.error(`Error making request to ${url.toString()}`, error); - const interceptorResult = await this.executeInterceptors( + console.error({ + URL: url.toString(), + config: config, + error: error instanceof Error ? error : undefined, + }); + const interceptorResult = await this.executeInterceptors({ url, config, - undefined, - error - ); + error, + options, + }); if (interceptorResult.result) { return interceptorResult.result; } @@ -219,12 +298,21 @@ export class TypiClient { } } - private async executeRequest(path: string, method: HttpMethod, input: any) { + private async executeRequest( + path: string, + method: HttpMethod, + input: any, + options?: ClientOptions + ) { const url = this.buildUrl(path, input); - let config = await this.buildRequestConfig(method, input); + let config = await this.buildRequestConfig(method, input, options); // Execute request interceptors - const interceptorResult = await this.executeInterceptors(url, config); + const interceptorResult = await this.executeInterceptors({ + url, + config, + options, + }); if (interceptorResult.result) { return interceptorResult.result; @@ -234,11 +322,14 @@ export class TypiClient { config = interceptorResult.config; } - return this.makeRequest(url, config); + return this.makeRequest(url, config, options); } } -export function createTypiClient({ +export function createTypiClient< + TRouter extends TypiRouter, + TOptions extends ClientOptions, +>({ baseUrl, baseHeaders, interceptors, @@ -247,15 +338,15 @@ export function createTypiClient({ baseUrl: string; baseHeaders?: BaseHeaders; interceptors?: RequestInterceptors; - options?: ClientOptions; -}): TypiClientInstance { + options?: TOptions; +}): TypiClientInstance { return new TypiClient( baseUrl, [], baseHeaders, interceptors, options - ) as TypiClientInstance; + ) as TypiClientInstance; } const isRouteHandlerResponse = ( diff --git a/packages/typiclient/src/index.ts b/packages/typiclient/src/index.ts index 5480084..d2ec230 100644 --- a/packages/typiclient/src/index.ts +++ b/packages/typiclient/src/index.ts @@ -1,2 +1,2 @@ -export * from "./client" -export * from "./types" +export * from "./client"; +export * from "./types"; diff --git a/packages/typiclient/src/types.ts b/packages/typiclient/src/types.ts deleted file mode 100644 index 7308580..0000000 --- a/packages/typiclient/src/types.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { type TypiClient } from "./client"; -import { RouteHandlerResponse, type TypiRouter } from "@repo/typiserver"; -import { - ExtractRoutesOutputsByStatusCodes, - RouteDefinition, - RouteHandlerValidatedInput, - TypiRoute, -} from "@repo/typiserver"; -import { HttpErrorStatusKey } from "@repo/typiserver/http"; - -export type TypiClientInstance> = TypiClient & { - [Path in keyof TRouter["routes"] as StripLeadingSlash< - Path & string - >]: TRouter["routes"][Path] extends TypiRouter - ? TypiClientInstance - : TRouter["routes"][Path] extends TypiRoute - ? { - [Method in keyof TRouter["routes"][Path]]: ( - params?: InferRouteInput - ) => Promise< - InferRouteOutput & { - response: Response; - } - >; - } - : never; -}; - -export type BaseHeaders = Record< - string, - string | (() => string | Promise) ->; - -export interface RequestInterceptors { - onRequest?: (({ - path, - config, - }: { - path: string; - config: RequestInit & { - [key: string]: any; - }; - }) => MaybePromise)[]; - onResponse?: (({ - path, - config, - response, - retry, - }: { - path: string; - config: RequestInit & { - [key: string]: any; - }; - response: Response; - retry: () => Promise; - }) => MaybePromise)[]; - onError?: (({ - path, - config, - error, - retry, - }: { - path: string; - config: RequestInit & { - [key: string]: any; - }; - error: any; - retry: () => Promise; - }) => MaybePromise)[]; -} - -export interface ClientOptions { - credentials?: RequestCredentials; - timeout?: number; -} - -type InferRouteInput< - TRouter extends TypiRouter, - TPath extends keyof TRouter["routes"], - TMethod extends keyof TRouter["routes"][TPath], -> = - TRouter["routes"][TPath][TMethod] extends RouteDefinition< - any, - infer TInput, - any - > - ? TPath extends string - ? RouteHandlerValidatedInput extends infer TValidatedInput - ? TValidatedInput extends { cookies: any } - ? Omit & { - cookies?: { - [K in keyof TValidatedInput["cookies"]]?: TValidatedInput["cookies"][K]; - }; - } - : TValidatedInput - : never - : never - : never; - -// type InferRouteInput< -// TRouter extends TypiRouter, -// TPath extends keyof TRouter["routes"], -// TMethod extends keyof TRouter["routes"][TPath], -// > = -// TRouter["routes"][TPath][TMethod] extends RouteDefinition< -// any, -// infer TInput, -// any -// > -// ? TPath extends string -// ? RouteHandlerValidatedInput -// : never -// : never; - -type InferRouteOutput< - TRouter extends TypiRouter, - TPath extends keyof TRouter["routes"], - TMethod extends keyof TRouter["routes"][TPath], -> = - TRouter["routes"][TPath][TMethod] extends RouteDefinition< - any, - any, - infer TMiddlewares, - infer THandlerOutput - > - ? - | ExtractRoutesOutputsByStatusCodes - | THandlerOutput // Has actual middlewares - : never; - -export type InferRouterInputs> = { - [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter - ? InferRouterInputs - : TRouter["routes"][Path] extends TypiRoute - ? { - [Method in keyof TRouter["routes"][Path]]: InferRouteInput< - TRouter, - Path, - Method - >; - } - : never; -}; - -export type InferRouterOutputs> = { - [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter - ? InferRouterOutputs - : TRouter["routes"][Path] extends TypiRoute - ? { - [Method in keyof TRouter["routes"][Path]]: Extract< - InferRouteOutput, - { - status: "OK"; - } - >["data"]; - } - : never; -}; - -type StripLeadingSlash = S extends `/${infer R}` ? R : S; - -type MaybePromise = Promise | T; diff --git a/packages/typiclient/src/types/client.ts b/packages/typiclient/src/types/client.ts new file mode 100644 index 0000000..fde3329 --- /dev/null +++ b/packages/typiclient/src/types/client.ts @@ -0,0 +1,223 @@ +import { + RouteHandlerResponse, + TypiRoute, + type TypiRouter, +} from "@repo/typiserver"; +import { type TypiClient } from "../client"; +import { MaybePromise, StripLeadingSlash } from "./util"; +import { InferRouteInput, InferRouteOutput } from "./infer"; + +// export type TypiClientInstance> = TypiClient & { +// [Path in keyof TRouter["routes"] as StripLeadingSlash< +// Path & string +// >]: TRouter["routes"][Path] extends TypiRouter +// ? TypiClientInstance +// : TRouter["routes"][Path] extends TypiRoute +// ? { +// [Method in keyof TRouter["routes"][Path]]: {} extends InferRouteInput< +// TRouter, +// Path, +// Method +// > +// ? // No input required - function can be called with no args or with optional args +// ((args?: { +// input?: InferRouteInput; +// options?: ClientOptions; +// }) => Promise< +// InferRouteOutput & { +// response: Response; +// } +// >) & +// (() => Promise< +// InferRouteOutput & { +// response: Response; +// } +// >) +// : // Input is required - args parameter is mandatory +// (args: { +// input: InferRouteInput; +// options?: ClientOptions; +// }) => Promise< +// InferRouteOutput & { +// response: Response; +// } +// >; +// } +// : never; +// }; + +export type TypiClientInstance< + TRouter extends TypiRouter, + TGlobalOptions extends ClientOptions, +> = TypiClient & { + [Path in keyof TRouter["routes"] as StripLeadingSlash< + Path & string + >]: TRouter["routes"][Path] extends TypiRouter + ? TypiClientInstance + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: {} extends InferRouteInput< + TRouter, + Path, + Method + > + ? // No input required - overloaded signatures + { + // When request throwOnErrorStatus is explicitly true + (args: { + input?: InferRouteInput; + options: ClientOptions & { throwOnErrorStatus: true }; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + { throwOnErrorStatus: true } + > & { + response: Response; + } + >; + // When request throwOnErrorStatus is explicitly false + (args: { + input?: InferRouteInput; + options: ClientOptions & { throwOnErrorStatus: false }; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + { throwOnErrorStatus: false } + > & { + response: Response; + } + >; + // When no request options provided (uses global options) + (args?: { + input?: InferRouteInput; + options?: Omit; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + {} + > & { + response: Response; + } + >; + // Default overload (no args, uses global options) + (): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + {} + > & { + response: Response; + } + >; + } + : // Input is required - overloaded signatures + { + // When request throwOnErrorStatus is explicitly true + (args: { + input: InferRouteInput; + options: ClientOptions & { throwOnErrorStatus: true }; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + { throwOnErrorStatus: true } + > & { + response: Response; + } + >; + // When request throwOnErrorStatus is explicitly false + (args: { + input: InferRouteInput; + options: ClientOptions & { throwOnErrorStatus: false }; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + { throwOnErrorStatus: false } + > & { + response: Response; + } + >; + // When no throwOnErrorStatus in request options (uses global) + (args: { + input: InferRouteInput; + options?: Omit; + }): Promise< + InferRouteOutput< + TRouter, + Path, + Method, + TGlobalOptions, + {} + > & { + response: Response; + } + >; + }; + } + : never; +}; + +export type BaseHeaders = Record< + string, + string | (() => string | Promise) +>; + +export interface RequestInterceptors { + onRequest?: (({ + path, + config, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + }) => MaybePromise)[]; + onResponse?: (({ + path, + config, + response, + retry, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + response: Response; + retry: () => Promise; + }) => MaybePromise)[]; + onError?: (({ + path, + config, + error, + retry, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + error: any; + retry: () => Promise; + }) => MaybePromise)[]; +} + +export interface ClientOptions { + credentials?: RequestCredentials; + timeout?: number; + throwOnErrorStatus?: boolean; +} diff --git a/packages/typiclient/src/types/index.ts b/packages/typiclient/src/types/index.ts new file mode 100644 index 0000000..8dcab55 --- /dev/null +++ b/packages/typiclient/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./client"; +export * from "./infer"; +export * from "./util"; diff --git a/packages/typiclient/src/types/infer.ts b/packages/typiclient/src/types/infer.ts new file mode 100644 index 0000000..2bf1f73 --- /dev/null +++ b/packages/typiclient/src/types/infer.ts @@ -0,0 +1,123 @@ +import { + ExtractRoutesOutputsByStatusCodes, + RouteDefinition, + RouteHandlerValidatedInput, + TypiRoute, + type TypiRouter, +} from "@repo/typiserver"; +import { HttpErrorStatusKey } from "@repo/typiserver/http"; + +type ResolveThrowOnErrorStatus = + TRequestOptions extends { throwOnErrorStatus: infer TRequestThrow } + ? TRequestThrow + : TGlobalOptions extends { throwOnErrorStatus: infer TGlobalThrow } + ? TGlobalThrow + : false; + +type TransformCookies = TValidatedInput extends { + cookies: any; +} + ? Omit & { + cookies?: { + [K in keyof TValidatedInput["cookies"]]?: TValidatedInput["cookies"][K]; + }; + } + : TValidatedInput; + +export type InferRouteInput< + TRouter extends TypiRouter, + TPath extends keyof TRouter["routes"], + TMethod extends keyof TRouter["routes"][TPath], +> = + TRouter["routes"][TPath][TMethod] extends RouteDefinition< + any, + infer TInput, + any + > + ? TPath extends string + ? RouteHandlerValidatedInput extends infer TValidatedInput + ? TransformCookies + : never + : never + : never; + +// type InferRouteInput< +// TRouter extends TypiRouter, +// TPath extends keyof TRouter["routes"], +// TMethod extends keyof TRouter["routes"][TPath], +// > = +// TRouter["routes"][TPath][TMethod] extends RouteDefinition< +// any, +// infer TInput, +// any +// > +// ? TPath extends string +// ? RouteHandlerValidatedInput +// : never +// : never; + +// type InferRouteOutput< +// TRouter extends TypiRouter, +// TPath extends keyof TRouter["routes"], +// TMethod extends keyof TRouter["routes"][TPath], +// > = +// TRouter["routes"][TPath][TMethod] extends RouteDefinition< +// any, +// any, +// infer TMiddlewares, +// infer THandlerOutput +// > +// ? +// | ExtractRoutesOutputsByStatusCodes +// | THandlerOutput // Has actual middlewares +// : never; + +export type InferRouteOutput< + TRouter extends TypiRouter, + TPath extends keyof TRouter["routes"], + TMethod extends keyof TRouter["routes"][TPath], + TGlobalOptions, + TRequestOptions, +> = + TRouter["routes"][TPath][TMethod] extends RouteDefinition< + any, + any, + infer TMiddlewares, + infer THandlerOutput + > + ? ResolveThrowOnErrorStatus extends true + ? // When resolved throwOnErrorStatus is true, only return success responses + Extract + : // When resolved throwOnErrorStatus is false, return all possible responses + | ExtractRoutesOutputsByStatusCodes + | THandlerOutput + : never; + +export type InferRouterInputs> = { + [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter + ? InferRouterInputs + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: InferRouteInput< + TRouter, + Path, + Method + >; + } + : never; +}; + +export type InferRouterOutputs> = { + [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter + ? InferRouterOutputs + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: Extract< + InferRouteOutput, + { + status: "OK"; + } + >["data"]; + } + : never; +}; diff --git a/packages/typiclient/src/types/util.ts b/packages/typiclient/src/types/util.ts new file mode 100644 index 0000000..71871a1 --- /dev/null +++ b/packages/typiclient/src/types/util.ts @@ -0,0 +1,4 @@ +export type StripLeadingSlash = S extends `/${infer R}` + ? R + : S; +export type MaybePromise = Promise | T; diff --git a/packages/typiserver/package.json b/packages/typiserver/package.json index 7236f16..73e65a4 100644 --- a/packages/typiserver/package.json +++ b/packages/typiserver/package.json @@ -40,7 +40,7 @@ "dependencies": { "express": "^4.18.2", "superjson": "^2.2.2", - "zod": "^3.22.4" + "zod": "^3.25.6" }, "devDependencies": { "@repo/eslint-config": "*", diff --git a/packages/typiserver/src/index.ts b/packages/typiserver/src/index.ts index 9dbbe41..f555f31 100644 --- a/packages/typiserver/src/index.ts +++ b/packages/typiserver/src/index.ts @@ -1,2 +1,2 @@ -export * from "./server" -export * from "./types" +export * from "./server"; +export * from "./types"; diff --git a/packages/typiserver/src/server.ts b/packages/typiserver/src/server.ts index 85d912e..6cbfa97 100644 --- a/packages/typiserver/src/server.ts +++ b/packages/typiserver/src/server.ts @@ -116,7 +116,26 @@ class TypiRouter { status: "OK", data: (data ?? {}) as TData extends undefined ? {} : TData, }; - // console.log(successData); + console.log( + JSON.stringify( + { + time: new Date().toString(), + request: { + URL: req.originalUrl, + method: req.method, + body: req.body, + path: req.params, + query: req.query, + }, + response: { + status: "OK", + data: successData.data, + }, + }, + null, + 2 + ) + ); return successData as any; }) as { (): RouteHandlerResponse<"OK", {}>; @@ -139,12 +158,65 @@ class TypiRouter { }, }, }; - console.error(errorData); + console.error( + JSON.stringify({ + time: new Date().toString(), + request: { + URL: req.originalUrl, + method: req.method, + body: req.body, + path: req.params, + query: req.query, + status: errorData.status, + }, + response: { + status: errorData.status, + data: errorData.data, + }, + }), + null, + 2 + ); return errorData; }, }; } + private setNestedValue(obj: any, path: string[], value: any) { + let current = obj; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!(key in current)) { + // Check if next key is numeric to create array vs object + const nextKey = path[i + 1]; + current[key] = /^\d+$/.test(nextKey) ? [] : {}; + } + current = current[key]; + } + current[path[path.length - 1]] = value; + } + + private transformFilesToBody(body: any, files: Express.Multer.File[]) { + if (!files || files.length === 0) return body; + + const result = { ...body }; + + files.forEach((file) => { + // Parse nested field names like 'user[picture]' -> user.picture + const fieldPath = file.fieldname.split(/[\[\]]+/).filter(Boolean); + + // Create a File-like object that matches your Zod schema + const fileObject = new File([file.buffer], file.originalname, { + type: file.mimetype, + }); + + // Set the file in the correct nested position + this.setNestedValue(result, fieldPath, fileObject); + }); + + return result; + } + private parseInputs( req: Request, ctx: RouteHandlerContext, @@ -152,13 +224,20 @@ class TypiRouter { input: Exact ) { const parsedInput: Record = {}; + + // Transform multer files back into the body structure + const transformedBody = this.transformFilesToBody( + req.body, + req.files as Express.Multer.File[] + ); + const inputsToParse: { key: keyof RouteHandlerInput | "path"; source: any; schema: z.ZodTypeAny | undefined; }[] = [ { key: "headers", source: req.headers, schema: input?.headers }, - { key: "body", source: req.body, schema: input?.body }, + { key: "body", source: transformedBody, schema: input?.body }, { key: "path", source: req.params, diff --git a/packages/typiserver/src/types.ts b/packages/typiserver/src/types.ts deleted file mode 100644 index ce09c10..0000000 --- a/packages/typiserver/src/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { type Request, type Response } from "express"; -import z from "zod"; -import { type TypiRouter } from "./server"; -import { - HttpMethod, - HttpStatusKey, - HttpSuccessStatusKey, - HttpErrorStatusKey, - HttpErrorStatusCode, -} from "./http"; - -export type ExtractRoutesOutputsByStatusCodes< - TRoutes extends RouteHandler[], - TStatusName extends HttpStatusKey, -> = { - [K in keyof TRoutes]: Awaited> extends infer TOutput - ? TOutput extends { - status: infer S; - } - ? S extends TStatusName - ? TOutput - : never - : never - : never; -}[number]; - -export type UnionToIntersection = ( - U extends any ? (x: U) => void : never -) extends (x: infer I) => void - ? I - : never; - -export type Exact = T extends TShape - ? Exclude extends never - ? T - : never - : never; - -export type RouteHandlerInput = { - headers?: z.ZodObject>; - body?: z.ZodObject>; - query?: z.ZodObject>; - cookies?: z.ZodObject>; -}; - -export type ExtractPathParams = - TPath extends `${string}/:${infer Param}/${infer Rest}` - ? { [K in Param]: string } & ExtractPathParams<`/${Rest}`> - : TPath extends `${string}/:${infer Param}` - ? { [K in Param]: string } - : {}; - -export type RouteHandlerValidatedInput< - TInput extends RouteHandlerInput, - TPath extends string, -> = { - [K in keyof TInput as TInput[K] extends undefined - ? never - : K]: TInput[K] extends z.ZodType ? z.infer : undefined; -} & ([keyof ExtractPathParams] extends [never] - ? {} // no path params, don't add path - : { path: ExtractPathParams }); - -export type RouteHandlerContext< - TPath extends string = string, - TInput extends RouteHandlerInput = RouteHandlerInput, - TMiddlewares extends MiddlewareHandlers = never, -> = { - input: RouteHandlerValidatedInput; - data: UnionToIntersection< - ExtractRoutesOutputsByStatusCodes< - TMiddlewares, - HttpSuccessStatusKey - >["data"] - >; - request: Request; - response: Response; - success: { - (): RouteHandlerResponse<"OK", {}>; - >( - data: TData - ): RouteHandlerResponse<"OK", TData>; - }; - error: ( - key: TErrorKey, - message?: string - ) => RouteHandlerResponse; -}; - -export interface RouteHandlerErrorDataResponse { - error: { - key: HttpErrorStatusKey; - code: HttpErrorStatusCode; - label: string; - message: string; - }; -} - -type Serialize = T extends Date - ? string - : T extends (infer U)[] - ? Serialize[] - : T extends Record - ? { [K in keyof T]: Serialize } - : T; - -export type RouteHandlerResponse< - TStatusKey extends HttpStatusKey = HttpStatusKey, - TData = TStatusKey extends HttpErrorStatusKey - ? RouteHandlerErrorDataResponse - : TStatusKey extends HttpSuccessStatusKey - ? Record - : never, -> = { - status: TStatusKey; - data: TData; -}; - -export type MiddlewareHandlers = RouteHandler[]; - -export type RouteHandler< - TPath extends string = string, - TInput extends RouteHandlerInput = RouteHandlerInput, - TMiddlewares extends MiddlewareHandlers = never, - TOutput extends RouteHandlerResponse = RouteHandlerResponse, -> = ( - ctx: RouteHandlerContext -) => TOutput | Promise; - -export type RouteDefinition< - TPath extends string = string, - TInput extends RouteHandlerInput = RouteHandlerInput, - TMiddlewares extends MiddlewareHandlers = never, - TOutput extends RouteHandlerResponse = RouteHandlerResponse, -> = { - middlewares?: TMiddlewares; - input?: TInput; - handler: RouteHandler; -}; - -export type TypiRoute = { - [Method in HttpMethod]?: RouteDefinition; -}; - -export type RouteMap = { - [TPath in string]: TypiRoute | TypiRouter; -}; diff --git a/packages/typiserver/src/types/context.ts b/packages/typiserver/src/types/context.ts new file mode 100644 index 0000000..394d6f6 --- /dev/null +++ b/packages/typiserver/src/types/context.ts @@ -0,0 +1,37 @@ +import { type Request, type Response } from "express"; +import { HttpSuccessStatusKey, HttpErrorStatusKey } from "../http"; +import { RouteHandlerInput, RouteHandlerValidatedInput } from "./input"; +import { MiddlewareHandlers } from "./route"; +import { UnionToIntersection } from "./util"; + +import { + ExtractRoutesOutputsByStatusCodes, + RouteHandlerErrorDataResponse, + RouteHandlerResponse, +} from "./response"; + +export type RouteHandlerContext< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, +> = { + input: RouteHandlerValidatedInput; + data: UnionToIntersection< + ExtractRoutesOutputsByStatusCodes< + TMiddlewares, + HttpSuccessStatusKey + >["data"] + >; + request: Request; + response: Response; + success: { + (): RouteHandlerResponse<"OK", {}>; + >( + data: TData + ): RouteHandlerResponse<"OK", TData>; + }; + error: ( + key: TErrorKey, + message?: string + ) => RouteHandlerResponse; +}; diff --git a/packages/typiserver/src/types/index.ts b/packages/typiserver/src/types/index.ts new file mode 100644 index 0000000..afa2fa2 --- /dev/null +++ b/packages/typiserver/src/types/index.ts @@ -0,0 +1,5 @@ +export * from "./context"; +export * from "./input"; +export * from "./response"; +export * from "./route"; +export * from "./util"; diff --git a/packages/typiserver/src/types/input.ts b/packages/typiserver/src/types/input.ts new file mode 100644 index 0000000..76b93de --- /dev/null +++ b/packages/typiserver/src/types/input.ts @@ -0,0 +1,26 @@ +import z from "zod"; + +export type RouteHandlerInput = { + headers?: z.ZodObject>; + body?: z.ZodObject>; + query?: z.ZodObject>; + cookies?: z.ZodObject>; +}; + +export type ExtractPathParams = + TPath extends `${string}/:${infer Param}/${infer Rest}` + ? { [K in Param]: string } & ExtractPathParams<`/${Rest}`> + : TPath extends `${string}/:${infer Param}` + ? { [K in Param]: string } + : {}; + +export type RouteHandlerValidatedInput< + TInput extends RouteHandlerInput, + TPath extends string, +> = { + [K in keyof TInput as TInput[K] extends undefined + ? never + : K]: TInput[K] extends z.ZodType ? z.infer : undefined; +} & ([keyof ExtractPathParams] extends [never] + ? {} // no path params, don't add path + : { path: ExtractPathParams }); diff --git a/packages/typiserver/src/types/response.ts b/packages/typiserver/src/types/response.ts new file mode 100644 index 0000000..faa4df9 --- /dev/null +++ b/packages/typiserver/src/types/response.ts @@ -0,0 +1,51 @@ +import { + HttpStatusKey, + HttpSuccessStatusKey, + HttpErrorStatusKey, + HttpErrorStatusCode, +} from "../http"; +import { RouteHandler } from "./route"; + +export type ExtractRoutesOutputsByStatusCodes< + TRoutes extends RouteHandler[], + TStatusName extends HttpStatusKey, +> = { + [K in keyof TRoutes]: Awaited> extends infer TOutput + ? TOutput extends { + status: infer S; + } + ? S extends TStatusName + ? TOutput + : never + : never + : never; +}[number]; + +type Serialize = T extends Date + ? string + : T extends (infer U)[] + ? Serialize[] + : T extends Record + ? { [K in keyof T]: Serialize } + : T; + +export interface RouteHandlerErrorDataResponse { + error: { + key: HttpErrorStatusKey; + code: HttpErrorStatusCode; + label: string; + message: string; + }; +} + +export type RouteHandlerResponse< + TStatusKey extends HttpStatusKey = HttpStatusKey, + TData = TStatusKey extends HttpErrorStatusKey + ? RouteHandlerErrorDataResponse + : TStatusKey extends HttpSuccessStatusKey + ? Record + : never, +> = { + status: TStatusKey; + data: TData; +}; diff --git a/packages/typiserver/src/types/route.ts b/packages/typiserver/src/types/route.ts new file mode 100644 index 0000000..3aab92d --- /dev/null +++ b/packages/typiserver/src/types/route.ts @@ -0,0 +1,35 @@ +import { type TypiRouter } from "../server"; +import { HttpMethod } from "../http"; +import { RouteHandlerInput } from "./input"; +import { RouteHandlerContext } from "./context"; +import { RouteHandlerResponse } from "./response"; + +export type MiddlewareHandlers = RouteHandler[]; + +export type RouteHandler< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, + TOutput extends RouteHandlerResponse = RouteHandlerResponse, +> = ( + ctx: RouteHandlerContext +) => TOutput | Promise; + +export type RouteDefinition< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, + TOutput extends RouteHandlerResponse = RouteHandlerResponse, +> = { + middlewares?: TMiddlewares; + input?: TInput; + handler: RouteHandler; +}; + +export type TypiRoute = { + [Method in HttpMethod]?: RouteDefinition; +}; + +export type RouteMap = { + [TPath in string]: TypiRoute | TypiRouter; +}; diff --git a/packages/typiserver/src/types/util.ts b/packages/typiserver/src/types/util.ts new file mode 100644 index 0000000..b13c4ca --- /dev/null +++ b/packages/typiserver/src/types/util.ts @@ -0,0 +1,11 @@ +export type UnionToIntersection = ( + U extends any ? (x: U) => void : never +) extends (x: infer I) => void + ? I + : never; + +export type Exact = T extends TShape + ? Exclude extends never + ? T + : never + : never;