UNPKG

@prism-engineer/router

Version:

Type-safe Express.js router with automatic client generation

882 lines (741 loc) • 22.9 kB
# @prism-engineer/router A type-safe router library that provides Express.js integration with automatic API client code generation. This library serves as a wrapper around Express.js that adds: 1. Dynamic route loading from file patterns 2. Automatic TypeScript API client generation 3. Type-safe API consumption for frontends and backends ## Table of Contents - [Installation](#installation) - [Quick Start](#quick-start) - [API Client Generation](#api-client-generation) - [Key Features](#key-features) - [Complete Example](#complete-example) - [Configuration Options](#configuration-options) - [Development](#development) ## Installation ```bash # Install the router npm install @prism-engineer/router # Install required peer dependencies npm install @sinclair/typebox express ``` **Note:** Both TypeBox and Express.js are required dependencies - TypeBox for defining route schemas with runtime validation and type safety, and Express.js as the underlying web framework. Note that in the current version, TypeBox is actually included as a dependency, not a peer dependency. ## Quick Start ### 1. Import and Initialize ```typescript import express from 'express'; import { router } from '@prism-engineer/router'; // Access the Express app instance const app = router.app; // Add middleware as needed app.use(express.json()); app.use(express.urlencoded({ extended: true })); ``` ### 2. Create Route Files Create route files using the `createApiRoute` helper. **TypeBox is required** for defining type-safe schemas: ```typescript // api/hello.ts - Simple GET route import { createApiRoute } from '@prism-engineer/router'; import { Type } from '@sinclair/typebox'; export const helloRoute = createApiRoute({ path: '/api/hello', method: 'GET', response: { 200: { contentType: 'application/json', body: Type.Object({ message: Type.String() }) } }, handler: async (req) => { return { status: 200 as const, body: { message: 'Hello, World!' } }; } }); ``` ```typescript // api/users.ts - POST route with request body import { createApiRoute } from '@prism-engineer/router'; import { Type } from '@sinclair/typebox'; export const createUserRoute = createApiRoute({ path: '/api/users', method: 'POST', request: { body: Type.Object({ name: Type.String(), email: Type.String() }) }, response: { 201: { contentType: 'application/json', body: Type.Object({ id: Type.Number(), name: Type.String(), email: Type.String() }) } }, handler: async (req) => { const { name, email } = req.body; return { status: 201 as const, body: { id: 1, name, email } }; } }); ``` ### 3. Load Routes Dynamically ```typescript // Load all API routes using RegExp pattern matching await router.loadRoutes('./api', /.*\.ts$/); // Load specific route patterns await router.loadRoutes('./api/v1', /.*\.ts$/); // Load multiple directories and patterns await router.loadRoutes('./api/v1', /users\.ts$/); await router.loadRoutes('./api/v2', /.*\.ts$/); await router.loadRoutes('./admin', /.*\.ts$/); ``` ### 4. Start the Server ```typescript // Start the server app.listen(3000, () => { console.log('Server running on port 3000'); }); ``` ## API Client Generation ### Configuration File Create `config.prism.router.ts` in your project root: ```typescript export default { outputDir: './generated', name: 'ApiClient', baseUrl: 'http://localhost:3000', routes: { directory: './api', pattern: /.*\.ts$/ } } as const; ``` ### Programmatic Compilation ```typescript import { router } from '@prism-engineer/router'; // Compile with custom config await router.compile({ outputDir: './src/generated', name: 'MyApiClient', baseUrl: 'http://localhost:3000', routes: { directory: './api', pattern: /.*\.ts$/ } }); ``` ### CLI Usage The CLI provides a simple way to generate API clients from your route definitions: ```bash # Generate API client using config file npx @prism-engineer/router compile # Alternative: use the binary name directly (after installation) npx prism-router compile # Show CLI help npx @prism-engineer/router help ``` **Configuration Options:** The CLI looks for configuration files in this order: - `config.prism.router.ts` - `config.prism.router.js` - `prism.config.ts` - `prism.config.js` **Example output:** ``` šŸ” Loading configuration... šŸ“ Output directory: ./generated šŸ·ļø Client name: ApiClient 🌐 Base URL: http://localhost:3000 šŸ“‚ Routes directory: ./api šŸ” Pattern: /.*\.ts$/ ⚔ Compiling API client... āœ… API client generated successfully! šŸ“„ Generated file: ./generated/ApiClient.generated.ts ``` ### Using Generated Client The generated client mirrors your API structure using actual paths: ```typescript import { createApiClient } from './generated/ApiClient.generated'; const client = createApiClient('http://localhost:3000'); // GET /api/hello -> client.api.hello.get() const hello = await client.api.hello.get(); // POST /api/users -> client.api.users.post({ body: {...} }) const newUser = await client.api.users.post({ body: { name: 'John', email: 'john@example.com' } }); ``` #### Query Parameters Add query parameters to GET requests for filtering, pagination, etc: ```typescript // Route definition export const getUsersRoute = createApiRoute({ path: '/api/users', method: 'GET', request: { query: Type.Object({ page: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()), search: Type.Optional(Type.String()) }) }, response: { 200: { contentType: 'application/json', body: Type.Array(Type.Object({ id: Type.Number(), name: Type.String(), email: Type.String() })) } }, handler: async (req) => { const { page = 1, limit = 10, search } = req.query; return { status: 200 as const, body: [{ id: 1, name: 'John', email: 'john@example.com' }] }; } }); ``` ```typescript // Client usage const users = await client.api.users.get({ query: { page: 1, limit: 10, search: 'john' } }); ``` #### Path Parameters Use `{paramName}` syntax in routes. Client uses underscore notation `_paramName_`: ```typescript // Route definition export const getUserByIdRoute = createApiRoute({ path: '/api/users/{userId}', method: 'GET', response: { 200: { contentType: 'application/json', body: Type.Object({ id: Type.Number(), name: Type.String(), email: Type.String() }) } }, handler: async (req) => { const { userId } = req.params; return { status: 200 as const, body: { id: Number(userId), name: 'John', email: 'john@example.com' } }; } }); ``` ```typescript // Client usage const user = await client.api.users._userId_.get('123'); // Multiple path parameters (passed in order) const comment = await client.api.users._userId_.posts._postId_.get('123', '456'); ``` #### Headers Define expected headers for validation and typing: ```typescript // Route definition export const protectedRoute = createApiRoute({ path: '/api/protected', method: 'GET', request: { headers: Type.Object({ authorization: Type.String(), 'x-api-version': Type.Optional(Type.String()) }) }, response: { 200: { contentType: 'application/json', body: Type.Object({ message: Type.String() }) } }, handler: async (req) => { const { authorization } = req.headers; return { status: 200 as const, body: { message: 'Access granted' } }; } }); ``` ```typescript // Client usage const result = await client.api.protected.get({ headers: { authorization: 'Bearer token123', 'x-api-version': 'v1' } }); ``` #### Authentication Define reusable authentication schemes and use them in routes: **Step 1: Define Authentication Schemes** ```typescript // auth/schemes.ts import { createAuthScheme } from '@prism-engineer/router'; import express from 'express'; export const bearerAuth = createAuthScheme({ name: 'bearer', validate: async (req: express.Request) => { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); const user = await validateJWT(token); return { user, scopes: user.permissions }; } throw new Error('Missing or invalid bearer token'); } }); export const apiKeyAuth = createAuthScheme({ name: 'apiKey', validate: async (req: express.Request) => { const key = req.headers['x-api-key'] as string; if (key) { const client = await validateApiKey(key); return { client, scopes: ['read', 'write'] }; } throw new Error('Missing API key'); } }); // Placeholder functions for the example declare function validateJWT(token: string): Promise<{ id: string; permissions: string[] }>; declare function validateApiKey(key: string): Promise<{ id: string }>; ``` **Step 2: Use Auth Schemes in Routes** ```typescript // api/users.ts import { bearerAuth } from '../auth/schemes'; export const getUsersRoute = createApiRoute({ path: '/api/users', method: 'GET', auth: bearerAuth, // Single auth scheme response: { 200: { contentType: 'application/json', body: Type.Array(Type.Object({ id: Type.Number(), name: Type.String() })) } }, handler: async (req) => { // req.auth contains { name: <scheme>, context: <auth result> } const { user } = req.auth.context; const authScheme = req.auth.name; // 'bearer' in this case return { status: 200 as const, body: [{ id: 1, name: 'John' }] }; } }); // Multiple auth schemes (OR logic) export const flexibleRoute = createApiRoute({ path: '/api/flexible', method: 'GET', auth: [bearerAuth, apiKeyAuth], // Either bearer OR API key handler: async (req) => { // req.auth is a union type that preserves which scheme was used if (req.auth.name === 'bearer') { // TypeScript knows context contains user data const { user } = req.auth.context; console.log('Authenticated user:', user.id); } else if (req.auth.name === 'apiKey') { // TypeScript knows context contains client data const { client } = req.auth.context; console.log('Authenticated client:', client.id); } return { status: 200 as const, body: { success: true } }; } }); ``` **Authentication Context Structure** The `req.auth` object now has a strongly-typed structure that preserves both the authentication scheme name and the validated result: ```typescript // req.auth structure (for single auth scheme) { name: 'your-scheme-name', // Literal type of your auth scheme name context: <YourValidateReturnType> // Exactly what your validate function returns } // req.auth structure (for multiple auth schemes - union type) { name: 'scheme1', context: <Scheme1ValidateReturnType> } | { name: 'scheme2', context: <Scheme2ValidateReturnType> } | ... ``` **Key Features:** - **Full Request Access**: Your `validate` function receives the complete `express.Request` object - **Type Safety**: `req.auth.context` is strongly typed based on your `validate` function's return type - **Scheme Discrimination**: `req.auth.name` lets you identify which auth scheme was used - **Maximum Flexibility**: Extract authentication data from headers, query params, cookies, or anywhere in the request **Example with Custom Return Types:** ```typescript const customAuth = createAuthScheme({ name: 'custom-jwt', validate: async (req: express.Request) => { // Access any part of the request const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token as string || req.cookies?.jwt; if (!token) throw new Error('No token provided'); // Your validate function can return ANY type const decoded = await verifyJWT(token); return { userId: decoded.sub, permissions: decoded.scope.split(' ') }; } }); const route = createApiRoute({ path: '/api/custom', method: 'GET', auth: customAuth, response: { 200: { contentType: 'application/json', body: Type.Object({ message: Type.String() }) } }, handler: async (req) => { // req.auth.context is typed as { userId: string, permissions: string[] } const userId: string = req.auth.context.userId; const permissions: string[] = req.auth.context.permissions; const schemeName: 'custom-jwt' = req.auth.name; // ... return { status: 200 as const, body: { message: 'Success' } }; } }); // Placeholder function for the example declare function verifyJWT(token: string): Promise<{ sub: string; scope: string }>; // Multiple auth schemes create union types const multiAuthRoute = createApiRoute({ path: '/api/multi', method: 'GET', auth: [bearerAuth, apiKeyAuth], // Union of different schemes handler: async (req) => { // req.auth is union type with proper discrimination if (req.auth.name === 'bearer') { // TypeScript knows this is bearer auth result const user = req.auth.context.user; // Typed correctly const scopes = req.auth.context.scopes; } else if (req.auth.name === 'apiKey') { // TypeScript knows this is API key auth result const client = req.auth.context.client; // Typed correctly const scopes = req.auth.context.scopes; } // Full type safety with literal string discrimination! } }); ``` **Step 3: Client Authentication** The generated client automatically handles authentication: ```typescript import { createApiClient } from './generated/ApiClient.generated'; // Initialize client with auth schemes const client = createApiClient('http://localhost:3000', { auth: { bearer: () => localStorage.getItem('token'), apiKey: 'your-api-key' } }); // Auth headers automatically added to protected endpoints const users = await client.api.users.get(); // Adds Bearer header const data = await client.api.flexible.get(); // Adds appropriate auth header ``` **Advanced Authentication Patterns** ```typescript // Note: Advanced authentication patterns like getToken and onUnauthorized // are not currently implemented in the generated client code. // The generated client currently supports simple string tokens or functions // that return tokens. // Dynamic token management (not currently implemented) const client = createApiClient(baseUrl, { auth: { bearer: () => authStore.getAccessToken(), apiKey: 'your-api-key' } }); // Multiple auth schemes for different endpoint types const client = createApiClient(baseUrl, { auth: { bearer: userToken, // For user endpoints apiKey: serviceKey, // For service endpoints oauth: oauthToken // For OAuth endpoints } }); ``` #### Request Options All HTTP methods support the same options structure: ```typescript interface RequestOptions { query?: Record<string, any>; body?: any; headers?: Record<string, string>; } // Example with query parameters await client.api.users.get({ query: { page: 1, limit: 10 } }); // Example with request body and headers await client.api.users.post({ body: { name: 'John', email: 'john@example.com' }, headers: { 'content-type': 'application/json' } }); ``` ## Key Features ### Core Benefits - **Runtime Validation**: Automatic request/response validation using TypeBox schemas - **Type Safety**: TypeScript types automatically inferred from schemas - **Path-Based Client**: Generated client mirrors your API structure (`client.api.users.get()`) - **JSON Schema Output**: Generate OpenAPI/Swagger documentation automatically ### Handler Functions Route handlers are **async functions** that receive a typed request object and return a response object: ```typescript handler: async (req) => { // req.body - typed request body (if defined) // req.query - typed query parameters (if defined) // req.headers - typed headers (if defined) // req.params - extracted path parameters return { status: 200 as const, body: { /* response data */ }, headers?: { /* optional response headers */ } }; } ``` ### Response Types The router supports two types of responses based on the `contentType` you specify: #### JSON Responses For JSON content types (`application/json`, `application/vnd.api+json`, `application/ld+json`, `text/json`), return a `body` object: ```typescript export const jsonRoute = createApiRoute({ path: '/api/data', method: 'GET', response: { 200: { contentType: 'application/json', body: Type.Object({ message: Type.String(), timestamp: Type.Number() }) } }, handler: async (req) => { return { status: 200 as const, body: { message: 'Hello, World!', timestamp: Date.now() } }; } }); ``` #### Custom Content Types For non-JSON content types (like `text/plain`, `text/html`, `image/png`, etc.), return a `custom` function that receives the Express response object: ```typescript export const customRoute = createApiRoute({ path: '/api/download', method: 'GET', response: { 200: { contentType: 'text/plain' }, 404: { contentType: 'application/json', body: Type.Object({ error: Type.String() }) } }, handler: async (req) => { const data = await getFileData(); if (!data) { return { status: 404 as const, body: { error: 'File not found' } }; } return { status: 200 as const, custom: (res) => { res.setHeader('Content-Disposition', 'attachment; filename="data.txt"'); res.send(data); } }; } }); // Example: Streaming response for large files or real-time data export const streamRoute = createApiRoute({ path: '/api/stream/{fileId}', method: 'GET', response: { 200: { contentType: 'application/octet-stream' }, 404: { contentType: 'application/json', body: Type.Object({ error: Type.String() }) } }, handler: async (req) => { const { fileId } = req.params; const fileStream = await getFileStream(fileId); if (!fileStream) { return { status: 404 as const, body: { error: 'File not found' } }; } return { status: 200 as const, custom: (res) => { res.setHeader('Content-Disposition', `attachment; filename="${fileId}"`); res.setHeader('Transfer-Encoding', 'chunked'); // Stream the file directly to the response fileStream.pipe(res); // Handle stream errors fileStream.on('error', (err) => { console.error('Stream error:', err); if (!res.headersSent) { res.status(500).json({ error: 'Stream error' }); } }); } }; } }); // Example: Server-Sent Events (SSE) for real-time updates export const sseRoute = createApiRoute({ path: '/api/events', method: 'GET', response: { 200: { contentType: 'text/event-stream' } }, handler: async (req) => { return { status: 200 as const, custom: (res) => { // Set SSE headers res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); // Send initial connection message res.write('data: {"type":"connected","timestamp":' + Date.now() + '}\n\n'); // Set up interval to send periodic updates const interval = setInterval(() => { const data = { type: 'update', timestamp: Date.now(), data: Math.random() }; res.write(`data: ${JSON.stringify(data)}\n\n`); }, 1000); // Clean up when client disconnects req.on('close', () => { clearInterval(interval); console.log('SSE connection closed'); }); // Handle errors res.on('error', (err) => { console.error('SSE error:', err); clearInterval(interval); }); } }; } }); // Placeholder functions for the examples declare function getFileData(): Promise<string | null>; declare function getFileStream(fileId: string): Promise<NodeJS.ReadableStream | null>; ``` **Key Points:** - **JSON responses**: Use `body` property with TypeBox schema validation - **Custom responses**: Use `custom` function for full control over the response - **Content-Type**: Automatically set by the framework based on your schema - **Mixed responses**: You can mix JSON and custom content types in the same route (different status codes) ## Complete Example ```typescript import express from 'express'; import { router } from '@prism-engineer/router'; async function main() { // Get the Express app const app = router.app; // Add middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Load routes from multiple directories await router.loadRoutes('./api', /.*\.ts$/); await router.loadRoutes('./admin', /.*\.ts$/); // Start server app.listen(3000, () => { console.log('šŸš€ Server running on port 3000'); }); // Generate API client await router.compile({ outputDir: './generated', name: 'ApiClient', baseUrl: 'http://localhost:3000', routes: { directory: './api', pattern: /.*\.ts$/ } }); console.log('āœ… API client generated'); } main().catch(console.error); ``` ## Configuration Options The `config.prism.router.ts` file supports these options: | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `outputDir` | `string` | āœ… | - | Output directory for generated client | | `name` | `string` | āœ… | - | Name of the generated client function | | `baseUrl` | `string` | āœ… | - | Base URL for API calls | | `routes.directory` | `string` | āœ… | - | Directory to scan for route files | | `routes.pattern` | `RegExp` | āœ… | - | RegExp pattern to match route files | ```typescript export default { outputDir: './generated', name: 'ApiClient', baseUrl: 'http://localhost:3000', routes: { directory: './api', pattern: /.*\.ts$/ } } as const; ``` ## Development ### Dependencies - Use `npm install {package}` to add dependencies - Compatible with any Node.js project ### Testing - Run tests: `npm test` - Watch mode: `npm run test:watch` - Coverage: `npm run test:coverage` ## Package Info - Package name: `@prism-engineer/router` - Type: Library package for Express.js applications - Current version: 0.0.5 - Node.js compatibility: >= 16.0.0