Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
0fa0fed
chore: add basic implementation
Apr 1, 2026
682ee6e
chore: add fe send by api contract
Apr 1, 2026
e6c425e
chore: add http errors mapping
Apr 2, 2026
0088747
chore: rename to captureAsError
Apr 2, 2026
307a5c5
chore: improve tests
Apr 2, 2026
21517e7
chore: add docs
Apr 2, 2026
ae07ea4
chore: improve generic names
Apr 2, 2026
d7b0a16
chore: exec lint fix
Apr 2, 2026
a26e7f3
chore: simplify be client
Apr 2, 2026
ca601eb
chore: extract shared types to api-contracts
Apr 2, 2026
918c80e
chore: add response headers parsing
Apr 3, 2026
54d6c92
chore: improve fe types
Apr 3, 2026
4944851
chore: improve be http client types
Apr 3, 2026
94d82cb
chore: use native undici retry handler in be client
Apr 3, 2026
162e7b6
chore: add capture as error to be client
Apr 3, 2026
e1f80eb
chore: improve docs
Apr 3, 2026
12e1b6e
chore: add undici errors tests
Apr 3, 2026
2c1853e
chore: use witj json for import
Apr 3, 2026
93d2975
chore: add headers normalize
Apr 3, 2026
e02048e
chore: improve var naming
Apr 3, 2026
b0d3a17
fix: failing test
Apr 3, 2026
42997e5
chore: adjust fe client
Apr 3, 2026
b075e58
chore: exec lint fix
Apr 3, 2026
17442ff
chore: improve client headers infer type
Apr 3, 2026
c6b0a6a
fix: sse parsing
Apr 3, 2026
c78a0b5
chore: adjust docs
Apr 3, 2026
f8b4b09
chore: add tests for retry after header
Apr 3, 2026
8846c88
chore: adjust persing headers order
Apr 3, 2026
f38e7d3
chore: remove validateResponse param
Apr 4, 2026
2d46f44
chore: add UnexpecterResponseError
Apr 4, 2026
06572ca
chore: adjust error docs
Apr 5, 2026
1801339
chore: add error code
Apr 7, 2026
35ef43d
chore: fix retries with custom delay
Apr 7, 2026
dffafc5
chore: throw undici errors instead of returning them
Apr 9, 2026
2a81a07
chore: improve result types
Apr 9, 2026
5132ed0
chore: extract sse stream parsing
Apr 9, 2026
58f63a9
chore: align sse stream with browser standard
Apr 9, 2026
b9dff98
chore: improve retry handling
Apr 9, 2026
03e5578
chore: make options optional
Apr 9, 2026
f164606
chore: add retry to be client
Apr 10, 2026
396719c
chore: move new files to dedicated dir
Apr 10, 2026
7ba1518
chore: improve test coverage
Apr 10, 2026
0759001
chore: improve default retry config
Apr 10, 2026
2b70743
chore: improve docs
Apr 10, 2026
8f3d7eb
chore: add docs to sendByApiContract fn
Apr 10, 2026
eb4d045
chore: unify fe with be client
Apr 10, 2026
aed9196
chore: resolve conflicts
Apr 10, 2026
88781b5
chore: adjust fe client tests
Apr 10, 2026
3ab5304
Merge branch 'main' into be-http-client-send-by-route-contract
CatchMe2 Apr 10, 2026
c9bc38d
chore: improve test coverage for api-contracts
Apr 10, 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
1 change: 1 addition & 0 deletions packages/app/api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './apiContracts.ts'
// Universal contract builder
export * from './contractBuilder.ts'
export * from './HttpStatusCodes.ts'
export * from './new/clientTypes.ts'
export * from './new/constants.ts'
export * from './new/contractResponse.ts'
export * from './new/defineApiContract.ts'
Expand Down
358 changes: 358 additions & 0 deletions packages/app/api-contracts/src/new/clientTypes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod/v4'
import type {
ClientRequestParams,
HeadersParam,
InferNonSseClientResponse,
InferSseClientResponse,
} from './clientTypes.ts'
import { ContractNoBody } from './constants.ts'
import { anyOfResponses, blobResponse, sseResponse, textResponse } from './contractResponse.ts'
import { defineApiContract } from './defineApiContract.ts'

type DefaultHeaders = Record<string, string | undefined>

describe('clientTypes', () => {
describe('ClientRequestParams', () => {
it('has no required fields for a minimal contract', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/ping',
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>>().toEqualTypeOf<{
streaming?: never
pathParams?: undefined
body?: undefined
queryParams?: undefined
headers?: undefined
pathPrefix?: string
}>()
})

it('requires pathParams when requestPathParamsSchema is defined', () => {
const contract = defineApiContract({
method: 'get',
requestPathParamsSchema: z.object({ id: z.string() }),
pathResolver: ({ id }) => `/products/${id}`,
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>>().toEqualTypeOf<{
streaming?: never
pathParams: { id: string }
body?: undefined
queryParams?: undefined
headers?: undefined
pathPrefix?: string
}>()
})

it('requires body when requestBodySchema is defined', () => {
const contract = defineApiContract({
method: 'post',
pathResolver: () => '/products',
requestBodySchema: z.object({ name: z.string() }),
responsesByStatusCode: { 201: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>>().toEqualTypeOf<{
streaming?: never
pathParams?: undefined
body: { name: string }
queryParams?: undefined
headers?: undefined
pathPrefix?: string
}>()
})

it('requires queryParams when requestQuerySchema is defined', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products',
requestQuerySchema: z.object({ limit: z.number() }),
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>>().toEqualTypeOf<{
streaming?: never
pathParams?: undefined
body?: undefined
queryParams: { limit: number }
headers?: undefined
pathPrefix?: string
}>()
})

it('requires headers when requestHeaderSchema is defined, accepting plain object or function', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products',
requestHeaderSchema: z.object({ authorization: z.string() }),
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>>().toEqualTypeOf<{
streaming?: never
pathParams?: undefined
body?: undefined
queryParams?: undefined
headers: HeadersParam<{ authorization: string }>
pathPrefix?: string
}>()
})

it('pathPrefix is always optional', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products',
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>['pathPrefix']>().toEqualTypeOf<
string | undefined
>()
})

it('forbids streaming field for non-SSE contracts', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products',
responsesByStatusCode: { 200: z.unknown() },
})
expectTypeOf<ClientRequestParams<typeof contract, false>['streaming']>().toEqualTypeOf<
never | undefined
>()
})

it('forbids streaming field for SSE-only contracts', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: { 200: sseResponse({ update: z.object({ id: z.string() }) }) },
})
expectTypeOf<ClientRequestParams<typeof contract, true>['streaming']>().toEqualTypeOf<
never | undefined
>()
})

it('requires streaming: true for dual-mode contracts with TIsStreaming=true', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/feed',
responsesByStatusCode: {
200: anyOfResponses([
sseResponse({ update: z.object({ id: z.string() }) }),
z.object({ latest: z.string() }),
]),
},
})
expectTypeOf<ClientRequestParams<typeof contract, true>['streaming']>().toEqualTypeOf<true>()
expectTypeOf<
ClientRequestParams<typeof contract, false>['streaming']
>().toEqualTypeOf<false>()
})
})

describe('InferSseClientResponse', () => {
it('maps success code to SSE body and error code to as-is body', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: {
200: sseResponse({ update: z.object({ id: z.string() }) }),
404: z.object({ message: z.string() }),
},
})
type Result = InferSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<
| {
statusCode: 200
headers: DefaultHeaders
body: AsyncIterable<{
type: 'update'
data: { id: string }
lastEventId: string
retry: number | undefined
}>
}
| { statusCode: 404; headers: DefaultHeaders; body: { message: string } }
>()
})

it('extracts only SSE body for dual-mode success code', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: {
200: anyOfResponses([
sseResponse({ chunk: z.object({ delta: z.string() }) }),
z.object({ text: z.string() }),
]),
},
})
type Result = InferSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: DefaultHeaders
body: AsyncIterable<{
type: 'chunk'
data: { delta: string }
lastEventId: string
retry: number | undefined
}>
}>()
})

it('returns a single entry for an SSE-only contract', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: {
200: sseResponse({ tick: z.object({ count: z.number() }) }),
},
})
type Result = InferSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: DefaultHeaders
body: AsyncIterable<{
type: 'tick'
data: { count: number }
lastEventId: string
retry: number | undefined
}>
}>()
})

it('includes typed headers when responseHeaderSchema is defined', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: {
200: sseResponse({ tick: z.object({ count: z.number() }) }),
},
responseHeaderSchema: z.object({ 'x-request-id': z.string() }),
})
type Result = InferSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: { 'x-request-id': string } & Record<string, string | undefined>
body: AsyncIterable<{
type: 'tick'
data: { count: number }
lastEventId: string
retry: number | undefined
}>
}>()
})
})

describe('InferNonSseClientResponse', () => {
it('maps success code to non-SSE body and error code to as-is body', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products/1',
responsesByStatusCode: {
200: z.object({ id: z.number() }),
404: z.object({ message: z.string() }),
},
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<
| { statusCode: 200; headers: DefaultHeaders; body: { id: number } }
| { statusCode: 404; headers: DefaultHeaders; body: { message: string } }
>()
})

it('maps dual-mode success code to non-SSE body only', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events',
responsesByStatusCode: {
200: anyOfResponses([
sseResponse({ chunk: z.object({ delta: z.string() }) }),
z.object({ text: z.string() }),
]),
},
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: DefaultHeaders
body: { text: string }
}>()
})

it('maps ContractNoBody success to null body', () => {
const contract = defineApiContract({
method: 'delete',
pathResolver: () => '/products/1',
responsesByStatusCode: { 204: ContractNoBody },
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 204
headers: DefaultHeaders
body: null
}>()
})

it('maps text success response to string body', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/export.csv',
responsesByStatusCode: { 200: textResponse('text/csv') },
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: DefaultHeaders
body: string
}>()
})

it('maps blob success response to Blob body', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/photo.png',
responsesByStatusCode: { 200: blobResponse('image/png') },
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: DefaultHeaders
body: Blob
}>()
})

it('includes typed headers when responseHeaderSchema is defined', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products/1',
responsesByStatusCode: { 200: z.object({ id: z.number() }) },
responseHeaderSchema: z.object({ 'x-request-id': z.string() }),
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: Omit<Record<string, string | undefined>, 'x-request-id'> & {
'x-request-id': string
}
body: { id: number }
}>()
})

it('allows non-string transformed header types without collapsing to never', () => {
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/products/1',
responsesByStatusCode: { 200: z.object({ id: z.number() }) },
responseHeaderSchema: z.object({ 'x-retry-count': z.coerce.number() }),
})
type Result = InferNonSseClientResponse<typeof contract>
expectTypeOf<Result>().toEqualTypeOf<{
statusCode: 200
headers: Omit<Record<string, string | undefined>, 'x-retry-count'> & {
'x-retry-count': number
}
body: { id: number }
}>()
})
})
})
Loading
Loading