Skip to content
Open
Show file tree
Hide file tree
Changes from 36 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
544 changes: 209 additions & 335 deletions packages/app/api-contracts/README.md

Large diffs are not rendered by default.

6 changes: 6 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,9 @@ export type HttpStatusCode =
| 508
| 510
| 511

export const SUCCESSFUL_HTTP_STATUS_CODES = [
200, 201, 202, 203, 204, 205, 206, 207, 208, 226,
] as const

export type SuccessfulHttpStatusCode = (typeof SUCCESSFUL_HTTP_STATUS_CODES)[number]
2 changes: 2 additions & 0 deletions packages/app/api-contracts/src/contractBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import type { SSEEventSchemas } from './sse/sseTypes.ts'
// ============================================================================

/**
* @deprecated Use `defineRouteContract` instead. This builder will be removed in a future version.
*
* Universal contract builder that creates either REST or SSE contracts based on configuration.
*
* This is a unified entry point that delegates to:
Expand Down
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
2 changes: 2 additions & 0 deletions packages/app/api-contracts/src/rest/restContractBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export type PayloadContractConfig<
// ============================================================================

/**
* @deprecated Use `defineRouteContract` instead. This builder will be removed in a future version.
*
* Builds REST API contracts with automatic type inference.
*
* This unified builder replaces the individual `buildGetRoute`, `buildPayloadRoute`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { z } from 'zod/v4'
import {
ContractNoBody,
defineNonJsonResponse,
defineRouteContract,
describeRouteContract,
getIsEmptyResponseExpected,
getIsNonJsonResponseExpected,
getSuccessResponseSchema,
mapRouteContractToPath,
} from './defineRouteContract.ts'
import type { InferSuccessSchema } from './inferTypes.ts'

describe('defineRouteContract', () => {
describe('type inference', () => {
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>()
})

it('infers pathResolver param type from requestPathParamsSchema', () => {
defineRouteContract({
method: 'get',
requestPathParamsSchema: z.object({ userId: z.string(), orgId: z.string() }),
pathResolver: ({ userId, orgId }) => {
expectTypeOf(userId).toEqualTypeOf<string>()
expectTypeOf(orgId).toEqualTypeOf<string>()
return `/orgs/${orgId}/users/${userId}`
},
})
})

it('accepts pathResolver without params when no requestPathParamsSchema', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
})

expect(mapRouteContractToPath(route)).toBe('/users')
})

it('preserves method literal type', () => {
const route = defineRouteContract({
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
})

expectTypeOf(route.method).toEqualTypeOf<'post'>()
})

it('preserves ContractNoBody sentinel in responseSchemasByStatusCode', () => {
const route = defineRouteContract({
method: 'delete',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: ({ userId }) => `/users/${userId}`,
responseSchemasByStatusCode: { 204: ContractNoBody },
})

expectTypeOf(route.responseSchemasByStatusCode['204']).toEqualTypeOf<typeof ContractNoBody>()
})

it('preserves TypedNonJsonResponse in responseSchemasByStatusCode', () => {
const schema = z.string()
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/export.csv',
responseSchemasByStatusCode: {
200: defineNonJsonResponse({ contentType: 'text/csv', schema }),
},
})

expectTypeOf(route.responseSchemasByStatusCode['200']).toEqualTypeOf<
ReturnType<typeof defineNonJsonResponse<typeof schema>>
>()
})

it('preserves serverSentEventSchemas', () => {
const chunkSchema = z.object({ delta: z.string() })
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/stream',
serverSentEventSchemas: { chunk: chunkSchema },
})

expectTypeOf(route.serverSentEventSchemas.chunk).toEqualTypeOf<typeof chunkSchema>()
})
})
})

describe('mapRouteContractToPath', () => {
it('returns static path when no requestPathParamsSchema', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
})

expect(mapRouteContractToPath(route)).toBe('/users')
})

it('replaces path params with :param placeholders', () => {
const route = defineRouteContract({
method: 'get',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: ({ userId }) => `/users/${userId}`,
})

expect(mapRouteContractToPath(route)).toBe('/users/:userId')
})

it('replaces multiple path params', () => {
const route = defineRouteContract({
method: 'get',
requestPathParamsSchema: z.object({ orgId: z.string(), userId: z.string() }),
pathResolver: ({ orgId, userId }) => `/orgs/${orgId}/users/${userId}`,
})

expect(mapRouteContractToPath(route)).toBe('/orgs/:orgId/users/:userId')
})
})

describe('describeRouteContract', () => {
it('returns uppercased method and path', () => {
const route = defineRouteContract({
method: 'get',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: ({ userId }) => `/users/${userId}`,
})

expect(describeRouteContract(route)).toBe('GET /users/:userId')
})

it('works for POST routes', () => {
const route = defineRouteContract({
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
})

expect(describeRouteContract(route)).toBe('POST /users')
})
})

describe('getSuccessResponseSchema', () => {
it('returns null when responseSchemasByStatusCode is not defined', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
})

expect(getSuccessResponseSchema(route)).toBeNull()
})

it('returns z.never() when all success entries are sentinels', () => {
const route = defineRouteContract({
method: 'delete',
pathResolver: () => '/users/1',
responseSchemasByStatusCode: { 204: ContractNoBody },
})

const result = getSuccessResponseSchema(route)
expect(result).not.toBeNull()
expect(result!.safeParse('anything').success).toBe(false)
})

it('returns null when only error status codes are defined', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: { 404: z.object({ message: z.string() }) },
})

expect(getSuccessResponseSchema(route)).toBeNull()
})

it('returns the schema for a single success entry', () => {
const schema = z.object({ id: z.string() })
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: { 200: schema },
})

expect(getSuccessResponseSchema(route)).toBe(schema)
})

it('returns a union schema for multiple success entries', () => {
const schema200 = z.object({ id: z.string() })
const schema201 = z.object({ name: z.string() })
const route = defineRouteContract({
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
responseSchemasByStatusCode: { 200: schema200, 201: schema201 },
})

const result = getSuccessResponseSchema(route)
expect(result).not.toBeNull()
expect(result!.parse({ id: 'x' })).toEqual({ id: 'x' })
expect(result!.parse({ name: 'x' })).toEqual({ name: 'x' })
})

it('returns z.never() for TypedNonJsonResponse entries', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/export.csv',
responseSchemasByStatusCode: {
200: defineNonJsonResponse({ contentType: 'text/csv', schema: z.string() }),
},
})

const result = getSuccessResponseSchema(route)
expect(result).not.toBeNull()
expect(result!.safeParse('anything').success).toBe(false)
})

it('contributes z.never() for sentinel entries in a mixed map', () => {
const schema200 = z.object({ id: z.string() })
const route = defineRouteContract({
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
responseSchemasByStatusCode: { 200: schema200, 204: ContractNoBody },
})

const result = getSuccessResponseSchema(route)
expect(result).not.toBeNull()
expect(result!.parse({ id: 'x' })).toEqual({ id: 'x' })
})
})

describe('getIsEmptyResponseExpected', () => {
it('returns true when responseSchemasByStatusCode is not defined', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
})

expect(getIsEmptyResponseExpected(route)).toBe(true)
})

it('returns true when all success entries are sentinels', () => {
const route = defineRouteContract({
method: 'delete',
pathResolver: () => '/users/1',
responseSchemasByStatusCode: { 204: ContractNoBody },
})

expect(getIsEmptyResponseExpected(route)).toBe(true)
})

it('returns true when only error status codes are defined', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: { 404: z.object({ message: z.string() }) },
})

expect(getIsEmptyResponseExpected(route)).toBe(true)
})

it('returns false when any success entry has a Zod schema', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: { 200: z.object({ id: z.string() }) },
})

expect(getIsEmptyResponseExpected(route)).toBe(false)
})

it('returns false when a mix of schema and sentinel exists', () => {
const route = defineRouteContract({
method: 'post',
pathResolver: () => '/users',
requestBodySchema: z.object({ name: z.string() }),
responseSchemasByStatusCode: {
200: z.object({ id: z.string() }),
204: ContractNoBody,
},
})

expect(getIsEmptyResponseExpected(route)).toBe(false)
})

it('returns false when a TypedNonJsonResponse is present', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/export.csv',
responseSchemasByStatusCode: {
200: defineNonJsonResponse({ contentType: 'text/csv', schema: z.string() }),
},
})

expect(getIsEmptyResponseExpected(route)).toBe(false)
})
})

describe('getIsNonJsonResponseExpected', () => {
it('returns false when responseSchemasByStatusCode is not defined', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
})

expect(getIsNonJsonResponseExpected(route)).toBe(false)
})

it('returns false for JSON schema responses', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: { 200: z.object({ id: z.string() }) },
})

expect(getIsNonJsonResponseExpected(route)).toBe(false)
})

it('returns false for ContractNoBody', () => {
const route = defineRouteContract({
method: 'delete',
pathResolver: () => '/users/1',
responseSchemasByStatusCode: { 204: ContractNoBody },
})

expect(getIsNonJsonResponseExpected(route)).toBe(false)
})

it('returns true for TypedNonJsonResponse', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/export.csv',
responseSchemasByStatusCode: {
200: defineNonJsonResponse({ contentType: 'text/csv', schema: z.string() }),
},
})

expect(getIsNonJsonResponseExpected(route)).toBe(true)
})

it('returns false when TypedNonJsonResponse is on an error status code', () => {
const route = defineRouteContract({
method: 'get',
pathResolver: () => '/users',
responseSchemasByStatusCode: {
400: defineNonJsonResponse({ contentType: 'text/plain', schema: z.string() }),
},
})

expect(getIsNonJsonResponseExpected(route)).toBe(false)
})
})
Loading
Loading