diff --git a/packages/app/api-contracts/src/HttpStatusCodes.ts b/packages/app/api-contracts/src/HttpStatusCodes.ts index 340dd5cde..184164179 100644 --- a/packages/app/api-contracts/src/HttpStatusCodes.ts +++ b/packages/app/api-contracts/src/HttpStatusCodes.ts @@ -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] diff --git a/packages/app/api-contracts/src/index.ts b/packages/app/api-contracts/src/index.ts index 9a4455235..ba876211e 100644 --- a/packages/app/api-contracts/src/index.ts +++ b/packages/app/api-contracts/src/index.ts @@ -2,6 +2,10 @@ export * from './apiContracts.ts' // Universal contract builder export * from './contractBuilder.ts' export * from './HttpStatusCodes.ts' +export * from './new/constants.ts' +export * from './new/contractResponse.ts' +export * from './new/defineApiContract.ts' +export * from './new/inferTypes.ts' export * from './pathUtils.ts' export * from './rest/restContractBuilder.ts' // Dual-mode (hybrid) contracts diff --git a/packages/app/api-contracts/src/new/README.md b/packages/app/api-contracts/src/new/README.md new file mode 100644 index 000000000..7007a437a --- /dev/null +++ b/packages/app/api-contracts/src/new/README.md @@ -0,0 +1,236 @@ +# api-contracts + +API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route — its path, HTTP method, and request/response schemas — and serves as the single source of truth for both sides. + +The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This eliminates assumptions across the boundary and keeps documentation, validation, and types in sync. + +## Defining contracts + +### REST routes + +```ts +import { defineApiContract, ContractNoBody } from '@lokalise/api-contracts' +import { z } from 'zod/v4' + +// GET with path params +const getUser = defineApiContract({ + method: 'get', + requestPathParamsSchema: z.object({ userId: z.uuid() }), + pathResolver: ({ userId }) => `/users/${userId}`, + responsesByStatusCode: { + 200: z.object({ id: z.string(), name: z.string() }), + }, +}) + +// POST +const createUser = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: { + 201: z.object({ id: z.string(), name: z.string() }), + }, +}) + +// DELETE with no response body +const deleteUser = defineApiContract({ + method: 'delete', + requestPathParamsSchema: z.object({ userId: z.uuid() }), + pathResolver: ({ userId }) => `/users/${userId}`, + responsesByStatusCode: { + 204: ContractNoBody, + }, +}) +``` + +### Non-JSON responses + +Use `textResponse` for plain-text or CSV responses, and `blobResponse` for binary responses (images, PDFs, etc.). Both carry the content type. + +```ts +import { defineApiContract, textResponse, blobResponse } from '@lokalise/api-contracts' + +const exportCsv = defineApiContract({ + method: 'get', + pathResolver: () => '/export.csv', + responsesByStatusCode: { + 200: textResponse('text/csv'), + }, +}) + +const downloadPhoto = defineApiContract({ + method: 'get', + pathResolver: () => '/photo.png', + responsesByStatusCode: { + 200: blobResponse('image/png'), + }, +}) +``` + +### SSE and dual-mode routes + +Use `sseResponse()` inside `responsesByStatusCode` to define SSE event schemas. For endpoints that can respond with either JSON or an SSE stream depending on the `Accept` header, use `anyOfResponses()` to declare both options on the same status code. + +```ts +import { defineApiContract, sseResponse, anyOfResponses } from '@lokalise/api-contracts' +import { z } from 'zod/v4' + +// SSE-only +const notifications = defineApiContract({ + method: 'get', + pathResolver: () => '/notifications/stream', + responsesByStatusCode: { + 200: sseResponse({ + notification: z.object({ id: z.string(), message: z.string() }), + }), + }, +}) + +// Dual-mode: JSON response or SSE stream depending on Accept header +const chatCompletion = defineApiContract({ + method: 'post', + pathResolver: () => '/chat/completions', + requestBodySchema: z.object({ message: z.string() }), + responsesByStatusCode: { + 200: anyOfResponses([ + sseResponse({ + chunk: z.object({ delta: z.string() }), + done: z.object({ finish_reason: z.string() }), + }), + z.object({ text: z.string() }), + ]), + }, +}) +``` + +`getSseSchemaByEventName(contract)` extracts SSE event schemas from a contract: + +```ts +import { getSseSchemaByEventName } from '@lokalise/api-contracts' + +getSseSchemaByEventName(notifications) +// { notification: ZodObject<...> } + +getSseSchemaByEventName(chatCompletion) +// { chunk: ZodObject<...>, done: ZodObject<...> } +``` + +### All fields + +```ts +defineApiContract({ + // Required + method: 'get' | 'post' | 'put' | 'patch' | 'delete', + pathResolver: (pathParams) => string, + responsesByStatusCode: { + [statusCode]: z.ZodType | ContractNoBody | TypedTextResponse | TypedBlobResponse | TypedSseResponse | AnyOfResponses + }, + + // Path params — links pathResolver parameter type to the schema + requestPathParamsSchema: z.ZodObject, + + // Request + requestBodySchema: z.ZodType | ContractNoBody, // POST / PUT / PATCH only + requestQuerySchema: z.ZodType, + requestHeaderSchema: z.ZodType, + + // Response + responseHeaderSchema: z.ZodType, + + // Documentation + summary: string, + description: string, + tags: readonly string[], + metadata: Record, +}) +``` + +### Header schemas + +```ts +const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/api/data', + requestHeaderSchema: z.object({ + authorization: z.string(), + 'x-api-key': z.string(), + }), + responseHeaderSchema: z.object({ + 'x-ratelimit-remaining': z.string(), + 'cache-control': z.string(), + }), + responsesByStatusCode: { + 200: dataSchema, + }, +}) +``` + +### Type utilities + +**`InferNonSseSuccessResponses`** — TypeScript output type of all non-SSE 2xx responses. JSON schemas → `z.output`, `textResponse` → `string`, `blobResponse` → `Blob`, `ContractNoBody` → `undefined`, `sseResponse` → `never` (excluded). `anyOfResponses` entries are unpacked before mapping. + +```ts +import type { InferNonSseSuccessResponses } from '@lokalise/api-contracts' + +type UserResponse = InferNonSseSuccessResponses +// { id: string; name: string } + +type CsvResponse = InferNonSseSuccessResponses +// string +``` + +**`InferJsonSuccessResponses`** — union of Zod schema types for all JSON 2xx entries. Text, Blob, SSE, and `ContractNoBody` entries are excluded. + +**`InferSseSuccessResponses`** — extracts the SSE event schema map type from a `responsesByStatusCode` map. Returns `never` when no SSE schemas are present. + +**`HasAnySseSuccessResponse`** — `true` if any 2xx entry is a `TypedSseResponse` or an `AnyOfResponses` containing one. + +**`HasAnyJsonSuccessResponse`** — `true` if any 2xx entry is a JSON Zod schema or an `AnyOfResponses` containing one. + +**`IsNoBodySuccessResponse`** — `true` when all 2xx entries are `ContractNoBody` or no 2xx status codes are defined. + +### Utility functions + +**`mapApiContractToPath`** — Express/Fastify-style path pattern. + +```ts +import { mapApiContractToPath } from '@lokalise/api-contracts' + +mapApiContractToPath(getUser) // "/users/:userId" +``` + +**`describeApiContract`** — human-readable `"METHOD /path"` string. + +```ts +import { describeApiContract } from '@lokalise/api-contracts' + +describeApiContract(getUser) // "GET /users/:userId" +``` + +**`getSuccessResponseSchema`** — merged Zod schema from all 2xx JSON entries. `ContractNoBody` and non-JSON entries are excluded. Returns `null` when no schema is present. + +```ts +import { getSuccessResponseSchema } from '@lokalise/api-contracts' + +getSuccessResponseSchema(getUser) // ZodObject +getSuccessResponseSchema(deleteUser) // null +``` + +**`getIsEmptyResponseExpected`** — `true` when no Zod schema exists among 2xx entries. + +```ts +import { getIsEmptyResponseExpected } from '@lokalise/api-contracts' + +getIsEmptyResponseExpected(deleteUser) // true +getIsEmptyResponseExpected(getUser) // false +``` + +**`getSseSchemaByEventName`** — extracts SSE event schemas from a contract. Returns `null` when no SSE schemas are present. + +```ts +import { getSseSchemaByEventName } from '@lokalise/api-contracts' + +getSseSchemaByEventName(notifications) // { notification: ZodObject<...> } +getSseSchemaByEventName(getUser) // null +``` + diff --git a/packages/app/api-contracts/src/new/constants.ts b/packages/app/api-contracts/src/new/constants.ts new file mode 100644 index 000000000..57c9dda89 --- /dev/null +++ b/packages/app/api-contracts/src/new/constants.ts @@ -0,0 +1 @@ +export const ContractNoBody = Symbol.for('ContractNoBody') diff --git a/packages/app/api-contracts/src/new/contractResponse.spec.ts b/packages/app/api-contracts/src/new/contractResponse.spec.ts new file mode 100644 index 000000000..467e470c8 --- /dev/null +++ b/packages/app/api-contracts/src/new/contractResponse.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod/v4' +import { ContractNoBody } from './constants.ts' +import { + anyOfResponses, + blobResponse, + resolveContractResponse, + sseResponse, + textResponse, +} from './contractResponse.ts' + +describe('resolveContractResponse', () => { + describe('ContractNoBody', () => { + it('returns noContent regardless of content-type', () => { + expect(resolveContractResponse(ContractNoBody, 'application/json')).toEqual({ + kind: 'noContent', + }) + expect(resolveContractResponse(ContractNoBody, undefined)).toEqual({ kind: 'noContent' }) + }) + }) + + describe('missing content-type', () => { + it('returns null for typed responses when content-type is absent', () => { + expect(resolveContractResponse(z.object({ id: z.string() }), undefined)).toBeNull() + expect(resolveContractResponse(textResponse('text/csv'), undefined)).toBeNull() + expect(resolveContractResponse(blobResponse('image/png'), undefined)).toBeNull() + }) + }) + + describe('JSON (ZodType)', () => { + it('resolves to json for application/json content-type', () => { + const schema = z.object({ id: z.string() }) + const result = resolveContractResponse(schema, 'application/json') + expect(result).toEqual({ kind: 'json', schema }) + }) + + it('returns null for non-json content-type', () => { + const schema = z.object({ id: z.string() }) + expect(resolveContractResponse(schema, 'text/plain')).toBeNull() + }) + }) + + describe('textResponse', () => { + it('resolves to text when content-type matches', () => { + expect(resolveContractResponse(textResponse('text/csv'), 'text/csv; charset=utf-8')).toEqual({ + kind: 'text', + }) + }) + + it('returns null when content-type does not match', () => { + expect(resolveContractResponse(textResponse('text/csv'), 'application/json')).toBeNull() + }) + }) + + describe('blobResponse', () => { + it('resolves to blob when content-type matches', () => { + expect(resolveContractResponse(blobResponse('image/png'), 'image/png')).toEqual({ + kind: 'blob', + }) + }) + + it('returns null when content-type does not match', () => { + expect(resolveContractResponse(blobResponse('image/png'), 'application/json')).toBeNull() + }) + }) + + describe('sseResponse', () => { + it('resolves to sse for text/event-stream content-type', () => { + const schema = { update: z.object({ id: z.string() }) } + const result = resolveContractResponse(sseResponse(schema), 'text/event-stream') + expect(result).toEqual({ kind: 'sse', schemaByEventName: schema }) + }) + + it('returns null for non-sse content-type', () => { + expect( + resolveContractResponse(sseResponse({ update: z.string() }), 'application/json'), + ).toBeNull() + }) + }) + + describe('anyOfResponses', () => { + it('resolves to the first matching entry by content-type', () => { + const schema = z.object({ id: z.string() }) + const entry = anyOfResponses([textResponse('text/csv'), schema]) + + expect(resolveContractResponse(entry, 'text/csv')).toEqual({ kind: 'text' }) + expect(resolveContractResponse(entry, 'application/json')).toEqual({ kind: 'json', schema }) + }) + + it('resolves SSE entry inside anyOfResponses', () => { + const sseSchema = { tick: z.object({ count: z.number() }) } + const entry = anyOfResponses([sseResponse(sseSchema), z.object({ total: z.number() })]) + + expect(resolveContractResponse(entry, 'text/event-stream')).toEqual({ + kind: 'sse', + schemaByEventName: sseSchema, + }) + }) + + it('returns null when no entry matches content-type', () => { + const entry = anyOfResponses([textResponse('text/csv'), blobResponse('image/png')]) + expect(resolveContractResponse(entry, 'application/json')).toBeNull() + }) + }) +}) diff --git a/packages/app/api-contracts/src/new/contractResponse.ts b/packages/app/api-contracts/src/new/contractResponse.ts new file mode 100644 index 000000000..665f9b6a3 --- /dev/null +++ b/packages/app/api-contracts/src/new/contractResponse.ts @@ -0,0 +1,131 @@ +import type { z } from 'zod/v4' +import type { HttpStatusCode } from '../HttpStatusCodes.ts' +import { ContractNoBody } from './constants.ts' + +export type TypedTextResponse = { + readonly _tag: 'TextResponse' + readonly contentType: string +} + +export const textResponse = (contentType: string): TypedTextResponse => ({ + _tag: 'TextResponse', + contentType, +}) + +export const isTextResponse = (value: ApiContractResponse): value is TypedTextResponse => + typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'TextResponse' + +export type TypedBlobResponse = { + readonly _tag: 'BlobResponse' + readonly contentType: string +} + +export const blobResponse = (contentType: string): TypedBlobResponse => ({ + _tag: 'BlobResponse', + contentType, +}) + +export const isBlobResponse = (value: ApiContractResponse): value is TypedBlobResponse => + typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'BlobResponse' + +export type SseSchemaByEventName = Record + +export type TypedSseResponse = { + readonly _tag: 'SseResponse' + readonly schemaByEventName: T +} + +export const sseResponse = ( + schemaByEventName: T, +): TypedSseResponse => ({ + _tag: 'SseResponse', + schemaByEventName, +}) + +export const isSseResponse = (value: ApiContractResponse): value is TypedSseResponse => + typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'SseResponse' + +export type TypedJsonResponse = z.ZodType + +export type TypedApiContractResponse = + | TypedJsonResponse + | TypedTextResponse + | TypedBlobResponse + | TypedSseResponse + +export type AnyOfResponses = { + readonly _tag: 'AnyOfResponses' + readonly responses: T[] +} + +export const anyOfResponses = ( + responses: T[], +): AnyOfResponses => ({ + _tag: 'AnyOfResponses', + responses, +}) + +export const isAnyOfResponses = (value: ApiContractResponse): value is AnyOfResponses => + typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'AnyOfResponses' + +export type ApiContractResponse = typeof ContractNoBody | TypedApiContractResponse | AnyOfResponses + +export type ResponsesByStatusCode = Partial> + +export type ResponseKind = + | { kind: 'noContent' } + | { kind: 'text' } + | { kind: 'blob' } + | { kind: 'json'; schema: z.ZodType } + | { kind: 'sse'; schemaByEventName: SseSchemaByEventName } + +const matchTypedResponse = ( + entry: TypedApiContractResponse, + contentType: string, +): ResponseKind | null => { + if (isTextResponse(entry)) { + return contentType.includes(entry.contentType) ? { kind: 'text' } : null + } + + if (isBlobResponse(entry)) { + return contentType.includes(entry.contentType) ? { kind: 'blob' } : null + } + + if (isSseResponse(entry)) { + return contentType.includes('text/event-stream') + ? { kind: 'sse', schemaByEventName: entry.schemaByEventName } + : null + } + + if (contentType.includes('application/json')) { + return { kind: 'json', schema: entry } + } + + return null +} + +export const resolveContractResponse = ( + schemaEntry: ApiContractResponse, + contentType: string | undefined, +): ResponseKind | null => { + if (schemaEntry === ContractNoBody) { + return { kind: 'noContent' } + } + + if (!contentType) { + return null + } + + if (isAnyOfResponses(schemaEntry)) { + for (const item of schemaEntry.responses) { + const resolved = matchTypedResponse(item, contentType) + + if (resolved) { + return resolved + } + } + return null + } + + return matchTypedResponse(schemaEntry, contentType) +} diff --git a/packages/app/api-contracts/src/new/defineApiContract.spec.ts b/packages/app/api-contracts/src/new/defineApiContract.spec.ts new file mode 100644 index 000000000..8263fb8f7 --- /dev/null +++ b/packages/app/api-contracts/src/new/defineApiContract.spec.ts @@ -0,0 +1,569 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod/v4' +import { ContractNoBody } from './constants.ts' +import { + anyOfResponses, + blobResponse, + isAnyOfResponses, + isBlobResponse, + isSseResponse, + isTextResponse, + sseResponse, + type TypedBlobResponse, + type TypedTextResponse, + textResponse, +} from './contractResponse.ts' +import { + defineApiContract, + describeApiContract, + getIsEmptyResponseExpected, + getSseSchemaByEventName, + getSuccessResponseSchema, + mapApiContractToPath, +} from './defineApiContract.ts' +import type { InferJsonSuccessResponses } from './inferTypes.ts' + +describe('defineApiContract', () => { + describe('type inference', () => { + it('preserves responsesByStatusCode for success schema inference', () => { + const schema = z.object({ name: z.string() }) + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 200: schema }, + }) + + type Result = InferJsonSuccessResponses + expectTypeOf().toEqualTypeOf() + }) + + it('infers pathResolver param type from requestPathParamsSchema', () => { + defineApiContract({ + method: 'get', + requestPathParamsSchema: z.object({ userId: z.string(), orgId: z.string() }), + pathResolver: ({ userId, orgId }) => { + expectTypeOf(userId).toEqualTypeOf() + expectTypeOf(orgId).toEqualTypeOf() + return `/orgs/${orgId}/users/${userId}` + }, + responsesByStatusCode: {}, + }) + }) + + it('accepts pathResolver without params when no requestPathParamsSchema', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: {}, + }) + + expect(mapApiContractToPath(route)).toBe('/users') + }) + + it('preserves method literal type', () => { + const route = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: {}, + }) + + expectTypeOf(route.method).toEqualTypeOf<'post'>() + }) + + it('preserves ContractNoBody sentinel in responsesByStatusCode', () => { + const route = defineApiContract({ + method: 'delete', + requestPathParamsSchema: z.object({ userId: z.string() }), + pathResolver: ({ userId }) => `/users/${userId}`, + responsesByStatusCode: { 204: ContractNoBody }, + }) + + expectTypeOf(route.responsesByStatusCode['204']).toEqualTypeOf() + }) + + it('preserves TypedTextResponse in responsesByStatusCode', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/export.csv', + responsesByStatusCode: { + 200: textResponse('text/csv'), + }, + }) + + expectTypeOf(route.responsesByStatusCode['200']).toEqualTypeOf() + }) + + it('preserves TypedBlobResponse in responsesByStatusCode', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/photo.png', + responsesByStatusCode: { + 200: blobResponse('image/png'), + }, + }) + + expectTypeOf(route.responsesByStatusCode['200']).toEqualTypeOf() + }) + }) +}) + +describe('mapApiContractToPath', () => { + it('returns static path when no requestPathParamsSchema', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: {}, + }) + + expect(mapApiContractToPath(route)).toBe('/users') + }) + + it('replaces path params with :param placeholders', () => { + const route = defineApiContract({ + method: 'get', + requestPathParamsSchema: z.object({ userId: z.string() }), + pathResolver: ({ userId }) => `/users/${userId}`, + responsesByStatusCode: {}, + }) + + expect(mapApiContractToPath(route)).toBe('/users/:userId') + }) + + it('replaces multiple path params', () => { + const route = defineApiContract({ + method: 'get', + requestPathParamsSchema: z.object({ orgId: z.string(), userId: z.string() }), + pathResolver: ({ orgId, userId }) => `/orgs/${orgId}/users/${userId}`, + responsesByStatusCode: {}, + }) + + expect(mapApiContractToPath(route)).toBe('/orgs/:orgId/users/:userId') + }) +}) + +describe('describeApiContract', () => { + it('returns uppercased method and path', () => { + const route = defineApiContract({ + method: 'get', + requestPathParamsSchema: z.object({ userId: z.string() }), + pathResolver: ({ userId }) => `/users/${userId}`, + responsesByStatusCode: {}, + }) + + expect(describeApiContract(route)).toBe('GET /users/:userId') + }) + + it('works for POST routes', () => { + const route = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: {}, + }) + + expect(describeApiContract(route)).toBe('POST /users') + }) +}) + +describe('getSuccessResponseSchema', () => { + it('returns null when responsesByStatusCode is not defined', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: {}, + }) + + expect(getSuccessResponseSchema(route)).toBeNull() + }) + + it('returns z.never() when all success entries are sentinels', () => { + const route = defineApiContract({ + method: 'delete', + pathResolver: () => '/users/1', + responsesByStatusCode: { 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 = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 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 = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 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 = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: { 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 textResponse entries', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/export.csv', + responsesByStatusCode: { 200: textResponse('text/csv') }, + }) + + const result = getSuccessResponseSchema(route) + expect(result).not.toBeNull() + expect(result!.safeParse('anything').success).toBe(false) + }) + + it('returns z.never() for blobResponse entries', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/photo.png', + responsesByStatusCode: { 200: blobResponse('image/png') }, + }) + + 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 = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: { 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 responsesByStatusCode is not defined', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: {}, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(true) + }) + + it('returns true when all success entries are sentinels', () => { + const route = defineApiContract({ + method: 'delete', + pathResolver: () => '/users/1', + responsesByStatusCode: { 204: ContractNoBody }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(true) + }) + + it('returns true when only error status codes are defined', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 404: z.object({ message: z.string() }) }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(true) + }) + + it('returns false when any success entry has a Zod schema', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 200: z.object({ id: z.string() }) }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) + + it('returns false when a mix of schema and sentinel exists', () => { + const route = defineApiContract({ + method: 'post', + pathResolver: () => '/users', + requestBodySchema: z.object({ name: z.string() }), + responsesByStatusCode: { + 200: z.object({ id: z.string() }), + 204: ContractNoBody, + }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) + + it('returns false when a textResponse is present', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/export.csv', + responsesByStatusCode: { 200: textResponse('text/csv') }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) + + it('returns false when a blobResponse is present', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/photo.png', + responsesByStatusCode: { 200: blobResponse('image/png') }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) +}) + +describe('isTextResponse', () => { + it('returns true for TypedTextResponse', () => { + expect(isTextResponse(textResponse('text/csv'))).toBe(true) + }) + + it('returns false for z.ZodType', () => { + expect(isTextResponse(z.string())).toBe(false) + }) + + it('returns false for TypedBlobResponse', () => { + expect(isTextResponse(blobResponse('image/png'))).toBe(false) + }) + + it('returns false for ContractNoBody', () => { + expect(isTextResponse(ContractNoBody)).toBe(false) + }) +}) + +describe('isBlobResponse', () => { + it('returns true for TypedBlobResponse', () => { + expect(isBlobResponse(blobResponse('image/png'))).toBe(true) + }) + + it('returns false for z.ZodType', () => { + expect(isBlobResponse(z.string())).toBe(false) + }) + + it('returns false for TypedTextResponse', () => { + expect(isBlobResponse(textResponse('text/csv'))).toBe(false) + }) + + it('returns false for ContractNoBody', () => { + expect(isBlobResponse(ContractNoBody)).toBe(false) + }) +}) + +describe('isSseResponse', () => { + it('returns true for TypedSseResponse', () => { + const value = sseResponse({ chunk: z.object({ delta: z.string() }) }) + expect(isSseResponse(value)).toBe(true) + }) + + it('returns false for z.ZodType', () => { + expect(isSseResponse(z.string())).toBe(false) + }) + + it('returns false for ContractNoBody', () => { + expect(isSseResponse(ContractNoBody)).toBe(false) + }) + + it('returns false for TypedTextResponse', () => { + expect(isSseResponse(textResponse('text/csv'))).toBe(false) + }) +}) + +describe('isAnyOfResponses', () => { + it('returns true for AnyOfResponse', () => { + const value = anyOfResponses([sseResponse({ chunk: z.string() }), z.object({ id: z.string() })]) + expect(isAnyOfResponses(value)).toBe(true) + }) + + it('returns true for AnyOfResponse containing textResponse', () => { + const value = anyOfResponses([textResponse('text/csv')]) + expect(isAnyOfResponses(value)).toBe(true) + }) + + it('returns true for AnyOfResponse containing blobResponse', () => { + const value = anyOfResponses([blobResponse('image/png')]) + expect(isAnyOfResponses(value)).toBe(true) + }) + + it('returns false for TypedSseResponse', () => { + expect(isAnyOfResponses(sseResponse({ chunk: z.string() }))).toBe(false) + }) + + it('returns false for z.ZodType', () => { + expect(isAnyOfResponses(z.string())).toBe(false) + }) +}) + +describe('getSuccessResponseSchema with SSE', () => { + it('returns z.never() for sseResponse', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: sseResponse({ chunk: z.object({ delta: z.string() }) }), + }, + }) + + const result = getSuccessResponseSchema(route) + expect(result).not.toBeNull() + expect(result!.safeParse('anything').success).toBe(false) + }) + + it('returns the JSON schema from anyOfResponses, excluding sseResponse', () => { + const jsonSchema = z.object({ id: z.string() }) + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: anyOfResponses([sseResponse({ chunk: z.object({ delta: z.string() }) }), jsonSchema]), + }, + }) + + const result = getSuccessResponseSchema(route) + expect(result).toBe(jsonSchema) + }) + + it('returns null for anyOf with only sseResponse', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: anyOfResponses([sseResponse({ chunk: z.object({ delta: z.string() }) })]), + }, + }) + + expect(getSuccessResponseSchema(route)).toBeNull() + }) + + it('returns null for anyOf with only textResponse', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/export.csv', + responsesByStatusCode: { + 200: anyOfResponses([textResponse('text/csv')]), + }, + }) + + expect(getSuccessResponseSchema(route)).toBeNull() + }) + + it('returns the JSON schema from anyOfResponses, excluding textResponse', () => { + const jsonSchema = z.object({ id: z.string() }) + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/export', + responsesByStatusCode: { + 200: anyOfResponses([textResponse('text/csv'), jsonSchema]), + }, + }) + + expect(getSuccessResponseSchema(route)).toBe(jsonSchema) + }) +}) + +describe('getIsEmptyResponseExpected with SSE', () => { + it('returns false for sseResponse', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: sseResponse({ chunk: z.object({ delta: z.string() }) }), + }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) + + it('returns false for anyOf', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: anyOfResponses([sseResponse({ chunk: z.string() }), z.object({ id: z.string() })]), + }, + }) + + expect(getIsEmptyResponseExpected(route)).toBe(false) + }) +}) + +describe('getSseSchemaByEventName', () => { + it('returns null when no SSE schemas are present', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: { 200: z.object({ id: z.string() }) }, + }) + + expect(getSseSchemaByEventName(route)).toBeNull() + }) + + it('returns null when responsesByStatusCode is not defined', () => { + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/users', + responsesByStatusCode: {}, + }) + + expect(getSseSchemaByEventName(route)).toBeNull() + }) + + it('extracts schemas from sseResponse in responsesByStatusCode', () => { + const chunkSchema = z.object({ delta: z.string() }) + const doneSchema = z.object({ finish_reason: z.string() }) + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: sseResponse({ chunk: chunkSchema, done: doneSchema }), + }, + }) + + const result = getSseSchemaByEventName(route) + expect(result).not.toBeNull() + expect(result!.chunk).toBe(chunkSchema) + expect(result!.done).toBe(doneSchema) + }) + + it('extracts sseResponse schemas from inside anyOf', () => { + const chunkSchema = z.object({ delta: z.string() }) + const route = defineApiContract({ + method: 'get', + pathResolver: () => '/stream', + responsesByStatusCode: { + 200: anyOfResponses([sseResponse({ chunk: chunkSchema }), z.object({ id: z.string() })]), + }, + }) + + const result = getSseSchemaByEventName(route) + expect(result).not.toBeNull() + expect(result!.chunk).toBe(chunkSchema) + }) +}) diff --git a/packages/app/api-contracts/src/new/defineApiContract.ts b/packages/app/api-contracts/src/new/defineApiContract.ts new file mode 100644 index 000000000..4c4177c44 --- /dev/null +++ b/packages/app/api-contracts/src/new/defineApiContract.ts @@ -0,0 +1,160 @@ +import { z } from 'zod/v4' +import type { + CommonRouteDefinitionMetadata, + InferSchemaOutput, + RoutePathResolver, +} from '../apiContracts.ts' +import { SUCCESSFUL_HTTP_STATUS_CODES } from '../HttpStatusCodes.ts' +import type { Exactly } from '../typeUtils.ts' +import { ContractNoBody } from './constants.ts' +import { + isAnyOfResponses, + isBlobResponse, + isSseResponse, + isTextResponse, + type ResponsesByStatusCode, + type SseSchemaByEventName, +} from './contractResponse.ts' + +export type RequestPathParamsSchema = z.ZodObject + +export type CommonApiContract = { + // biome-ignore lint/suspicious/noExplicitAny: Required for compatibility with generics + pathResolver: RoutePathResolver + requestPathParamsSchema?: RequestPathParamsSchema + requestQuerySchema?: z.ZodType + requestHeaderSchema?: z.ZodType + responseHeaderSchema?: z.ZodType + responsesByStatusCode: ResponsesByStatusCode + + metadata?: CommonRouteDefinitionMetadata + summary?: string + description?: string + tags?: readonly string[] +} + +export type GetApiContract = CommonApiContract & { + method: 'get' + requestBodySchema?: never +} + +export type DeleteApiContract = CommonApiContract & { + method: 'delete' + requestBodySchema?: never +} + +export type PayloadApiContract = CommonApiContract & { + method: 'post' | 'put' | 'patch' + requestBodySchema: typeof ContractNoBody | z.ZodType +} + +export type ApiContract = GetApiContract | DeleteApiContract | PayloadApiContract + +type TypedPathApiContract = Omit< + ApiContract, + 'pathResolver' | 'requestPathParamsSchema' +> & { + pathResolver: RoutePathResolver> + requestPathParamsSchema?: T +} + +export const defineApiContract = < + PathParamsSchema extends RequestPathParamsSchema, + const Contract extends TypedPathApiContract, +>( + contract: Exactly> & { + requestPathParamsSchema?: PathParamsSchema + }, +): Contract => contract + +export const mapApiContractToPath = (routeConfig: ApiContract): string => { + if (!routeConfig.requestPathParamsSchema) { + return routeConfig.pathResolver(undefined) + } + + const resolverParams = Object.keys(routeConfig.requestPathParamsSchema.shape).reduce< + Record + >((acc, key) => { + acc[key] = `:${key}` + + return acc + }, {}) + + return routeConfig.pathResolver(resolverParams) +} + +export const describeApiContract = (routeConfig: ApiContract): string => { + return `${routeConfig.method.toUpperCase()} ${mapApiContractToPath(routeConfig)}` +} + +export const getSseSchemaByEventName = (routeConfig: ApiContract): SseSchemaByEventName | null => { + const result: SseSchemaByEventName = {} + + for (const value of Object.values(routeConfig.responsesByStatusCode)) { + if (isSseResponse(value)) { + Object.assign(result, value.schemaByEventName) + } else if (isAnyOfResponses(value)) { + for (const response of value.responses) { + if (isSseResponse(response)) { + Object.assign(result, response.schemaByEventName) + } + } + } + } + + return Object.keys(result).length > 0 ? result : null +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: it is acceptable +export const getSuccessResponseSchema = (routeConfig: ApiContract): z.ZodType | null => { + const schemas: z.ZodType[] = [] + let hasDirectNonJsonEntry = false + + for (const code of SUCCESSFUL_HTTP_STATUS_CODES) { + const value = routeConfig.responsesByStatusCode[code] + + if (!value) { + continue + } + + if (isAnyOfResponses(value)) { + for (const response of value.responses) { + if (!isSseResponse(response) && !isTextResponse(response) && !isBlobResponse(response)) { + schemas.push(response) + } + } + } else if ( + value === ContractNoBody || + isSseResponse(value) || + isTextResponse(value) || + isBlobResponse(value) + ) { + hasDirectNonJsonEntry = true + } else { + schemas.push(value) + } + } + + if (schemas.length > 1) { + return z.union(schemas) + } + if (schemas.length === 1) { + return schemas[0]! + } + return hasDirectNonJsonEntry ? z.never() : null +} + +export const getIsEmptyResponseExpected = (routeConfig: ApiContract): boolean => { + let isEmptyResponseExpected = true + + for (const code of SUCCESSFUL_HTTP_STATUS_CODES) { + const value = routeConfig.responsesByStatusCode[code] + + if (value && value !== ContractNoBody) { + isEmptyResponseExpected = false + break + } + } + + return isEmptyResponseExpected +} diff --git a/packages/app/api-contracts/src/new/inferTypes.spec.ts b/packages/app/api-contracts/src/new/inferTypes.spec.ts new file mode 100644 index 000000000..7cb598238 --- /dev/null +++ b/packages/app/api-contracts/src/new/inferTypes.spec.ts @@ -0,0 +1,208 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod/v4' +import { ContractNoBody } from './constants.ts' +import { anyOfResponses, blobResponse, sseResponse, textResponse } from './contractResponse.ts' +import { defineApiContract } from './defineApiContract.ts' +import type { + HasAnySseSuccessResponse, + InferJsonSuccessResponses, + InferSseSuccessResponses, +} from './inferTypes.ts' + +describe('inferTypes', () => { + describe('InferJsonSuccessResponses', () => { + it('returns never when no success response schemas are defined', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 404: z.object({ message: z.string() }) }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('extracts the union of JSON success schemas', () => { + const schema200 = z.object({ name: z.string() }) + const schema201 = z.object({ id: z.string() }) + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: schema200, + 201: schema201, + 404: z.object({ message: z.string() }), + }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns never for ContractNoBody', () => { + const contract = defineApiContract({ + method: 'delete', + pathResolver: () => '/test', + responsesByStatusCode: { 204: ContractNoBody }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns never for textResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 200: textResponse('text/csv') }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns never for blobResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 200: blobResponse('image/png') }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns never for sseResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: sseResponse({ chunk: z.object({ delta: z.string() }) }), + }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('extracts JSON schema from AnyOfResponses, excluding SSE', () => { + const jsonSchema = z.object({ id: z.string() }) + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: anyOfResponses([ + sseResponse({ chunk: z.object({ delta: z.string() }) }), + jsonSchema, + ]), + }, + }) + type Result = InferJsonSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + }) + + describe('HasAnySseSuccessResponse', () => { + it('returns false for JSON schema responses', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 200: z.object({ id: z.string() }) }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns false for ContractNoBody', () => { + const contract = defineApiContract({ + method: 'delete', + pathResolver: () => '/test', + responsesByStatusCode: { 204: ContractNoBody }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns true for sseResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: sseResponse({ chunk: z.object({ delta: z.string() }) }), + }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns true for AnyOfResponses containing sseResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: anyOfResponses([ + sseResponse({ chunk: z.object({ delta: z.string() }) }), + z.object({ id: z.string() }), + ]), + }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns false for AnyOfResponses containing only JSON schemas', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 200: anyOfResponses([z.object({ id: z.string() })]) }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('returns false for error-only status codes with sseResponse', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 400: sseResponse({ chunk: z.object({ delta: z.string() }) }), + }, + }) + type Result = HasAnySseSuccessResponse<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + }) + + describe('InferSseSuccessResponses', () => { + it('returns never for JSON schema responses', () => { + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { 200: z.object({ id: z.string() }) }, + }) + type Result = InferSseSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf() + }) + + it('extracts schemas object from sseResponse', () => { + const chunkSchema = z.object({ delta: z.string() }) + const doneSchema = z.object({ finish_reason: z.string() }) + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: sseResponse({ chunk: chunkSchema, done: doneSchema }), + }, + }) + type Result = InferSseSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf<'chunk' | 'done'>() + }) + + it('extracts SSE schemas object from AnyOfResponses', () => { + const chunkSchema = z.object({ delta: z.string() }) + const contract = defineApiContract({ + method: 'get', + pathResolver: () => '/test', + responsesByStatusCode: { + 200: anyOfResponses([sseResponse({ chunk: chunkSchema }), z.object({ id: z.string() })]), + }, + }) + type Result = InferSseSuccessResponses<(typeof contract)['responsesByStatusCode']> + expectTypeOf().toEqualTypeOf<'chunk'>() + }) + }) +}) diff --git a/packages/app/api-contracts/src/new/inferTypes.ts b/packages/app/api-contracts/src/new/inferTypes.ts new file mode 100644 index 000000000..2281bd7ca --- /dev/null +++ b/packages/app/api-contracts/src/new/inferTypes.ts @@ -0,0 +1,108 @@ +import type { z } from 'zod/v4' +import type { SuccessfulHttpStatusCode } from '../HttpStatusCodes.ts' +import type { IsUnion, ValueOf } from '../typeUtils.ts' +import type { ContractNoBody } from './constants.ts' +import type { ResponsesByStatusCode } from './contractResponse.ts' + +type ExtractSuccessResponses = ValueOf< + T, + Extract +> + +/** + * Returns true if all success responses have no body (ContractNoBody or no success status codes defined). + */ +export type IsNoBodySuccessResponse = [ + ExtractSuccessResponses, +] extends [typeof ContractNoBody | undefined] + ? true + : false + +type UnpackAnyOf = T extends { _tag: 'AnyOfResponses'; responses: Array } ? Item : T + +type FlatSuccessResponses = UnpackAnyOf> + +/** + * Returns true if any success status code entry is TypedSseResponse, + * or an AnyOfResponses containing a TypedSseResponse. + */ +export type HasAnySseSuccessResponse = + Extract, { _tag: 'SseResponse' }> extends never ? false : true + +type SseSchemaOf = T extends { _tag: 'SseResponse'; schemaByEventName: infer S } ? S : never + +/** + * Extracts the merged SSE event schema map from a responsesByStatusCode map. + * Returns the union of all `schemaByEventName` objects from TypedSseResponse entries, + * including those nested inside AnyOfResponses. + */ +export type InferSseSuccessResponses = SseSchemaOf< + FlatSuccessResponses +> + +/** + * Returns true if any success status code entry is a JSON Zod schema, + * or an AnyOfResponses containing one. + */ +export type HasAnyJsonSuccessResponse = + Extract, z.ZodType> extends never ? false : true + +type JsonSchemaOf = T extends z.ZodType ? T : never + +/** + * Extracts the union of JSON Zod schemas from all success responses, + * including those nested inside AnyOfResponses. Text, Blob, and SSE responses are excluded. + */ +export type InferJsonSuccessResponses = JsonSchemaOf< + FlatSuccessResponses +> + +type NonSseBodyOf = T extends { _tag: 'SseResponse' } + ? never + : T extends { _tag: 'BlobResponse' } + ? Blob + : T extends { _tag: 'TextResponse' } + ? string + : T extends z.ZodType + ? z.output + : undefined + +/** + * Infers the TypeScript output type of all non-SSE success responses. + * JSON schemas → z.output. TextResponse → string. BlobResponse → Blob. + * ContractNoBody → undefined. SseResponse → never (excluded). + * AnyOfResponses are unpacked before mapping. + */ +export type InferNonSseSuccessResponses = NonSseBodyOf< + FlatSuccessResponses +> + +/** + * Discriminated union of SSE events inferred from a schemaByEventName map. + * Each event is `{ event: EventName, data: z.output }`. + */ +export type SseEventOf = { + [K in keyof S]: K extends string + ? { event: K; data: S[K] extends z.ZodType ? z.output : never } + : never +}[keyof S] + +/** + * True when the contract has both SSE and non-SSE success responses (dual-mode). + */ +export type IsDualModeSse = + HasAnySseSuccessResponse extends true + ? IsUnion> extends true + ? true + : false + : false + +/** + * Union of response mode literals available for a given responsesByStatusCode map. + */ +export type AvailableResponseModes = + | (HasAnyJsonSuccessResponse extends true ? 'json' : never) + | (HasAnySseSuccessResponse extends true ? 'sse' : never) + | (Extract, { _tag: 'BlobResponse' }> extends never ? never : 'blob') + | (Extract, { _tag: 'TextResponse' }> extends never ? never : 'text') + | (Extract, typeof ContractNoBody> extends never ? never : 'noContent') diff --git a/packages/app/api-contracts/src/typeUtils.ts b/packages/app/api-contracts/src/typeUtils.ts new file mode 100644 index 000000000..7ad52eae6 --- /dev/null +++ b/packages/app/api-contracts/src/typeUtils.ts @@ -0,0 +1,22 @@ +/** + * Returns true when T is a union with more than one member. + */ +export type IsUnion = (T extends unknown ? ([U] extends [T] ? 0 : 1) : never) extends 0 + ? false + : true + +/** + * Helper to prevent extra keys. If T has keys not in U, it forces an error. + */ +export type Exactly = T & { + [K in keyof T]: K extends keyof U ? T[K] : never +} + +/** + * Extracts a union of value types from an object type. + * Optionally constrained to a subset of keys via ValueType. + */ +export type ValueOf< + ObjectType, + ValueType extends keyof ObjectType = keyof ObjectType, +> = ObjectType[ValueType]