Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b39bb45
chore: add base setup
Mar 13, 2026
8c12001
chore: improve types and add tests
Mar 13, 2026
f26f6c6
chore: remove unneeded code
Mar 13, 2026
90efdb8
chore: remove unneeded import
Mar 13, 2026
765e009
chore: migrate to pathResolver
Mar 16, 2026
2b3c4a3
chore: add support for fastify route builder
Mar 16, 2026
b481366
chore: add support for backend-http-client
Mar 16, 2026
cc563a2
chore: adjust gaps in http client
Mar 16, 2026
340976e
chore: add no body symbol
Mar 16, 2026
89809a1
chore: add symbols for no body and non json
Mar 16, 2026
606a2c4
chore: simplify contract types
Mar 16, 2026
fbda305
chore: add docs and deprecate outdated fns
Mar 17, 2026
166f87b
chore: add tests
Mar 17, 2026
dda76c5
chore: improve fastify api contracts
Mar 17, 2026
1303242
chore: add tests for fastify contracts
Mar 17, 2026
c6d3021
chore: add injectByRouteContract
Mar 17, 2026
e0debf3
chore: add api deprecations and update docs
Mar 17, 2026
d07624f
chore: add sendByRouteContract to be-http-client
Mar 17, 2026
e784858
chore: exec lint fix
Mar 17, 2026
b8849e3
chore: add fe-http-client support
Mar 18, 2026
4f78126
chore: improve typesafety
Mar 18, 2026
da05ddb
chore: resolve merge conflicts
Mar 18, 2026
e5c1bd1
chore: remove unneeded comments
Mar 18, 2026
6117006
chore: simplify fe-http-client
Mar 18, 2026
decce02
chore: exec lint fix
Mar 19, 2026
d85ed87
chore: further type simplification
Mar 19, 2026
dac7360
chore: improve path params types
Mar 20, 2026
c5344fd
fix: query passing
Mar 20, 2026
3920b2f
chore: bring back prev version range to fastify-api-contract
Mar 23, 2026
81138b3
chore: add non json response handling
Mar 23, 2026
bb7ebc0
chore: adjust tests
Mar 23, 2026
d87f811
fix: tests
Mar 23, 2026
1063d22
chore: extract headers util in fe-http-client
Mar 23, 2026
6871af2
Merge branch 'main' into extend-api-contracts-with-route-contract
CatchMe2 Mar 23, 2026
92126fc
chore: add client cleantup in tests
Mar 23, 2026
6655db8
fix: fe value for no content response
Mar 23, 2026
9184159
fix: tests
Mar 23, 2026
2450b89
chore: simplify non json naming
Mar 23, 2026
9fd432f
chore: add sse response and anyOfResponses to api-contract
Mar 25, 2026
0bdfd8d
chore: add support for dual mode to be http client
Mar 25, 2026
36592e4
chore: improve be http client
Mar 26, 2026
23a6b8d
chore: improve file structure
Mar 26, 2026
0771b83
chore: improve sse parsing
Mar 26, 2026
74d2cc2
chore: add headers handling
Mar 26, 2026
1ee3e93
chore: improve README
Mar 26, 2026
da2c83e
chore: add abort signal support
Mar 26, 2026
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
2 changes: 2 additions & 0 deletions packages/app/api-contracts/src/HttpStatusCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,5 @@ export type HttpStatusCode =
| 508
| 510
| 511

export type SuccessfulHttpStatusCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
2 changes: 2 additions & 0 deletions packages/app/api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export * from './contractBuilder.ts'
export * from './HttpStatusCodes.ts'
export * from './pathUtils.ts'
export * from './rest/restContractBuilder.ts'
export * from './route-contract/defineRouteContract.ts'
export * from './route-contract/inferTypes.ts'
// Dual-mode (hybrid) contracts
export * from './sse/dualModeContracts.ts'
// Contract builders
Expand Down
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>()
})
})
150 changes: 150 additions & 0 deletions packages/app/api-contracts/src/route-contract/defineRouteContract.ts
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>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)

export interface CommonRouteDefinitionMetadata extends Record<string, unknown> {}

export type CommonRouteDefinition<
  ...
  metadata?: CommonRouteDefinitionMetadata
}

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 }>()
})
})
34 changes: 34 additions & 0 deletions packages/app/api-contracts/src/route-contract/inferTypes.ts
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>>
Loading
Loading