-
Notifications
You must be signed in to change notification settings - Fork 1
Extend api contracts with route contract #890
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
base: main
Are you sure you want to change the base?
Changes from 10 commits
b39bb45
8c12001
f26f6c6
90efdb8
765e009
2b3c4a3
b481366
cc563a2
340976e
89809a1
606a2c4
fbda305
166f87b
dda76c5
1303242
c6d3021
e0debf3
d07624f
e784858
b8849e3
4f78126
da05ddb
e5c1bd1
6117006
decce02
d85ed87
dac7360
c5344fd
3920b2f
81138b3
bb7ebc0
d87f811
1063d22
6871af2
92126fc
6655db8
9184159
2450b89
9fd432f
0bdfd8d
36592e4
23a6b8d
0771b83
74d2cc2
1ee3e93
da2c83e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { describe, expectTypeOf, it } from 'vitest' | ||
| import { z } from 'zod/v4' | ||
| import { defineRouteContract } from './defineRouteContract.ts' | ||
| import type { InferSuccessSchema } from './inferTypes.ts' | ||
|
|
||
| describe('defineRouteContract', () => { | ||
| it('preserves responseSchemasByStatusCode for success schema inference', () => { | ||
| const schema200 = z.object({ name: z.string() }) | ||
| const route = defineRouteContract({ | ||
| method: 'get', | ||
| pathResolver: () => '/users', | ||
| responseSchemasByStatusCode: { | ||
| 200: schema200, | ||
| }, | ||
| }) | ||
|
|
||
| type SuccessSchema = InferSuccessSchema<typeof route.responseSchemasByStatusCode> | ||
| expectTypeOf<SuccessSchema>().toEqualTypeOf<typeof schema200>() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { z } from 'zod/v4' | ||
| import type { InferSchemaOutput, RoutePathResolver } from '../apiContracts.ts' | ||
| import type { HttpStatusCode } from '../HttpStatusCodes.ts' | ||
|
|
||
| export const ContractNoBody = Symbol.for('ContractNoBody'); | ||
| export type ContractNoBodyType = typeof ContractNoBody; | ||
|
|
||
| export const ContractNonJsonResponse = Symbol.for('ContractNonJsonResponse'); | ||
| export type ContractNonJsonResponseType = typeof ContractNonJsonResponse; | ||
|
|
||
| export type RouteContractResponse = ContractNoBodyType | ContractNonJsonResponseType | z.Schema | ||
|
|
||
| export type ResponseSchemasByStatusCode = Partial< | ||
| Record<HttpStatusCode, RouteContractResponse> | ||
| >; | ||
|
|
||
| type CommonRouteContract<PathParamsSchema extends z.Schema | undefined> = { | ||
| pathResolver: RoutePathResolver<InferSchemaOutput<PathParamsSchema>> | ||
| requestPathParamsSchema?: z.Schema | ||
| requestQuerySchema?: z.Schema | ||
| requestHeaderSchema?: z.Schema | ||
| responseHeaderSchema?: z.Schema | ||
| responseSchemasByStatusCode?: ResponseSchemasByStatusCode | ||
|
|
||
| metadata?: Record<string, unknown> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do I understand correctly that we lose customizability here, users no longer can augment this type globally?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're not losing anything, the existing contract implementation is identical (but I just inlined the type) |
||
| summary?: string | ||
| description?: string | ||
| tags?: readonly string[] | ||
| } | ||
|
|
||
| /** | ||
| * Configuration for building a GET route. | ||
| */ | ||
| export type GetRouteContract<PathParamsSchema extends z.Schema | undefined> = | ||
| CommonRouteContract<PathParamsSchema> & { | ||
| method: 'get' | ||
| requestBodySchema?: never | ||
| } | ||
|
|
||
| /** | ||
| * Configuration for building a DELETE route. | ||
| */ | ||
| export type DeleteRouteContract<PathParamsSchema extends z.Schema | undefined> = | ||
| CommonRouteContract<PathParamsSchema> & { | ||
| method: 'delete' | ||
| requestBodySchema?: never | ||
| } | ||
|
|
||
| /** | ||
| * Configuration for building a payload route (POST, PUT, PATCH). | ||
| */ | ||
| export type PayloadRouteContract<PathParamsSchema extends z.Schema | undefined> = | ||
| CommonRouteContract<PathParamsSchema> & { | ||
| method: 'post' | 'put' | 'patch' | ||
| requestBodySchema: z.Schema| ContractNoBodyType | ||
| } | ||
|
|
||
| export type RouteContract<PathParamsSchema extends z.Schema | undefined> = | ||
| | GetRouteContract<PathParamsSchema> | ||
| | DeleteRouteContract<PathParamsSchema> | ||
| | PayloadRouteContract<PathParamsSchema> | ||
|
|
||
| /** * Helper to prevent extra keys. | ||
| * If T has keys not in U, it forces an error. | ||
| */ | ||
| type Exactly<T, U> = T & { | ||
| [K in keyof T]: K extends keyof U ? T[K] : never | ||
| } | ||
|
|
||
| export const defineRouteContract = < | ||
| PathParamsSchema extends z.Schema | undefined, | ||
| const Contract extends RouteContract<PathParamsSchema>, | ||
| >( | ||
| route: Exactly<Contract, RouteContract<PathParamsSchema>> & { | ||
| requestPathParamsSchema?: PathParamsSchema | ||
| }, | ||
| ): Contract => route | ||
|
|
||
| export const mapRouteContractToPath = (routeConfig: RouteContract<any>): string => { | ||
| if (!routeConfig.requestPathParamsSchema) { | ||
| return routeConfig.pathResolver(undefined) | ||
| } | ||
|
|
||
| // biome-ignore lint/suspicious/noExplicitAny: cannot infer zod object with typed shape here | ||
| const shape = (routeConfig.requestPathParamsSchema as any).shape | ||
| const resolverParams: Record<string, string> = {} | ||
| for (const key of Object.keys(shape)) { | ||
| resolverParams[key] = `:${key}` | ||
| } | ||
|
|
||
| return routeConfig.pathResolver(resolverParams) | ||
| } | ||
|
|
||
| export const describeRouteContract = (routeConfig: RouteContract<any>): string => { | ||
| return `${routeConfig.method.toUpperCase()} ${mapRouteContractToPath(routeConfig)}` | ||
| } | ||
|
|
||
| const SUCCESSFUL_STATUS_CODES = [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] as const | ||
|
|
||
| export const getSuccessResponseSchema = (routeConfig: RouteContract<any>): z.Schema | null => { | ||
| const { responseSchemasByStatusCode } = routeConfig | ||
| if (!responseSchemasByStatusCode) { | ||
| return null | ||
| } | ||
|
|
||
| const schemas: z.Schema[] = [] | ||
|
|
||
| for (const code of SUCCESSFUL_STATUS_CODES) { | ||
| const value = responseSchemasByStatusCode[code] | ||
|
|
||
| if (!value) { | ||
| continue | ||
| } | ||
|
|
||
| if (typeof value === 'symbol') { | ||
| schemas.push(z.never()) | ||
| } else { | ||
| schemas.push(value) | ||
| } | ||
| } | ||
|
|
||
| if (schemas.length === 0) { | ||
| return null | ||
| } | ||
| if (schemas.length > 1) { | ||
| return z.union(schemas) | ||
| } | ||
| return schemas.at(0) ?? null | ||
| } | ||
|
|
||
|
|
||
| export const getIsEmptyResponseExpected = (routeConfig: RouteContract<any>): boolean => { | ||
| const { responseSchemasByStatusCode } = routeConfig | ||
| if (!responseSchemasByStatusCode) { | ||
| return true | ||
| } | ||
|
|
||
| let isEmptyResponseExpected = true | ||
|
|
||
| for (const code of SUCCESSFUL_STATUS_CODES) { | ||
| const value = responseSchemasByStatusCode[code] | ||
|
|
||
| if (value && typeof value !== 'symbol') { | ||
| isEmptyResponseExpected = false | ||
| break | ||
| } | ||
| } | ||
|
|
||
| return isEmptyResponseExpected | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { describe, expectTypeOf, it } from 'vitest' | ||
| import { z } from 'zod/v4' | ||
| import type { InferSuccessResponse, InferSuccessSchema } from './inferTypes.ts' | ||
|
|
||
| describe('InferSuccessSchema', () => { | ||
| it('extracts undefined when responseSchemasByStatusCode is undefined', () => { | ||
| type Result = InferSuccessSchema<undefined> | ||
| expectTypeOf<Result>().toEqualTypeOf<undefined>() | ||
| }) | ||
|
|
||
| it('extracts never when no success response schemas are defined', () => { | ||
| const schema404 = z.object({ message: z.string() }) | ||
|
|
||
| const schemaByStatusCode = { | ||
| 404: schema404, | ||
| } as const | ||
| type Result = InferSuccessSchema<typeof schemaByStatusCode> | ||
|
|
||
| expectTypeOf<Result>().toEqualTypeOf<never>() | ||
| }) | ||
|
|
||
| it('extracts the union of success response schemas', () => { | ||
| const schema200 = z.object({ name: z.string() }) | ||
| const schema201 = z.object({ id: z.string() }) | ||
| const schema404 = z.object({ message: z.string() }) | ||
|
|
||
| const schemaByStatusCode = { | ||
| 200: schema200, | ||
| 201: schema201, | ||
| 404: schema404, | ||
| } as const | ||
| type Result = InferSuccessSchema<typeof schemaByStatusCode> | ||
|
|
||
| expectTypeOf<Result>().toEqualTypeOf<typeof schema200 | typeof schema201>() | ||
| }) | ||
| }) | ||
|
|
||
| describe('InferSuccessResponse', () => { | ||
| it('extracts undefined when responseSchemasByStatusCode is undefined', () => { | ||
| type Result = InferSuccessResponse<undefined> | ||
| expectTypeOf<Result>().toEqualTypeOf<undefined>() | ||
| }) | ||
|
|
||
| it('extracts never when no success response schemas are defined', () => { | ||
| const schema404 = z.object({ message: z.string() }) | ||
|
|
||
| const schemaByStatusCode = { | ||
| 404: schema404, | ||
| } as const | ||
| type Result = InferSuccessResponse<typeof schemaByStatusCode> | ||
|
|
||
| expectTypeOf<Result>().toEqualTypeOf<never>() | ||
| }) | ||
|
|
||
| it('extracts the union of success response schemas', () => { | ||
| const schema200 = z.object({ name: z.string() }) | ||
| const schema201 = z.object({ id: z.string() }) | ||
| const schema404 = z.object({ message: z.string() }) | ||
|
|
||
| const schemaByStatusCode = { | ||
| 200: schema200, | ||
| 201: schema201, | ||
| 404: schema404, | ||
| } as const | ||
| type Result = InferSuccessResponse<typeof schemaByStatusCode> | ||
|
|
||
| expectTypeOf<Result>().toEqualTypeOf<{ name: string } | { id: string }>() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { z } from 'zod/v4' | ||
| import type { SuccessfulHttpStatusCode } from '../HttpStatusCodes.ts' | ||
| import type { ResponseSchemasByStatusCode } from './defineRouteContract.ts' | ||
|
|
||
| type InferSchemaOutput<T extends z.ZodSchema | undefined> = T extends z.ZodSchema | ||
| ? z.output<T> | ||
| : undefined | ||
|
|
||
| /** Maps ContractNoBodyType and ContractNonJsonResponseType to undefined, preserving z.Schema as-is. */ | ||
| type ToZodSchema<T> = T extends z.Schema ? T : undefined | ||
|
|
||
| type ValueOf< | ||
| ObjectType, | ||
| ValueType extends keyof ObjectType = keyof ObjectType, | ||
| > = ObjectType[ValueType] | ||
|
|
||
| /** | ||
| * Infers the union of all success response Zod schemas from a responseSchemaByStatusCode map. | ||
| * ContractNoBody entries are mapped to undefined. | ||
| */ | ||
| export type InferSuccessSchema< | ||
| T extends ResponseSchemasByStatusCode | undefined, | ||
| > = | ||
| T extends ResponseSchemasByStatusCode | ||
| ? ToZodSchema<ValueOf<T, Extract<keyof T, SuccessfulHttpStatusCode>>> | ||
| : undefined | ||
|
|
||
| /** | ||
| * Infers the union of TypeScript output types of all success response schemas | ||
| * from a responseSchemasByStatusCode map. | ||
| */ | ||
| export type InferSuccessResponse< | ||
| T extends ResponseSchemasByStatusCode | undefined, | ||
| > = InferSchemaOutput<InferSuccessSchema<T>> |
Uh oh!
There was an error while loading. Please reload this page.