Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 78 additions & 0 deletions examples/client-example/README.md
Original file line number Diff line number Diff line change
@@ -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<AuthorApiSpec>({
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
```
23 changes: 23 additions & 0 deletions examples/client-example/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createClient } from 'tspec';
import { AuthorApiSpec } from './server';

const client = createClient<AuthorApiSpec>({
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();
15 changes: 15 additions & 0 deletions examples/client-example/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions examples/client-example/server.ts
Original file line number Diff line number Diff line change
@@ -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 },
},
},
},
}
}>;
49 changes: 49 additions & 0 deletions examples/tspec-basic-example/client-example.ts
Original file line number Diff line number Diff line change
@@ -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<BookApiSpec>({
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 },
headers: { 'X-Request-ID': 'example-request-123' },
cookies: { 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 };
43 changes: 43 additions & 0 deletions packages/tspec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthorApiSpec>({
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

Expand Down
5 changes: 5 additions & 0 deletions packages/tspec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/tspec/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineConfig([
{
input: {
index: 'src/index.ts',
client: 'src/client/index.ts',
cli: 'src/cli/index.ts',
},
output: [
Expand Down Expand Up @@ -51,6 +52,7 @@ export default defineConfig([
{
input: {
index: 'src/index.ts',
client: 'src/client/index.ts',
cli: 'src/cli/index.ts',
},
output: [
Expand Down
126 changes: 126 additions & 0 deletions packages/tspec/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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, string | number>): 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, any>): 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<ApiSpec>(config: ClientConfig): Client<ApiSpec> {
const { baseUrl, baseHeaders = {}, fetch: customFetch = globalThis.fetch } = config;

async function request(
method: string,
path: string,
options: any
): Promise<ClientResponse<any>> {
const { params, query, body, headers = {}, cookies } = 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,
};

// 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(),
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<any>;
}

// 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<ApiSpec>;
}
Loading