From e103b5487f7a2b162efc8133e09e051892cf9122 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:02:01 +0000 Subject: [PATCH 1/5] Initial plan From 96e5da9c282ce03ece75f9505f8c602343596c93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:11:39 +0000 Subject: [PATCH 2/5] Add typed client layer with types, implementation, and tests Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- packages/tspec/package.json | 5 + packages/tspec/rollup.config.ts | 2 + packages/tspec/src/client/index.ts | 118 ++++++++ packages/tspec/src/index.ts | 1 + packages/tspec/src/test/client.test.ts | 398 +++++++++++++++++++++++++ packages/tspec/src/types/client.ts | 96 ++++++ packages/tspec/src/types/index.ts | 1 + 7 files changed, 621 insertions(+) create mode 100644 packages/tspec/src/client/index.ts create mode 100644 packages/tspec/src/test/client.test.ts create mode 100644 packages/tspec/src/types/client.ts diff --git a/packages/tspec/package.json b/packages/tspec/package.json index 948d7e6..19445b2 100644 --- a/packages/tspec/package.json +++ b/packages/tspec/package.json @@ -57,6 +57,11 @@ "require": "./dist/index.cjs", "default": "./dist/index.js" }, + "./client": { + "types": "./dist/client/index.d.ts", + "require": "./dist/client.cjs", + "default": "./dist/client.js" + }, "./cli": { "types": "./dist/cli/index.d.ts", "require": "./dist/cli.cjs", diff --git a/packages/tspec/rollup.config.ts b/packages/tspec/rollup.config.ts index b16e211..a718325 100644 --- a/packages/tspec/rollup.config.ts +++ b/packages/tspec/rollup.config.ts @@ -7,6 +7,7 @@ export default defineConfig([ { input: { index: 'src/index.ts', + client: 'src/client/index.ts', cli: 'src/cli/index.ts', }, output: [ @@ -51,6 +52,7 @@ export default defineConfig([ { input: { index: 'src/index.ts', + client: 'src/client/index.ts', cli: 'src/cli/index.ts', }, output: [ diff --git a/packages/tspec/src/client/index.ts b/packages/tspec/src/client/index.ts new file mode 100644 index 0000000..a30d3db --- /dev/null +++ b/packages/tspec/src/client/index.ts @@ -0,0 +1,118 @@ +import type { ClientConfig, Client, ClientResponse } from '../types/client'; + +/** + * Substitute path parameters in a URL pattern + * E.g., "/authors/{id}" with params { id: 1 } => "/authors/1" + */ +function substitutePath(path: string, params?: Record): string { + if (!params) return path; + + let result = path; + for (const [key, value] of Object.entries(params)) { + result = result.replace(`{${key}}`, String(value)); + } + return result; +} + +/** + * Build query string from query parameters + */ +function buildQueryString(query?: Record): string { + if (!query) return ''; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach(v => params.append(key, String(v))); + } else { + params.append(key, String(value)); + } + } + } + + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +/** + * Create a type-safe API client + */ +export function createClient(config: ClientConfig): Client { + const { baseUrl, baseHeaders = {}, fetch: customFetch = globalThis.fetch } = config; + + async function request( + method: string, + path: string, + options: any + ): Promise> { + const { params, query, body, headers = {} } = options || {}; + + // Substitute path parameters + const substitutedPath = substitutePath(path, params); + + // Build query string + const queryString = buildQueryString(query); + + // Build full URL + const url = `${baseUrl}${substitutedPath}${queryString}`; + + // Merge headers + const mergedHeaders = { + ...baseHeaders, + ...headers, + }; + + // Build request options + const requestOptions: RequestInit = { + method: method.toUpperCase(), + headers: mergedHeaders, + }; + + // Add body if present + if (body !== undefined) { + if (mergedHeaders['Content-Type'] === 'application/json' || + !mergedHeaders['Content-Type']) { + requestOptions.body = JSON.stringify(body); + if (!mergedHeaders['Content-Type']) { + mergedHeaders['Content-Type'] = 'application/json'; + } + } else { + requestOptions.body = body as any; + } + } + + // Make request + const response = await customFetch(url, requestOptions); + + // Parse response body + let responseBody: any; + const contentType = response.headers.get('Content-Type'); + + if (contentType?.includes('application/json')) { + try { + responseBody = await response.json(); + } catch (e) { + responseBody = null; + } + } else { + responseBody = await response.text(); + } + + return { + status: response.status as any, + body: responseBody, + headers: response.headers, + } as ClientResponse; + } + + // Create client with methods for each HTTP verb + const client: any = {}; + + const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; + for (const method of methods) { + client[method] = (path: string, options?: any) => request(method, path, options); + } + + return client as Client; +} diff --git a/packages/tspec/src/index.ts b/packages/tspec/src/index.ts index 3571501..fb3c9fa 100644 --- a/packages/tspec/src/index.ts +++ b/packages/tspec/src/index.ts @@ -2,3 +2,4 @@ export * from './generator'; export * from './server'; export * from './types'; export * from './nestjs'; +export * from './client'; diff --git a/packages/tspec/src/test/client.test.ts b/packages/tspec/src/test/client.test.ts new file mode 100644 index 0000000..5eab47a --- /dev/null +++ b/packages/tspec/src/test/client.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createClient } from '../client'; +import type { Tspec } from '../types/tspec'; +import type { ExtractPathParams, ClientResponse, Client } from '../types/client'; + +// Define a test API spec +interface Author { + id: number; + name: string; +} + +interface Book { + id: number; + title: string; + authorId: number; +} + +type TestApiSpec = Tspec.DefineApiSpec<{ + paths: { + '/authors/{id}': { + get: { + summary: 'Get author by id'; + path: { id: number }; + responses: { + 200: Author; + 404: { message: string }; + }; + }; + }; + '/books': { + get: { + summary: 'List books'; + query: { page: number; limit: number }; + responses: { + 200: Book[]; + }; + }; + post: { + summary: 'Create a book'; + body: Omit; + responses: { + 201: Book; + 400: { error: string }; + }; + }; + }; + '/books/{id}': { + put: { + summary: 'Update a book'; + path: { id: number }; + body: Partial; + responses: { + 200: Book; + 404: { message: string }; + }; + }; + delete: { + summary: 'Delete a book'; + path: { id: number }; + responses: { + 204: ''; + }; + }; + }; + }; +}>; + +describe('Client Type System', () => { + describe('ExtractPathParams', () => { + it('should extract single path parameter', () => { + type Params = ExtractPathParams<'/authors/{id}'>; + const params: Params = { id: 1 }; + expect(params.id).toBe(1); + }); + + it('should extract multiple path parameters', () => { + type Params = ExtractPathParams<'/authors/{authorId}/books/{bookId}'>; + const params: Params = { authorId: 1, bookId: 2 }; + expect(params.authorId).toBe(1); + expect(params.bookId).toBe(2); + }); + + it('should return empty object for paths without parameters', () => { + type Params = ExtractPathParams<'/books'>; + const params: Params = {}; + expect(params).toEqual({}); + }); + }); +}); + +describe('Client Runtime', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + }); + + describe('createClient', () => { + it('should create a client with all HTTP methods', () => { + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + expect(client.get).toBeDefined(); + expect(client.post).toBeDefined(); + expect(client.put).toBeDefined(); + expect(client.patch).toBeDefined(); + expect(client.delete).toBeDefined(); + expect(client.options).toBeDefined(); + expect(client.head).toBeDefined(); + }); + + it('should substitute path parameters in URL', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ id: 1, name: 'John Doe' }), + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await client.get('/authors/{id}', { + params: { id: 1 }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/authors/1', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('should build query string from query parameters', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => [], + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await client.get('/books', { + query: { page: 1, limit: 10 }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/books?page=1&limit=10', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('should send JSON body for POST requests', async () => { + mockFetch.mockResolvedValue({ + status: 201, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ id: 1, title: 'New Book', authorId: 1 }), + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await client.post('/books', { + body: { title: 'New Book', authorId: 1 }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/books', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ title: 'New Book', authorId: 1 }), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('should merge base headers with request headers', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ id: 1, name: 'John Doe' }), + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Authorization': 'Bearer token', + }, + fetch: mockFetch, + }); + + await client.get('/authors/{id}', { + params: { id: 1 }, + headers: { 'X-Request-ID': '123' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/authors/1', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer token', + 'X-Request-ID': '123', + }), + }) + ); + }); + + it('should return response with correct status and body', async () => { + const expectedAuthor = { id: 1, name: 'John Doe' }; + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => expectedAuthor, + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + const response = await client.get('/authors/{id}', { + params: { id: 1 }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(expectedAuthor); + }); + + it('should handle 404 responses', async () => { + const errorBody = { message: 'Author not found' }; + mockFetch.mockResolvedValue({ + status: 404, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => errorBody, + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + const response = await client.get('/authors/{id}', { + params: { id: 999 }, + }); + + expect(response.status).toBe(404); + expect(response.body).toEqual(errorBody); + }); + + it('should handle PUT requests with path params and body', async () => { + const updatedBook = { id: 1, title: 'Updated Book', authorId: 1 }; + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => updatedBook, + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await client.put('/books/{id}', { + params: { id: 1 }, + body: { title: 'Updated Book' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/books/1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ title: 'Updated Book' }), + }) + ); + }); + + it('should handle DELETE requests', async () => { + mockFetch.mockResolvedValue({ + status: 204, + headers: new Headers(), + text: async () => '', + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + const response = await client.delete('/books/{id}', { + params: { id: 1 }, + }); + + expect(response.status).toBe(204); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/books/1', + expect.objectContaining({ + method: 'DELETE', + }) + ); + }); + + it('should handle non-JSON responses', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'text/plain' }), + text: async () => 'Plain text response', + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + const response = await client.get('/authors/{id}', { + params: { id: 1 }, + }); + + expect(response.body).toBe('Plain text response'); + }); + + it('should handle array query parameters', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => [], + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + type ExtendedApiSpec = Tspec.DefineApiSpec<{ + paths: { + '/books': { + get: { + query: { tags: string[] }; + responses: { 200: Book[] }; + }; + }; + }; + }>; + + const extendedClient = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await extendedClient.get('/books', { + query: { tags: ['fiction', 'adventure'] }, + }); + + // URLSearchParams will append multiple values + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain('tags=fiction'); + expect(calledUrl).toContain('tags=adventure'); + }); + + it('should use global fetch when custom fetch not provided', async () => { + // Save original fetch + const originalFetch = globalThis.fetch; + + // Mock global fetch + const mockGlobalFetch = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ id: 1, name: 'John Doe' }), + }); + globalThis.fetch = mockGlobalFetch; + + const client = createClient({ + baseUrl: 'https://api.example.com', + }); + + await client.get('/authors/{id}', { + params: { id: 1 }, + }); + + expect(mockGlobalFetch).toHaveBeenCalled(); + + // Restore original fetch + globalThis.fetch = originalFetch; + }); + }); +}); diff --git a/packages/tspec/src/types/client.ts b/packages/tspec/src/types/client.ts new file mode 100644 index 0000000..1c2c40a --- /dev/null +++ b/packages/tspec/src/types/client.ts @@ -0,0 +1,96 @@ +import { Tspec } from './tspec'; + +/** + * Extract path parameters from a URL pattern + * E.g., "/authors/{id}" => { id: string | number } + */ +export type ExtractPathParams = + Path extends `${infer _Start}/{${infer Param}}/${infer Rest}` + ? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string | number } + : Path extends `${infer _Start}/{${infer Param}}` + ? { [K in Param]: string | number } + : {}; + +/** + * Extract all paths from an API spec + */ +export type PathsFromSpec = Spec extends Record + ? P extends string + ? P + : never + : never; + +/** + * Extract all HTTP methods for a given path + */ +export type MethodsFromPath = + Path extends keyof Spec + ? Spec[Path] extends Record + ? M extends Tspec.HttpMethod + ? M + : never + : never + : never; + +/** + * Extract endpoint spec for a given path and method + */ +export type EndpointSpec = + Path extends keyof Spec + ? Method extends keyof Spec[Path] + ? Spec[Path][Method] + : never + : never; + +/** + * Extract response types from an endpoint spec + */ +export type ExtractResponses = + Spec extends { responses: infer R } + ? R extends Record + ? R + : never + : never; + +/** + * Create a discriminated union of responses by status code + */ +export type ClientResponse = { + [Status in keyof Responses]: { + status: Status; + body: Responses[Status]; + headers: Headers; + } +}[keyof Responses]; + +/** + * Extract request options for an endpoint + */ +export type RequestOptions = { + params?: Spec extends { path: infer P } ? P : never; + query?: Spec extends { query: infer Q } ? Q : never; + body?: Spec extends { body: infer B } ? B : never; + headers?: Spec extends { header: infer H } ? H : Record; +}; + +/** + * Client configuration + */ +export interface ClientConfig { + baseUrl: string; + baseHeaders?: Record; + fetch?: typeof fetch; +} + +/** + * Type-safe client interface + */ +export type Client = { + [Method in Tspec.HttpMethod]: < + Path extends PathsFromSpec, + Endpoint extends EndpointSpec + >( + path: Path, + options: RequestOptions + ) => Promise>>; +}; diff --git a/packages/tspec/src/types/index.ts b/packages/tspec/src/types/index.ts index 83630cc..f22fb9c 100644 --- a/packages/tspec/src/types/index.ts +++ b/packages/tspec/src/types/index.ts @@ -1 +1,2 @@ export * from './tspec'; +export * from './client'; From adffb993c7a56cbae132073782d236fe2adaa9b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:14:31 +0000 Subject: [PATCH 3/5] Add documentation and examples for typed client Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- examples/client-example/README.md | 78 +++++++++++++++++++ examples/client-example/client.ts | 23 ++++++ examples/client-example/package.json | 15 ++++ examples/client-example/server.ts | 21 +++++ .../tspec-basic-example/client-example.ts | 49 ++++++++++++ packages/tspec/README.md | 43 ++++++++++ 6 files changed, 229 insertions(+) create mode 100644 examples/client-example/README.md create mode 100644 examples/client-example/client.ts create mode 100644 examples/client-example/package.json create mode 100644 examples/client-example/server.ts create mode 100644 examples/tspec-basic-example/client-example.ts diff --git a/examples/client-example/README.md b/examples/client-example/README.md new file mode 100644 index 0000000..7d41274 --- /dev/null +++ b/examples/client-example/README.md @@ -0,0 +1,78 @@ +# Typed Client Example + +This example demonstrates how to use tspec's typed client layer to make type-safe API calls. + +## Features + +- **Full Type Safety**: The client infers types from your API specification +- **Path Parameters**: Automatically typed and substituted in URLs +- **Response Types**: Discriminated union types based on status codes +- **Query Parameters**: Fully typed query string support +- **Request Body**: Type-safe request body validation + +## Usage + +### 1. Define your API specification (server.ts) + +```typescript +import { Tspec } from 'tspec'; + +interface Author { + id: number; + name: string; +} + +export type AuthorApiSpec = Tspec.DefineApiSpec<{ + paths: { + '/authors/{id}': { + get: { + summary: 'Get author by id', + path: { id: number }, + responses: { + 200: Author, + 404: { message: string }, + }, + }, + }, + } +}>; +``` + +### 2. Create a typed client (client.ts) + +```typescript +import { createClient } from 'tspec'; +import { AuthorApiSpec } from './server'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Content-Type': 'application/json', + }, +}); + +const result = await client.get('/authors/{id}', { + params: { id: 1 }, +}); + +if (result.status === 200) { + console.log(result.body.name); // Fully typed as Author +} else if (result.status === 404) { + console.error(result.body.message); // Fully typed as { message: string } +} +``` + +## Type Safety Benefits + +1. **Path Parameters**: TypeScript will error if you forget to provide required path parameters or provide the wrong type +2. **Response Handling**: The response body type changes based on the status code, enabling exhaustive handling +3. **Request Body**: POST/PUT/PATCH requests have typed body parameters +4. **Query Parameters**: Query strings are fully typed +5. **Autocomplete**: Full IDE autocomplete support for paths, methods, and parameters + +## Running the Example + +```bash +npm install +npm start +``` diff --git a/examples/client-example/client.ts b/examples/client-example/client.ts new file mode 100644 index 0000000..b57cf75 --- /dev/null +++ b/examples/client-example/client.ts @@ -0,0 +1,23 @@ +import { createClient } from 'tspec'; +import { AuthorApiSpec } from './server'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Content-Type': 'application/json', + }, +}); + +async function main() { + const result = await client.get('/authors/{id}', { + params: { id: 1 }, + }); + + if (result.status === 200) { + console.log(result.body.name); // Fully typed as Author + } else if (result.status === 404) { + console.error(result.body.message); // Fully typed as { message: string } + } +} + +main(); diff --git a/examples/client-example/package.json b/examples/client-example/package.json new file mode 100644 index 0000000..822919b --- /dev/null +++ b/examples/client-example/package.json @@ -0,0 +1,15 @@ +{ + "name": "tspec-client-example", + "version": "1.0.0", + "description": "Example of using tspec typed client", + "main": "client.ts", + "scripts": { + "start": "tsx client.ts" + }, + "dependencies": { + "tspec": "workspace:*" + }, + "devDependencies": { + "tsx": "^3.12.7" + } +} diff --git a/examples/client-example/server.ts b/examples/client-example/server.ts new file mode 100644 index 0000000..559013d --- /dev/null +++ b/examples/client-example/server.ts @@ -0,0 +1,21 @@ +import { Tspec } from 'tspec'; + +interface Author { + id: number; + name: string; +} + +export type AuthorApiSpec = Tspec.DefineApiSpec<{ + paths: { + '/authors/{id}': { + get: { + summary: 'Get author by id', + path: { id: number }, + responses: { + 200: Author, + 404: { message: string }, + }, + }, + }, + } +}>; diff --git a/examples/tspec-basic-example/client-example.ts b/examples/tspec-basic-example/client-example.ts new file mode 100644 index 0000000..95e56aa --- /dev/null +++ b/examples/tspec-basic-example/client-example.ts @@ -0,0 +1,49 @@ +import { createClient } from 'tspec'; +import type { BookApiSpec } from './index'; + +/** + * Example client usage demonstrating type-safe API calls + */ + +// Create a typed client +const client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Content-Type': 'application/json', + }, +}); + +// Example: Get a book by ID with full type safety +async function getBookById(id: number) { + const result = await client.get('/books/{id}', { + params: { id }, + header: { 'X-Request-ID': 'example-request-123' }, + cookie: { debug: 1 }, + }); + + // TypeScript knows the exact response types based on status code + if (result.status === 200) { + // result.body is typed as Book + console.log('Book found:', result.body.title); + console.log('Author:', result.body.id); + return result.body; + } + + // Handle other status codes if defined in the API spec + // Note: This example only defines 200 response in the spec + console.error('Unexpected status:', result.status); + return null; +} + +// Example usage +getBookById(1) + .then(book => { + if (book) { + console.log('Successfully retrieved book:', book); + } + }) + .catch(error => { + console.error('Error fetching book:', error); + }); + +export { client }; diff --git a/packages/tspec/README.md b/packages/tspec/README.md index ae4dce4..79e0344 100644 --- a/packages/tspec/README.md +++ b/packages/tspec/README.md @@ -175,6 +175,49 @@ npx tspec generate --nestjs See the [NestJS Integration Guide](https://ts-spec.github.io/tspec/guide/nestjs-integration) for more details. +## Typed Client + +Tspec provides a fully typed client layer that enables end-to-end type safety between your API specification and client-side usage. + +### Creating a Typed Client + +```ts +import { createClient } from 'tspec'; +import type { AuthorApiSpec } from './server'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Content-Type': 'application/json', + }, +}); +``` + +### Making Type-Safe Requests + +```ts +const result = await client.get('/authors/{id}', { + params: { id: 1 }, +}); + +if (result.status === 200) { + console.log(result.body.name); // Fully typed as Author +} else if (result.status === 404) { + console.error(result.body.message); // Fully typed as { message: string } +} +``` + +### Features + +- **Path Parameters**: Automatically typed and substituted in URLs +- **Query Parameters**: Fully typed query string support +- **Request Body**: Type-safe request body validation for POST/PUT/PATCH +- **Response Types**: Discriminated union types based on status codes +- **Headers & Cookies**: Type-safe header and cookie parameters +- **All HTTP Methods**: Support for GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD + +See the [client example](../../examples/client-example) for a complete working example. + ## Documentation https://ts-spec.github.io/tspec From a718ba8fe3ce6d361c095763a50ac85fad58c1ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:18:33 +0000 Subject: [PATCH 4/5] Add cookie support and fix RequestOptions type Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- .../tspec-basic-example/client-example.ts | 4 +- packages/tspec/src/client/index.ts | 10 ++++- packages/tspec/src/test/client.test.ts | 39 +++++++++++++++++++ packages/tspec/src/types/client.ts | 1 + 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/examples/tspec-basic-example/client-example.ts b/examples/tspec-basic-example/client-example.ts index 95e56aa..0322386 100644 --- a/examples/tspec-basic-example/client-example.ts +++ b/examples/tspec-basic-example/client-example.ts @@ -17,8 +17,8 @@ const client = createClient({ async function getBookById(id: number) { const result = await client.get('/books/{id}', { params: { id }, - header: { 'X-Request-ID': 'example-request-123' }, - cookie: { debug: 1 }, + headers: { 'X-Request-ID': 'example-request-123' }, + cookies: { debug: 1 }, }); // TypeScript knows the exact response types based on status code diff --git a/packages/tspec/src/client/index.ts b/packages/tspec/src/client/index.ts index a30d3db..2c93fc5 100644 --- a/packages/tspec/src/client/index.ts +++ b/packages/tspec/src/client/index.ts @@ -46,7 +46,7 @@ export function createClient(config: ClientConfig): Client { path: string, options: any ): Promise> { - const { params, query, body, headers = {} } = options || {}; + const { params, query, body, headers = {}, cookies } = options || {}; // Substitute path parameters const substitutedPath = substitutePath(path, params); @@ -63,6 +63,14 @@ export function createClient(config: ClientConfig): Client { ...headers, }; + // Add cookies as Cookie header if provided + if (cookies && Object.keys(cookies).length > 0) { + const cookieString = Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join('; '); + mergedHeaders['Cookie'] = cookieString; + } + // Build request options const requestOptions: RequestInit = { method: method.toUpperCase(), diff --git a/packages/tspec/src/test/client.test.ts b/packages/tspec/src/test/client.test.ts index 5eab47a..6fc5861 100644 --- a/packages/tspec/src/test/client.test.ts +++ b/packages/tspec/src/test/client.test.ts @@ -394,5 +394,44 @@ describe('Client Runtime', () => { // Restore original fetch globalThis.fetch = originalFetch; }); + + it('should send cookies as Cookie header', async () => { + mockFetch.mockResolvedValue({ + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({ id: 1, name: 'John Doe' }), + }); + + type ApiSpecWithCookies = Tspec.DefineApiSpec<{ + paths: { + '/authors/{id}': { + get: { + path: { id: number }; + cookie: { sessionId: string; debug: number }; + responses: { 200: Author }; + }; + }; + }; + }>; + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: mockFetch, + }); + + await client.get('/authors/{id}', { + params: { id: 1 }, + cookies: { sessionId: 'abc123', debug: 1 }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/authors/1', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Cookie': 'sessionId=abc123; debug=1', + }), + }) + ); + }); }); }); diff --git a/packages/tspec/src/types/client.ts b/packages/tspec/src/types/client.ts index 1c2c40a..680d7c2 100644 --- a/packages/tspec/src/types/client.ts +++ b/packages/tspec/src/types/client.ts @@ -71,6 +71,7 @@ export type RequestOptions = { query?: Spec extends { query: infer Q } ? Q : never; body?: Spec extends { body: infer B } ? B : never; headers?: Spec extends { header: infer H } ? H : Record; + cookies?: Spec extends { cookie: infer C } ? C : Record; }; /** From 5c5d696fa35d2a9443a84f1f48efee2ec3ddccb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:22:00 +0000 Subject: [PATCH 5/5] Add type validation test for comprehensive type safety verification Co-authored-by: hanc2006 <4517251+hanc2006@users.noreply.github.com> --- packages/tspec/src/test/type-validation.ts | 137 +++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/tspec/src/test/type-validation.ts diff --git a/packages/tspec/src/test/type-validation.ts b/packages/tspec/src/test/type-validation.ts new file mode 100644 index 0000000..223003d --- /dev/null +++ b/packages/tspec/src/test/type-validation.ts @@ -0,0 +1,137 @@ +/** + * Type validation test - this file should compile without errors + * to ensure all type definitions work correctly + */ + +import { createClient, Tspec } from '../index'; +import type { + ExtractPathParams, + ClientResponse, + Client, + RequestOptions +} from '../types/client'; + +// Test API Spec +interface User { + id: number; + name: string; + email: string; +} + +type TestApiSpec = Tspec.DefineApiSpec<{ + paths: { + '/users/{id}': { + get: { + path: { id: number }; + responses: { + 200: User; + 404: { message: string }; + }; + }; + put: { + path: { id: number }; + body: Partial; + responses: { + 200: User; + 400: { errors: string[] }; + }; + }; + }; + '/users': { + get: { + query: { page: number; limit: number }; + header: { 'Authorization': string }; + cookie: { sessionId: string }; + responses: { + 200: User[]; + }; + }; + post: { + body: Omit; + responses: { + 201: User; + 400: { errors: string[] }; + }; + }; + }; + }; +}>; + +// Type tests +type _Test1 = ExtractPathParams<'/users/{id}'>; // Should be { id: string | number } +type _Test2 = ExtractPathParams<'/users/{userId}/posts/{postId}'>; // Should have both params +type _Test3 = ExtractPathParams<'/users'>; // Should be {} + +// Client creation +const client: Client = createClient({ + baseUrl: 'https://api.example.com', + baseHeaders: { + 'Content-Type': 'application/json', + }, +}); + +// Ensure methods exist +const _methodsExist: Record = { + get: client.get, + post: client.post, + put: client.put, + patch: client.patch, + delete: client.delete, + options: client.options, + head: client.head, +}; + +// Test that types are properly inferred +async function testTypeSafety() { + // GET request with path params + const getUserResult = await client.get('/users/{id}', { + params: { id: 1 }, + }); + + // Status-based type narrowing + if (getUserResult.status === 200) { + const user: User = getUserResult.body; + const _name: string = user.name; + } else if (getUserResult.status === 404) { + const error: { message: string } = getUserResult.body; + const _message: string = error.message; + } + + // GET request with query, headers, and cookies + const listUsersResult = await client.get('/users', { + query: { page: 1, limit: 10 }, + headers: { 'Authorization': 'Bearer token' }, + cookies: { sessionId: 'abc123' }, + }); + + if (listUsersResult.status === 200) { + const users: User[] = listUsersResult.body; + const _firstUser: User = users[0]; + } + + // POST request with body + const createUserResult = await client.post('/users', { + body: { name: 'John', email: 'john@example.com' }, + }); + + if (createUserResult.status === 201) { + const newUser: User = createUserResult.body; + const _id: number = newUser.id; + } else if (createUserResult.status === 400) { + const error: { errors: string[] } = createUserResult.body; + const _errors: string[] = error.errors; + } + + // PUT request with path params and body + const updateUserResult = await client.put('/users/{id}', { + params: { id: 1 }, + body: { name: 'Jane' }, + }); + + if (updateUserResult.status === 200) { + const updatedUser: User = updateUserResult.body; + const _updatedName: string = updatedUser.name; + } +} + +console.log('Type validation passed!');