diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9464537 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# API Configuration +APP_TITLE=api-machine +API_PORT=4000 diff --git a/.eslintrc.js b/.eslintrc.js index 70a7379..d9a24e9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -45,4 +45,13 @@ module.exports = { "@typescript-eslint/promise-function-async": 1, "@typescript-eslint/no-var-requires": 2, }, + overrides: [ + { + files: ["examples/**/*.ts", "scripts/**/*.ts"], + rules: { + "@typescript-eslint/no-unused-vars": [1, { "argsIgnorePattern": "request|response" }], + "no-console": 0 + } + }, + ] }; diff --git a/.eslintrc.prod.json b/.eslintrc.prod.json index 3e2cea9..86f93e4 100644 --- a/.eslintrc.prod.json +++ b/.eslintrc.prod.json @@ -4,5 +4,17 @@ "rules": { "no-console": 2, "no-debugger": 2 - } + }, + "overrides": [ + { + "files": ["examples/**/*.ts", "scripts/**/*.ts"], + "rules": { + "@typescript-eslint/no-unused-vars": [ + 1, + { "argsIgnorePattern": "request|response" } + ], + "no-console": 0 + } + } + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 160f051..5852afe 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,11 +9,12 @@ "type": "node", "request": "launch", "args": [ - "scripts/example.ts" + "scripts/index.ts", + "openapi" ], "runtimeArgs": [ "-r", - "ts-node/register" + "ts-node/register", ], "cwd": "${workspaceRoot}", "internalConsoleOptions": "openOnSessionStart" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe8fba..13626e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -# ts-rest - Changelog +# api-machine - Changelog diff --git a/LICENSE.md b/LICENSE.md index 524a05a..64437c3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2022 ts-rest and others +Copyright (c) 2022 api-machine and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 7d9cf97..527fa5d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,382 @@ -# ts-rest - Readme +# api-machine - REST API Server Framework + +A lightweight, TypeScript-first REST API framework built on Express with a class-based routing architecture. + +## Installation + +```bash +npm i api-machine +``` + +## Features + +- **TypeScript-First**: Fully typed API development with TypeScript +- **Self-documenting** - Automatically hosts Swagger & OpenAPI (when enabled) +- **Class-Based Architecture**: Organize your API with classes for servers, routers, and endpoints +- **Built on Express**: Leverages the power and ecosystem of Express.js +- **Secure by Default**: Automatic security headers and server fingerprinting protection +- **CORS Support**: Built-in CORS handling +- **Error Handling**: Automatic error handling with standardized responses +- **Configurable**: Flexible configuration for ports, payload sizes, and logging + +## Quick Start + +See the [Quick Start Example](examples/quick-start/) for a complete, runnable example. + +Here's the basic structure: + +### 1. Endpoints + +```typescript +class HelloEndpoint extends BaseApiEndpoint { + override path = '/hello'; + + override async handle(request, response) { + return { message: 'Hello, World!' }; + } +} +``` + +### 2. Routers + +```typescript +class MyRouter extends BaseApiRouter { + override path = '/api'; + + override async routes() { + return [HelloEndpoint, /* UsersRouter */]; + } +} +``` + +### 3. Servers + +```typescript +class MyApiServer extends RestServer { + override router = MyRouter; +} + +const server = new MyApiServer({ + port: 4000, + swaggerEnabled: process.env?.NODE_ENV === 'development' +}) +``` + +### Swagger + +Navigate to http://localhost:4000/docs to browse your swagger API docs + +## Examples + +The [`examples/`](examples/) directory contains comprehensive examples: + +- **[Quick Start](examples/quick-start/README.md)** - Basic server setup demonstrating the fundamental concepts + - Simple server, router, and endpoint structure + - Default GET endpoints + - Basic JSON responses + - Minimal configuration + +- **[Complete Example](examples/complete-example/README.md)** - Advanced features and production patterns + - Full CRUD operations (GET, POST, PUT, DELETE) + - Route parameters and validation + - Error handling with proper status codes + - Custom logger configuration (ts-tiny-log) + - Express integration (headers, query params) + - Request body validation + - Structured error responses + +## Configuration + +### Server Options +#### port `number = 5000` + +The port number on which the server will listen for incoming requests. + +#### maxPayloadSizeMB `number = 10` + +Maximum size in megabytes for JSON request payloads that the server will accept. + +#### maxUrlEncodedSizeMB `number = 1` + +Maximum size in megabytes for URL-encoded request payloads that the server will accept. + +#### log `LogInterface = console` + +Custom logger interface for handling server logging (e.g. `ts-tiny-log`). Must implement the LogInterface contract. + +#### securityHeaders `SecurityHeadersOptions` + +Configuration for HTTP security headers. By default, api-machine is **secure by default** with: +- X-Powered-By header removed (prevents server fingerprinting) +- X-Content-Type-Options: nosniff (prevents MIME sniffing) +- X-Frame-Options: DENY (prevents clickjacking) +- X-XSS-Protection: 1; mode=block (legacy XSS protection) + +See **[Security Headers Documentation](docs/security-headers.md)** for detailed configuration options and best practices. + +### Example with Options + +```typescript +const server = new MyServer({ + port: 8080, + maxPayloadSizeMB: 20, + maxUrlEncodedSizeMB: 2, + log: myCustomLogger +}); +``` + +## API Summary + +### RestServer + +Abstract class for creating REST API servers. + +**Methods:** +- `start()`: Starts the server +- `stop()`: Stops the server + + +### BaseApiRouter + +Abstract class for creating route groups. + +**Properties:** +- `path`: The base path for the router (e.g., `/api`) + +**Methods:** +- `routes()`: Abstract method to define endpoints (must be implemented) + +### BaseApiEndpoint + +Abstract class for creating API endpoints. + +**Properties:** +- `path`: The endpoint path (default: `''`) +- `method`: The HTTP method (default: `GET`). Can be: + - `EndpointMethods.GET` + - `EndpointMethods.POST` + - `EndpointMethods.PATCH` + - `EndpointMethods.PUT` + - `EndpointMethods.DELETE` + +**Methods:** +- `handle(request, response, next)`: Abstract method to handle requests (must be implemented) + +#### Using Different HTTP Methods + +api-machine provides convenience classes for each HTTP method with appropriate default status codes: + +```typescript +// GET endpoint (200 OK by default) +class GetUsersEndpoint extends GetEndpoint { + override path = '/users'; + + async handle(request, response) { + return [{ id: 1, name: 'John' }]; + } +} + +// POST endpoint +class CreateUserEndpoint extends PostEndpoint { + override path = '/users'; + + async handle(request, response) { + const newUser = { + id: Date.now(), + name: request.body.name + }; + + return newUser; + } +} + +// PUT endpoint +class UpdateUserEndpoint extends PutEndpoint { + override path = '/users/:id'; + + async handle(request, response) { + const id = parseInt(request.params['id'], 10); + + // Update entire user resource + return { id, ...request.body }; + } +} + +// PATCH endpoint +class PatchUserEndpoint extends PatchEndpoint { + override path = '/users/:id'; + + async handle(request, response) { + const id = parseInt(request.params['id'], 10); + + // Update only provided fields + return { id, ...request.body }; + } +} + +// DELETE endpoint (204 No Content by default) +class DeleteUserEndpoint extends DeleteEndpoint { + override path = '/users/:id'; + + async handle(request, response) { + const id = parseInt(request.params['id'], 10); + + // Deletion logic here + return {}; + } +} +``` + +**Available Endpoint Classes:** +- `GetEndpoint` - GET requests (200 OK) +- `PostEndpoint` - POST requests (201 Created) +- `PutEndpoint` - PUT requests (200 OK) +- `PatchEndpoint` - PATCH requests (200 OK) +- `DeleteEndpoint` - DELETE requests (204 No Content) +- `HealthCheckEndpoint` - Pre-built health check endpoint (GET /health) + +You can also use `BaseApiEndpoint` and manually set the `method` and `statusCode` properties if needed for custom behavior. + +#### Pre-Built Endpoints + +##### HealthCheckEndpoint + +A ready-to-use health check endpoint that returns system status information. Simply include it in your router: + +```typescript +import { BaseApiRouter, HealthCheckEndpoint } from 'api-machine'; + +class MyRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [ + HealthCheckEndpoint, // Available at GET /api/health + // ... other endpoints + ]; + } +} +``` + +**Response Format:** +```json +{ + "status": "ok", + "timestamp": "2025-11-08T12:00:00.000Z", + "uptime": 123.45, + "environment": "development" +} +``` + +**Customizing the Path:** +```typescript +class CustomHealthCheck extends HealthCheckEndpoint { + override path = '/status'; // Available at GET /api/status +} +``` + +For advanced usage, extending the health check with custom checks, and deployment examples (Kubernetes, Docker, monitoring), see the **[Health Check Endpoint Documentation](docs/health-check-endpoint.md)**. + +## Error Handling + +api-machine provides a comprehensive set of HTTP error classes for standardized error responses. All errors extend `HTTPError` and automatically format responses with proper status codes and headers. + +```typescript +import { NotFoundError, BadRequestError, UnauthorizedError } from 'api-machine'; + +class GetUserEndpoint extends GetEndpoint { + override path = '/users/:id'; + + async handle(request, response) { + const id = parseInt(request.params['id'], 10); + const user = await findUser(id); + + if (!user) { + throw new NotFoundError('User not found', { + details: { userId: id } + }); + } + + return user; + } +} +``` + +**Key Features:** +- 29 built-in error classes covering HTTP status codes 400-451 +- Automatic JSON error responses with timestamps +- Support for custom headers (e.g., `WWW-Authenticate`, `Retry-After`) +- Optional `details` field for additional context +- User-provided headers override defaults + +**Common Error Classes:** +- `BadRequestError` (400) +- `UnauthorizedError` (401) +- `ForbiddenError` (403) +- `NotFoundError` (404) +- `ConflictError` (409) +- `UnprocessableEntityError` (422) +- `TooManyRequestsError` (429) + +For the complete list of error classes, usage examples, and custom error creation, see the **[HTTP Errors Documentation](docs/http-errors.md)**. + +## Validation & Sanitization + +api-machine supports request validation and sanitization using [valsan](https://www.npmjs.com/package/valsan). You can declare ObjectSanitizer members on your endpoint classes for `body`, `query`, `params`, or `headers`: + +```typescript +import { ObjectSanitizer, EmailValidator } from 'valsan'; +import { NameValSan } from './examples/complete-example/users/name-valsan'; + +class CreateUserEndpoint extends PostEndpoint { + override path = '/users'; + + override body = new ObjectSanitizer({ + name: new NameValSan(), + email: new EmailValidator(), + }); + + async handle(request, response) { + // request.body is validated & sanitized + // ... + } +} +``` + +- If validation fails, a 400 error is returned with details. +- If validation succeeds, the sanitized values are available in `request.body`, `request.query`, etc. + +You can create custom validators by extending `ComposedValSan` from valsan. See the [valsan documentation](../valsan/README.md) for more details. + +## Authentication + +api-machine provides a declarative authentication system with cascading support across server, router, and endpoint levels: + +```typescript +class SecureRouter extends BaseApiRouter { + override path = '/api'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => await validateToken(token), + }); + + async routes() { + return [ProtectedEndpoint]; + } +} +``` + +**Key Features:** +- **Cascading authentication** - Server → Router → Endpoint priority +- **Bearer token support** - Built-in Bearer authentication scheme +- **OpenAPI integration** - Automatic security scheme generation +- **Public routes** - Set `authentication = null` to bypass parent auth +- **Custom schemes** - Extend `AuthenticationScheme` for custom auth + +See **[Authentication Documentation](docs/authentication.md)** for complete usage, cascading examples, and custom authentication schemes. + +## Middleware + +Routers and endpoints support Express-style middleware for logging, validation, and more. See [Middleware Support](docs/middleware.md) for usage and examples. ## Contributing & Development diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..72fa818 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,227 @@ +# Authentication + +api-machine provides a declarative authentication system with cascading support across server, router, and endpoint levels. Authentication schemes automatically integrate with OpenAPI/Swagger documentation. + +## Quick Start + +```typescript +import { RestServer, BaseApiRouter, BaseApiEndpoint } from 'api-machine'; +import { BearerAuthenticationScheme } from 'api-machine'; + +class SecureEndpoint extends BaseApiEndpoint { + override path = '/data'; + + async handle(request, response) { + return { message: 'Secure data' }; + } +} + +class ApiRouter extends BaseApiRouter { + override path = '/api'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'secret-token', + schemeName: 'BearerAuth', + }); + + async routes() { + return [SecureEndpoint]; + } +} + +const server = new RestServer({ + port: 4000, + authentication: new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'server-token', + }), +}); +``` + +## Authentication Cascading + +Authentication follows a priority hierarchy: + +**Endpoint → Router → Server** + +- **Endpoint-level** authentication has highest priority +- **Router-level** authentication applies if endpoint doesn't specify +- **Server-level** authentication applies as the default fallback +- Set `authentication = null` to make a route explicitly public + +```typescript +class PublicRouter extends BaseApiRouter { + override path = '/public'; + override authentication = null; // Bypass server auth + + async routes() { + return [PublicEndpoint]; + } +} + +class AdminEndpoint extends BaseApiEndpoint { + override path = '/admin'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token) => token === 'admin-token', + }); // Override router auth + + async handle() { + return { admin: true }; + } +} +``` + +## Built-in Authentication Schemes + +### Bearer Authentication + +Validates Bearer tokens from the `Authorization` header. + +```typescript +import { BearerAuthenticationScheme } from 'api-machine'; + +const auth = new BearerAuthenticationScheme({ + checkToken: async (token: string) => { + // Validate token (check database, JWT, etc.) + return token === 'valid-token'; + }, + schemeName: 'BearerAuth', // Optional: OpenAPI scheme name + bearerFormat: 'JWT', // Optional: Token format + description: 'JWT Bearer Auth', // Optional: OpenAPI description +}); +``` + +**Features:** +- Automatically validates `Authorization: Bearer ` header format +- Returns 401 Unauthorized if token missing or invalid +- Integrates with OpenAPI security schemes +- Sets `request.authenticated = true` on successful validation + +## Server-Level Authentication + +Apply authentication to all endpoints by default: + +```typescript +const server = new RestServer({ + port: 4000, + authentication: new BearerAuthenticationScheme({ + checkToken: async (token) => await validateToken(token), + }), +}); +``` + +All endpoints will require authentication unless a router or endpoint overrides it with `authentication = null`. + +## Router-Level Authentication + +Apply authentication to all endpoints in a router: + +```typescript +class SecureRouter extends BaseApiRouter { + override path = '/secure'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token) => token === 'router-token', + }); + + async routes() { + return [Endpoint1, Endpoint2]; // Both require auth + } +} +``` + +## Endpoint-Level Authentication + +Override parent authentication for specific endpoints: + +```typescript +class AdminEndpoint extends BaseApiEndpoint { + override path = '/admin'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token) => token === 'admin-token', + schemeName: 'AdminAuth', + }); + + async handle() { + return { admin: true }; + } +} +``` + +## Public Routes + +Make routes explicitly public by setting `authentication = null`: + +```typescript +class PublicEndpoint extends BaseApiEndpoint { + override path = '/public'; + override authentication = null; // No authentication required + + async handle() { + return { public: true }; + } +} +``` + +This bypasses any parent router or server authentication. + +## Custom Authentication Schemes + +Create custom authentication by extending `AuthenticationScheme`: + +```typescript +import { AuthenticationScheme } from 'api-machine'; +import { RequestHandler } from 'express'; + +class ApiKeyAuthenticationScheme extends AuthenticationScheme { + constructor(private apiKey: string) { + super(); + } + + getSecurityScheme() { + return { + type: 'apiKey' as const, + in: 'header' as const, + name: 'X-API-Key', + }; + } + + getMiddleware(): RequestHandler { + return async (req, res, next) => { + const apiKey = req.headers['x-api-key']; + + if (apiKey !== this.apiKey) { + throw new UnauthorizedError('Invalid API key'); + } + + next(); + }; + } +} +``` + +**Required Methods:** +- `getSecurityScheme()`: Returns OpenAPI SecuritySchemeObject +- `getMiddleware()`: Returns Express middleware function +- `getSecurityRequirement()`: Optional, returns OpenAPI SecurityRequirementObject + +## OpenAPI Integration + +Authentication schemes automatically generate OpenAPI security definitions: + +```typescript +const auth = new BearerAuthenticationScheme({ + checkToken: async (token) => await validate(token), + schemeName: 'BearerAuth', + description: 'JWT Bearer Authentication', +}); +``` + +## Best Practices + +1. **Use server-level auth as default** - Apply authentication at the server level and override only where needed +2. **Make public routes explicit** - Use `authentication = null` to clearly mark public endpoints +3. **Validate tokens properly** - Always validate tokens against a secure source (database, JWT verification, etc.) +4. **Use descriptive scheme names** - Help API consumers understand authentication requirements +5. **Leverage cascading** - Set auth at the appropriate level (server/router/endpoint) based on your needs + +## Examples + +See [examples/authentication-example.ts](../examples/authentication-example.ts) for a complete working example. diff --git a/docs/health-check-endpoint.md b/docs/health-check-endpoint.md new file mode 100644 index 0000000..bad0101 --- /dev/null +++ b/docs/health-check-endpoint.md @@ -0,0 +1,258 @@ +# Health Check Endpoint + +The `HealthCheckEndpoint` is a pre-built endpoint that provides a simple health check response for your REST API. This is useful for monitoring, load balancers, and orchestration systems that need to verify your service is running. + +## Features + +- **Ready to Use**: No configuration needed - just include it in your router +- **System Information**: Returns status, timestamp, uptime, and environment +- **Customizable**: Override the path or extend the response as needed +- **Secure**: Uses GET method with 200 OK status + +## Basic Usage + +```typescript +import { BaseApiRouter, HealthCheckEndpoint } from 'api-machine'; + +class MyRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [ + HealthCheckEndpoint, // Available at GET /api/health + // ... other endpoints + ]; + } +} +``` + +## Response Format + +```json +{ + "status": "ok", + "timestamp": "2025-11-08T12:00:00.000Z", + "uptime": 123.45, + "environment": "development" +} +``` + +**Response Fields:** +- `status`: Always "ok" when the server is responding +- `timestamp`: Current time in ISO 8601 format +- `uptime`: Process uptime in seconds +- `environment`: Value of `NODE_ENV` environment variable (defaults to "development") + +## Customization + +### Custom Path + +Override the path to use a different endpoint URL: + +```typescript +class MyHealthCheck extends HealthCheckEndpoint { + override path = '/status'; // Available at GET /api/status +} +``` + +### Extended Response + +Extend the endpoint to add custom health information: + +```typescript +import { HealthCheckEndpoint, ApiRequest, ApiResponse } from 'api-machine'; + +class ExtendedHealthCheck extends HealthCheckEndpoint { + override path = '/health'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_request: ApiRequest, _response: ApiResponse) { + const baseHealth = await super.handle(_request, _response); + + return { + ...baseHealth, + version: '1.0.0', + database: await this.checkDatabase(), + cache: await this.checkCache(), + }; + } + + private async checkDatabase(): Promise { + // Check database connection + return 'connected'; + } + + private async checkCache(): Promise { + // Check cache connection + return 'connected'; + } +} +``` + +### Custom Health Logic + +Replace the entire response with your own health check logic: + +```typescript +class CustomHealthCheck extends HealthCheckEndpoint { + override path = '/health'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_request: ApiRequest, response: ApiResponse) { + const isHealthy = await this.performHealthChecks(); + + if (!isHealthy) { + response.status(503); // Service Unavailable + return { + status: 'unhealthy', + timestamp: new Date().toISOString(), + }; + } + + return { + status: 'healthy', + timestamp: new Date().toISOString(), + checks: { + database: 'ok', + cache: 'ok', + api: 'ok', + }, + }; + } + + private async performHealthChecks(): Promise { + // Implement your health check logic + return true; + } +} +``` + +## Use Cases + +### Load Balancers + +Configure your load balancer to poll the health check endpoint: + +```bash +curl http://localhost:3000/api/health +``` + +### Kubernetes + +Use the health check endpoint for liveness and readiness probes: + +```yaml +livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 +``` + +### Docker Healthcheck + +Add a health check to your Dockerfile: + +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 +``` + +### Monitoring + +Use monitoring tools to track service availability: + +```bash +# Prometheus +curl http://localhost:3000/api/health | jq '.status' + +# Nagios, Zabbix, etc. +check_http -H localhost -p 3000 -u /api/health -s "ok" +``` + +## Best Practices + +1. **Keep it Simple**: The health check should be fast and lightweight +2. **Status Codes**: Return 200 for healthy, 503 for unhealthy +3. **Minimal Dependencies**: Avoid complex checks that might fail unnecessarily +4. **Separate Readiness**: Consider separate endpoints for liveness vs readiness +5. **Authentication**: Health checks typically don't require authentication + +## Example: Multiple Health Endpoints + +```typescript +class HealthRouter extends BaseApiRouter { + override path = '/'; + + async routes() { + return [ + // Basic health check + HealthCheckEndpoint, // GET /health + + // Detailed health with dependencies + class extends HealthCheckEndpoint { + override path = '/health/detailed'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_request: ApiRequest, _response: ApiResponse) { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env['NODE_ENV'] || 'development', + dependencies: { + database: 'connected', + redis: 'connected', + api: 'reachable', + }, + }; + } + }, + + // Liveness probe (is the service running?) + class extends HealthCheckEndpoint { + override path = '/health/live'; + }, + + // Readiness probe (is the service ready to accept traffic?) + class extends HealthCheckEndpoint { + override path = '/health/ready'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_request: ApiRequest, response: ApiResponse) { + const isReady = await this.checkReadiness(); + + if (!isReady) { + response.status(503); + return { status: 'not ready' }; + } + + return { + status: 'ready', + timestamp: new Date().toISOString(), + }; + } + + private async checkReadiness(): Promise { + // Check if dependencies are ready + return true; + } + }, + ]; + } +} +``` + +## See Also + +- [Quick Start Example](../examples/quick-start/README.md) - Includes HealthCheckEndpoint usage +- [Complete Example](../examples/complete-example/README.md) - Advanced endpoint patterns +- [HTTP Errors](./http-errors.md) - Error handling for health checks diff --git a/docs/http-errors.md b/docs/http-errors.md new file mode 100644 index 0000000..fc9a5c3 --- /dev/null +++ b/docs/http-errors.md @@ -0,0 +1,418 @@ +# HTTP Error Handling + +The api-machine framework provides a comprehensive set of HTTP error classes for consistent error handling across your API. + +## Overview + +All HTTP error classes extend from the base `HTTPError` class and follow a consistent constructor pattern: + +```typescript +new ErrorClass(message: string, options: ErrorOptions = {}) +``` + +Where `ErrorOptions` includes: +- `headers?: Record` - Custom HTTP headers +- `details?: unknown` - Additional error details for logging/debugging + +## Basic Usage + +### Simple Errors + +Most error classes only need a message: + +```typescript +import { + BadRequestError, + NotFoundError, + ConflictError +} from 'api-machine'; + +// 400 Bad Request +throw new BadRequestError('Invalid input format'); + +// 404 Not Found +throw new NotFoundError('User not found'); + +// 409 Conflict +throw new ConflictError('Email already exists'); +``` + +### Errors with Details + +Add contextual information via the `details` option: + +```typescript +import { + NotFoundError, + UnprocessableEntityError +} from 'api-machine'; + +// Include the missing resource ID +throw new NotFoundError('User not found', { + details: { userId: 123 } +}); + +// Include validation errors +throw new UnprocessableEntityError('Validation failed', { + details: { + errors: [ + { field: 'email', message: 'Invalid email format' }, + { field: 'age', message: 'Must be 18 or older' } + ] + } +}); +``` + +### Errors with Custom Headers + +Add custom headers for additional metadata: + +```typescript +import { BadRequestError } from 'api-machine'; + +throw new BadRequestError('Request failed', { + headers: { + 'X-Request-Id': requestId, + 'X-API-Version': '2.0' + }, + details: { reason: 'Invalid JSON' } +}); +``` + +## Special Error Classes + +Some error classes have extended options for setting specific HTTP headers. + +### UnauthorizedError (401) + +```typescript +import { UnauthorizedError } from 'api-machine'; + +// Basic usage +throw new UnauthorizedError('Invalid token'); + +// Custom realm (sets WWW-Authenticate header) +throw new UnauthorizedError('Invalid token', { + realm: 'API Access' +}); +// Headers: { 'WWW-Authenticate': 'Bearer realm="API Access"' } + +// Custom authentication scheme +throw new UnauthorizedError('Invalid credentials', { + realm: 'Admin Portal', + scheme: 'Basic' +}); +// Headers: { 'WWW-Authenticate': 'Basic realm="Admin Portal"' } + +// With additional details +throw new UnauthorizedError('Token expired', { + realm: 'API', + details: { + expiredAt: '2024-01-01T00:00:00Z', + tokenId: 'abc123' + } +}); +``` + +### MethodNotAllowedError (405) + +```typescript +import { MethodNotAllowedError } from 'api-machine'; + +// Basic usage +throw new MethodNotAllowedError(); + +// With allowed methods (sets Allow header) +throw new MethodNotAllowedError('DELETE not supported', { + allowedMethods: ['GET', 'POST', 'PUT'] +}); +// Headers: { 'Allow': 'GET, POST, PUT' } + +// With details +throw new MethodNotAllowedError('Method not supported', { + allowedMethods: ['GET', 'POST'], + details: { attemptedMethod: 'DELETE', path: '/api/users/1' } +}); +``` + +### TooManyRequestsError (429) + +```typescript +import { TooManyRequestsError } from 'api-machine'; + +// Basic usage +throw new TooManyRequestsError(); + +// With retry-after (sets Retry-After header) +throw new TooManyRequestsError('Rate limit exceeded', { + retryAfter: 60 // seconds +}); +// Headers: { 'Retry-After': '60' } + +// With rate limit details +throw new TooManyRequestsError('Too many requests', { + retryAfter: 120, + details: { + limit: 100, + current: 150, + resetAt: '2024-01-01T12:00:00Z' + } +}); +``` + +### UnsupportedMediaTypeError (415) + +```typescript +import { UnsupportedMediaTypeError } from 'api-machine'; + +// Basic usage +throw new UnsupportedMediaTypeError(); + +// With accepted types (sets Accept header) +throw new UnsupportedMediaTypeError('Invalid content type', { + acceptedTypes: ['application/json', 'application/xml'] +}); +// Headers: { 'Accept': 'application/json, application/xml' } + +// With received content type +throw new UnsupportedMediaTypeError('Unsupported media type', { + acceptedTypes: ['application/json'], + details: { receivedType: 'text/plain' } +}); +``` + +### RangeNotSatisfiableError (416) + +```typescript +import { RangeNotSatisfiableError } from 'api-machine'; + +// Basic usage +throw new RangeNotSatisfiableError(); + +// With content range (sets Content-Range header) +throw new RangeNotSatisfiableError('Invalid range', { + contentRange: 'bytes */1000' +}); +// Headers: { 'Content-Range': 'bytes */1000' } + +// With range details +throw new RangeNotSatisfiableError('Range not satisfiable', { + contentRange: 'bytes */5000', + details: { requestedRange: 'bytes=6000-7000' } +}); +``` + +### ProxyAuthenticationRequiredError (407) + +```typescript +import { ProxyAuthenticationRequiredError } from 'api-machine'; + +// Default realm +throw new ProxyAuthenticationRequiredError(); +// Headers: { 'Proxy-Authenticate': 'Basic realm="Proxy"' } + +// Custom realm +throw new ProxyAuthenticationRequiredError('Proxy auth required', { + realm: 'Corporate Proxy' +}); +// Headers: { 'Proxy-Authenticate': 'Basic realm="Corporate Proxy"' } +``` + +### UpgradeRequiredError (426) + +```typescript +import { UpgradeRequiredError } from 'api-machine'; + +// Default protocol +throw new UpgradeRequiredError(); +// Headers: { 'Upgrade': 'TLS/1.0' } + +// Custom protocol +throw new UpgradeRequiredError('Please upgrade', { + upgradeProtocol: 'HTTP/2.0' +}); +// Headers: { 'Upgrade': 'HTTP/2.0' } +``` + +## Complete Error Class List + +### 4xx Client Errors + +| Class | Status | Default Message | +|-------|--------|----------------| +| `BadRequestError` | 400 | Bad Request | +| `UnauthorizedError` | 401 | Unauthorized | +| `PaymentRequiredError` | 402 | Payment Required | +| `ForbiddenError` | 403 | Forbidden | +| `NotFoundError` | 404 | Not Found | +| `MethodNotAllowedError` | 405 | Method Not Allowed | +| `NotAcceptableError` | 406 | Not Acceptable | +| `ProxyAuthenticationRequiredError` | 407 | Proxy Authentication Required | +| `RequestTimeoutError` | 408 | Request Timeout | +| `ConflictError` | 409 | Conflict | +| `GoneError` | 410 | Gone | +| `LengthRequiredError` | 411 | Length Required | +| `PreconditionFailedError` | 412 | Precondition Failed | +| `PayloadTooLargeError` | 413 | Payload Too Large | +| `URITooLongError` | 414 | URI Too Long | +| `UnsupportedMediaTypeError` | 415 | Unsupported Media Type | +| `RangeNotSatisfiableError` | 416 | Range Not Satisfiable | +| `ExpectationFailedError` | 417 | Expectation Failed | +| `ImATeapotError` | 418 | I'm a teapot | +| `MisdirectedRequestError` | 421 | Misdirected Request | +| `UnprocessableEntityError` | 422 | Unprocessable Entity | +| `LockedError` | 423 | Locked | +| `FailedDependencyError` | 424 | Failed Dependency | +| `TooEarlyError` | 425 | Too Early | +| `UpgradeRequiredError` | 426 | Upgrade Required | +| `PreconditionRequiredError` | 428 | Precondition Required | +| `TooManyRequestsError` | 429 | Too Many Requests | +| `RequestHeaderFieldsTooLargeError` | 431 | Request Header Fields Too Large | +| `UnavailableForLegalReasonsError` | 451 | Unavailable For Legal Reasons | + +## In Endpoint Handlers + +```typescript +import { ApiRequest, ApiResponse, GetEndpoint } from 'api-machine'; +import { NotFoundError, ForbiddenError } from 'api-machine'; + +export class GetUserEndpoint extends GetEndpoint { + override path = '/:id'; + + async handle(request: ApiRequest, response: ApiResponse) { + const userId = parseInt(request.params['id'], 10); + + // Check permissions + if (!request.user.canViewUser(userId)) { + throw new ForbiddenError('Access denied', { + details: { + userId, + requiredPermission: 'users:read' + } + }); + } + + // Find user + const user = await this.userRepo.findById(userId); + if (!user) { + throw new NotFoundError('User not found', { + details: { userId } + }); + } + + return user; + } +} +``` + +## Error Response Format + +The server automatically formats HTTPError instances into JSON responses: + +```json +{ + "error": "NotFoundError", + "message": "User not found", + "timestamp": "2024-11-08T12:34:56.789Z", + "options": { + "details": { + "userId": 123 + } + } +} +``` + +HTTP headers from the error are automatically set in the response: + +``` +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="API" +Content-Type: application/json + +{ + "error": "UnauthorizedError", + "message": "Invalid token", + "timestamp": "2024-11-08T12:34:56.789Z", + "options": {} +} +``` + +## Creating Custom Error Classes + +Extend `HTTPError` to create custom error classes: + +```typescript +import { HTTPError } from 'api-machine'; +import { HttpErrorOptions } from 'api-machine'; + +export interface MyCustomErrorOptions extends HttpErrorOptions { + errorCode?: string; + retryable?: boolean; +} + +export class MyCustomError extends HTTPError { + public readonly errorCode?: string; + public readonly retryable?: boolean; + + constructor( + message: string, + options: MyCustomErrorOptions = {} + ) { + super(message, options); + this.errorCode = options.errorCode; + this.retryable = options.retryable ?? false; + } + + public override getStatusCode(): number { + return 400; // Your status code + } + + // Override getResponseJson to include custom fields + public override getResponseJson() { + const json = super.getResponseJson(); + return { + ...json, + errorCode: this.errorCode, + retryable: this.retryable, + }; + } +} + +// Usage +throw new MyCustomError('Something went wrong', { + errorCode: 'CUSTOM_001', + retryable: true, + details: { /* ... */ } +}); +``` + +## Best Practices + +1. **Use Specific Error Classes**: Choose the most appropriate error class for your situation +2. **Include Details**: Add contextual information via `details` for debugging and logging +3. **Override Default Headers**: User-provided headers always take precedence over default headers +4. **Consistent Messages**: Use clear, user-friendly error messages +5. **Don't Leak Secrets**: Avoid exposing sensitive information in error messages or details + +```typescript +// Good +throw new NotFoundError('Resource not found', { + details: { resourceType: 'user', resourceId: id } +}); + +// Bad - exposes internal details +throw new NotFoundError('User with email john@example.com not found in database users table'); + +// Bad - exposes sensitive data +throw new UnauthorizedError('Invalid token', { + details: { token: request.headers.authorization } // Don't expose tokens! +}); + +// Override default headers +throw new UnauthorizedError('Custom auth', { + realm: 'API', // Would set 'Bearer realm="API"' + headers: { + 'WWW-Authenticate': 'Custom scheme' // This overrides the default + } +}); +``` diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..1cd35b0 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,71 @@ +# Middleware Support + +You can add Express-style middleware to both Routers and Endpoints in this framework. Middleware functions allow you to run logic before your endpoint handler is called (for example, authentication, logging, validation, etc.). + +## Router Middleware + +To add middleware to a router, set the `middleware` property to an array of Express middleware functions. These will run for all endpoints registered under that router. + +```typescript +import { BaseApiRouter } from 'src/router/router'; +import { RequestHandler } from 'express'; + +const logMiddleware: RequestHandler = (req, res, next) => { + console.log('Request:', req.method, req.path); + next(); +}; + +export class MyRouter extends BaseApiRouter { + override path = '/api'; + override middleware = [logMiddleware]; + + async routes() { + return [/* your endpoints */]; + } +} +``` + +## Endpoint Middleware + +To add middleware to a specific endpoint, set the `middleware` property to an array of Express middleware functions on your endpoint class. These will run only for that endpoint, after any router-level middleware. + +```typescript +import { BaseApiEndpoint, EndpointMethod } from 'src/router/endpoint'; +import { RequestHandler } from 'express'; + +const authMiddleware: RequestHandler = (req, res, next) => { + if (req.headers['authorization'] === 'Bearer validtoken') { + next(); + } else { + res.status(401).json({ error: 'Unauthorized' }); + } +}; + +export class MyEndpoint extends BaseApiEndpoint { + override path = '/secure'; + override method = EndpointMethod.GET; + + override middleware = [authMiddleware]; + + async handle(req, res) { + return { ok: true }; + } +} +``` + +## Middleware Order + +- Router middleware runs first (in the order defined in the array). +- Endpoint middleware runs next (in the order defined in the array). +- Finally, the endpoint handler is called. + +If any middleware sends a response (e.g., for authentication failure), the endpoint handler will not be called. + +## Example Use Cases +- Authentication/authorization +- Logging +- Input validation +- Rate limiting + +## See Also +- [Express Middleware Documentation](https://expressjs.com/en/guide/using-middleware.html) diff --git a/docs/openapi-integration.md b/docs/openapi-integration.md new file mode 100644 index 0000000..59a01e9 --- /dev/null +++ b/docs/openapi-integration.md @@ -0,0 +1,78 @@ +# Self-documenting OpenAPI (Swagger) + +api-machine includes a built-in, self-documenting feature that can generate an OpenAPI Spec. The spec is automatically generated from your server/router definitions, and automatically served as a Swagger UI. + +See the Example and reference section below: [View the runnable example](#example). + +## Quick enable + +1. Enable the feature by passing `swaggerEnabled: true` in the server options. + +```ts +export const server = new YourRestServer({ + port: 5050, + swaggerEnabled: true, +}); +``` + +2. Start the server as usual. When enabled, the server will expose two default endpoints: + +- `/openapi.json` — the generated OpenAPI document (JSON) +- `/docs` — an interactive Swagger UI based on the generated spec + +[These routes can be customized](#customization) + +## Documentation data source (what's included) + +The generated spec includes: + +- All registered routers/endpoints (paths + methods) +- Parameters derived from endpoint validators (path/query/header) +- Request bodies derived from declared body validators +- Response shapes for common success/failure cases (where available) + +Because the spec is derived from the endpoint definitions, it stays in sync with your code as long as you declare validators/metadata on the endpoints. The `test/spec/api/openapi-server.ts` example shows idiomatic endpoint definitions (classes that declare `params`, `body`, `query`, etc.) which are converted into OpenAPI automatically. + +### Inputs that influence the generated spec + +The generator pulls metadata from a few common places — set these values on your server/router/endpoint classes to produce richer API docs: + +- RestServer metadata + - `name` + - `description` + - `version` + +- Router and endpoint metadata + - Router (`BaseApiRouter`) can set `path` and `description` which are reflected in the grouping/description of operations. + - Endpoint classes may set `name` and `description` properties (or override `path`) — these values are used for operation `summary`/`description` in the generated spec. + +### ValSan schemas + +This project uses `valsan` validators on endpoints to infer request/parameter and body schemas. Common validator placements include properties on endpoint classes such as `params`, `query`, `headers`, and `body`. + +If you rely on Valsan validators in your endpoints, the self-documenting feature will include schema details automatically — this keeps the documentation accurate and minimizes duplication. + +## Security & deployment notes + +- Exposing the OpenAPI UI and spec is useful in development and for API consumers, but in production you may want to restrict access. Options: + - Only enable `swaggerEnabled` for non-production environments. + - Put `/docs` and `/openapi.json` behind authentication or IP allowlisting. + +- The generator produces a snapshot of the API at runtime. If your API uses dynamic runtime-only routes, consider whether those routes should be included in the public spec. + +## Example + +### Run the example +`npm run script openapi` + +### View example code +`test/spec/api/openapi-server.ts` — this file shows a `RestServer` with self-documenting Swagger. + +## Customization + +The default mount points are `/openapi.json` and `/docs`. If you need different paths or want to mount the UI manually, override or extend the server's `registerSwagger` method in your `RestServer` subclass and call the route helper with custom options. + +## Troubleshooting + +- If `/openapi.json` is missing or empty, verify `swaggerEnabled` is true and your server's routers are registering routes during startup. +- If some endpoints are missing from the spec, ensure the endpoint classes expose validators or metadata (for example `body`, `params`, `query`, `headers`) so the generator can infer the input shapes. diff --git a/docs/security-headers.md b/docs/security-headers.md new file mode 100644 index 0000000..86f8400 --- /dev/null +++ b/docs/security-headers.md @@ -0,0 +1,164 @@ +# Security Headers + +The api-machine server implements security best practices by setting HTTP security headers globally and disabling server fingerprinting headers. + +## Default Security Headers + +By default, the server applies the following security configurations: + +### Headers Set + +1. **X-Content-Type-Options: nosniff** + - Prevents MIME type sniffing attacks + - Forces browsers to respect the declared Content-Type + +2. **X-Frame-Options: DENY** + - Prevents clickjacking attacks + - Stops the page from being displayed in iframes + +3. **X-XSS-Protection: 1; mode=block** + - Provides legacy XSS protection for older browsers + - Modern browsers use Content-Security-Policy instead + +### Headers Removed + +1. **X-Powered-By** + - Express's default header is disabled + - Prevents server fingerprinting and information disclosure + - Reduces attack surface by not revealing server technology + +### Headers Not Set by Default + +1. **Strict-Transport-Security (HSTS)** + - Disabled by default (only use with HTTPS) + - Can be enabled via configuration when using HTTPS + +2. **Content-Security-Policy** + - Application-specific, should be set per endpoint or route + +## Configuration + +You can customize security headers when creating your server: + +```typescript +import { RestServer } from 'api-machine'; + +class MyServer extends RestServer { + override async routes() { + return [/* your routers */]; + } +} + +// Example 1: Default security (recommended) +const server = new MyServer({ + port: 3000 +}); + +// Example 2: Custom security configuration +const server = new MyServer({ + port: 3000, + securityHeaders: { + disableXPoweredBy: true, // Remove X-Powered-By + noSniff: true, // Set X-Content-Type-Options + frameOptions: 'SAMEORIGIN', // Allow same-origin iframes + xssProtection: true, // Set X-XSS-Protection + hsts: 31536000, // Enable HSTS for 1 year (HTTPS only!) + } +}); + +// Example 3: Disable security headers (not recommended) +const server = new MyServer({ + port: 3000, + securityHeaders: { + disableXPoweredBy: false, + noSniff: false, + frameOptions: false, + xssProtection: false, + hsts: false, + } +}); +``` + +## Security Headers Options + +### disableXPoweredBy +- **Type:** `boolean` +- **Default:** `true` +- **Description:** Disables the `X-Powered-By` header to prevent server fingerprinting + +### noSniff +- **Type:** `boolean` +- **Default:** `true` +- **Description:** Sets `X-Content-Type-Options: nosniff` to prevent MIME type sniffing + +### frameOptions +- **Type:** `'DENY' | 'SAMEORIGIN' | false` +- **Default:** `'DENY'` +- **Description:** Controls the `X-Frame-Options` header + - `'DENY'`: Page cannot be displayed in any iframe + - `'SAMEORIGIN'`: Page can be displayed in iframes from same origin + - `false`: Header not set + +### xssProtection +- **Type:** `boolean` +- **Default:** `true` +- **Description:** Sets `X-XSS-Protection: 1; mode=block` for legacy browser protection + +### hsts +- **Type:** `number | false` +- **Default:** `false` +- **Description:** Sets `Strict-Transport-Security` header with max-age in seconds + - **Only use with HTTPS!** + - Common values: `31536000` (1 year), `63072000` (2 years) + - `false`: Header not set + +## Override Headers in Endpoints + +You can override global security headers in individual endpoints: + +```typescript +import { ApiRequest, ApiResponse, BaseApiEndpoint } from 'api-machine'; + +export class CustomHeaderEndpoint extends BaseApiEndpoint { + override path = '/custom'; + + async handle(request: ApiRequest, response: ApiResponse) { + // Override global X-Frame-Options for this endpoint + response.setHeader('X-Frame-Options', 'SAMEORIGIN'); + + // Add custom header + response.setHeader('X-Custom-Header', 'value'); + + // Override X-Powered-By (if you really need to) + response.setHeader('X-Powered-By', 'MyCustomServer/1.0'); + + return { success: true }; + } +} +``` + +## Best Practices + +1. **Keep defaults enabled** - The default security headers provide good baseline protection +2. **Only enable HSTS with HTTPS** - Setting HSTS on HTTP can break your site +3. **Don't re-enable X-Powered-By** - Keeping this disabled improves security +4. **Consider Content-Security-Policy** - Set CSP headers at the application level for modern XSS protection +5. **Test with different browsers** - Security headers can behave differently across browsers + +## Additional Security Considerations + +While these headers provide important baseline security, remember to also: + +- Use HTTPS in production +- Implement proper authentication and authorization +- Validate and sanitize all user input +- Keep dependencies up to date +- Use environment variables for secrets +- Implement rate limiting +- Enable CORS properly for your use case + +## References + +- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) +- [MDN HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) +- [Security Headers Website](https://securityheaders.com/) diff --git a/examples/authentication-example.ts b/examples/authentication-example.ts new file mode 100644 index 0000000..1d0b03c --- /dev/null +++ b/examples/authentication-example.ts @@ -0,0 +1,155 @@ +/** + * Example for Authentication class system + * Shows server-level, router-level, and endpoint-level authentication + */ + +import { + RestServer, + BaseApiRouter, + BaseApiEndpoint, + BearerAuthenticationScheme, +} from '../src'; + +// This endpoint inherits server-level authentication +class ProtectedEndpoint extends BaseApiEndpoint { + override path = '/protected'; + override description = 'The token is: "valid-token-123"'; + + async handle() { + return { + message: 'This endpoint is protected by server-level auth', + authenticated: true, + }; + } +} + +class PublicEndpoint extends BaseApiEndpoint { + override path = '/info'; + override description = 'This endpoint is public - no authentication needed'; + + async handle() { + return { + message: 'This endpoint is public', + authenticated: false, + }; + } +} + +// Router explicitly made public (no authentication) +class PublicRouter extends BaseApiRouter { + override path = '/public'; + override authentication = null; // Explicitly public - overrides server auth + override description = 'Public routes - no authentication required'; + + async routes() { + return [PublicEndpoint]; + } +} + +class AdminDashboardEndpoint extends BaseApiEndpoint { + override path = '/dashboard'; + override description = + 'Admin dashboard - requires AdminAuth. ' + + 'The token is: "admin-token-456"'; + + async handle() { + return { + message: 'Admin dashboard - requires AdminAuth', + role: 'admin', + }; + } +} + +// Endpoint with even more restrictive authentication +class SuperAdminEndpoint extends BaseApiEndpoint { + override path = '/super-admin'; + override description = + 'Super admin area - requires SuperAdminAuth. ' + + 'The token is: "super-admin-token-789"'; + + // Endpoint-level override for super admin + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => { + return token === 'super-admin-token-789'; + }, + schemeName: 'SuperAdminAuth', + bearerFormat: 'JWT', + description: 'Super admin authentication', + }); + + async handle() { + return { + message: 'Super admin area - requires SuperAdminAuth.', + role: 'super-admin', + }; + } +} + +// Router with different authentication scheme +class AdminRouter extends BaseApiRouter { + override path = '/admin'; + + // Override with stricter authentication + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => { + // Admin token validation + return token === 'admin-token-456'; + }, + schemeName: 'AdminAuth', + bearerFormat: 'JWT', + description: 'Admin-level JWT authentication', + }); + + async routes() { + return [AdminDashboardEndpoint, SuperAdminEndpoint]; + } +} + +// Router inherits server authentication automatically +class MainRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [ProtectedEndpoint, PublicRouter, AdminRouter]; + } +} + +class AuthenticatedServer extends RestServer { + override router = MainRouter; + override name = 'Authentication Example API'; + override version = '1.0.0'; + + constructor() { + super({ + port: 4000, + swaggerEnabled: true, + // Server-level authentication - applies to all routes by default + authentication: new BearerAuthenticationScheme({ + checkToken: async (token: string) => { + // In real app, validate against database/JWT + return token === 'valid-token-123'; + }, + bearerFormat: 'JWT', + description: 'JWT Bearer token authentication', + }), + }); + } +} + +// Start the server +async function main() { + const server = new AuthenticatedServer(); + await server.start(); + console.log(`Server running on http://localhost:${server.port}`); + console.log('\nEndpoints:'); + console.log(' Protected: GET /api/protected (valid-token-123)'); + console.log(' Public: GET /api/public/info (no auth required)'); + console.log(' Admin: GET /api/admin/dashboard (admin-token-456)'); + console.log( + ' SuperAdmin:GET /api/admin/super-admin (super-admin-token-789)' + ); +} + +if (require.main === module) { + main().catch(console.error); +} diff --git a/examples/complete-example/README.md b/examples/complete-example/README.md new file mode 100644 index 0000000..0f3c5e2 --- /dev/null +++ b/examples/complete-example/README.md @@ -0,0 +1,146 @@ +# Complete Example + +This comprehensive example demonstrates all major features of the api-machine framework including domain-driven organization, multiple routers, all HTTP methods, route parameters, error handling, validation, custom logging, and Express integration. It showcases a complete REST API with organized domain structure. + +## Setup & Run + +From the root of the project, run: + +```bash +npx ts-node examples/complete-example +``` + +The server will start on `http://localhost:3000`. + +## Test the Endpoints + +### User CRUD Operations + +```bash +# List all users +curl http://localhost:3000/api/users + +# Get a specific user by ID +curl http://localhost:3000/api/users/123 + +# Create a new user +curl -X POST http://localhost:3000/api/users \ + -H "Content-Type: application/json" \ + -d '{"name":"Alice","email":"alice@example.com"}' + +# Update a user +curl -X PUT http://localhost:3000/api/users/123 \ + -H "Content-Type: application/json" \ + -d '{"name":"Bob","email":"bob@example.com"}' + +# Delete a user +curl -X DELETE http://localhost:3000/api/users/123 +``` + +### Error Handling + +```bash +# Test validation errors +curl http://localhost:3000/api/users/0 # 400 Bad Request +curl http://localhost:3000/api/users/999 # 404 Not Found + +# Test invalid POST data +curl -X POST http://localhost:3000/api/users \ + -H "Content-Type: application/json" \ + -d '{}' # Missing required fields + +curl -X POST http://localhost:3000/api/users \ + -H "Content-Type: application/json" \ + -d '{"name":"Alice","email":"invalid"}' # Invalid email format +``` + +### Express Integration + +```bash +# Test header access and manipulation +curl http://localhost:3000/api/express/headers \ + -H "X-Custom-Header: my-value" -v + +# Test query parameter parsing +curl "http://localhost:3000/api/express/search?q=test&page=2&limit=20" +``` + +### Health Check + +```bash +# Check service health +curl http://localhost:3000/api/health +``` + +## Key Points + +### Domain-Driven Organization +- Endpoints organized by domain (users, express-features) +- Each domain has its own router +- Routers group related endpoints under a common path +- Demonstrates scalable project structure + +### HTTP Methods +- **GET**: Default method for reading data +- **POST**: Override `register()` method to create resources +- **PUT**: Override `register()` method to update resources +- **DELETE**: Override `register()` method to delete resources + +### Route Parameters +- Use Express-style route parameters like `:id` in endpoint paths +- Paths are relative to their router (e.g., `/:id` under `/api/users` becomes `/api/users/:id`) +- Access via `request.params.id` + +### Error Handling & Validation +- Validate input before processing +- Return appropriate HTTP status codes (400, 404, etc.) +- Use consistent error response format with `error`, `code`, and `timestamp` +- Use `return response.status().json()` for error responses + +### Custom Logging +- The server uses `ts-tiny-log` with custom configuration +- Logger is configured in `server.ts` with timestamps and log levels +- Demonstrates how to integrate custom loggers that implement `LogInterface` + +### Express Integration +- Full access to Express request/response objects +- Access headers via `request.headers` +- Parse query parameters via `request.query` +- Set response headers with `response.setHeader()` + +### Server Configuration +- Custom port configuration +- Payload size limits (`maxPayloadSizeMB`) +- Custom logger integration + +## File Structure + +``` +complete-example/ +├── index.ts # Entry point +├── server.ts # Server with custom logger +├── router.ts # Main router (groups domain routers) +├── users/ # User management domain +│ ├── users-router.ts # Groups under /api/users +│ ├── list-users-endpoint.ts # GET /api/users +│ ├── get-user-endpoint.ts # GET /api/users/:id +│ ├── create-user-endpoint.ts # POST /api/users +│ ├── update-user-endpoint.ts # PUT /api/users/:id +│ └── delete-user-endpoint.ts # DELETE /api/users/:id +└── express-features/ # Express integration domain + ├── express-features-router.ts # Groups under /api/express + ├── headers-endpoint.ts # GET /api/express/headers + └── query-params-endpoint.ts # GET /api/express/search +``` + +## Learning Path + +After completing the quick-start example, this example demonstrates: +1. How to organize endpoints using domain-driven design +2. How to create and nest multiple routers +3. How to implement full CRUD operations +4. How to override HTTP methods beyond GET +5. How to validate input and handle errors gracefully +6. How to configure and use custom loggers +7. How to leverage Express features within your endpoints +8. Best practices for structuring a production-ready API diff --git a/examples/complete-example/express-features/express-features-router.ts b/examples/complete-example/express-features/express-features-router.ts new file mode 100644 index 0000000..285f635 --- /dev/null +++ b/examples/complete-example/express-features/express-features-router.ts @@ -0,0 +1,18 @@ +import { BaseApiRouter } from '../../../src/index'; +import { HeadersEndpoint } from './headers-endpoint'; +import { QueryParamsEndpoint } from './query-params-endpoint'; + +/** + * Express Features Router + * + * Groups endpoints demonstrating Express integration: + * - Request/response headers + * - Query parameter parsing + */ +export class ExpressFeaturesRouter extends BaseApiRouter { + override path = '/express'; + + async routes() { + return [HeadersEndpoint, QueryParamsEndpoint]; + } +} diff --git a/examples/complete-example/express-features/headers-endpoint.ts b/examples/complete-example/express-features/headers-endpoint.ts new file mode 100644 index 0000000..0f49c37 --- /dev/null +++ b/examples/complete-example/express-features/headers-endpoint.ts @@ -0,0 +1,34 @@ +import { ApiRequest, ApiResponse, BaseApiEndpoint } from '../../../src/index'; + +/** + * Complete Example - Headers Endpoint + * + * Demonstrates accessing request headers and setting response headers. + * Accessible at GET /api/express/headers + * + * Note: X-Powered-By is disabled globally by default for security. + * Setting it here demonstrates how to override with a custom value. + */ +export class HeadersEndpoint extends BaseApiEndpoint { + override path = '/headers'; + + async handle(request: ApiRequest, response: ApiResponse) { + // Access request headers + const userAgent = request.headers['user-agent']; + const contentType = request.headers['content-type']; + const customHeader = request.headers['x-custom-header']; + + // Set response headers + response.setHeader('X-Custom-Header', 'custom-value'); + response.setHeader('X-Powered-By', 'api-machine'); + + return { + receivedHeaders: { + userAgent, + contentType, + customHeader, + }, + timestamp: new Date().toISOString(), + }; + } +} diff --git a/examples/complete-example/express-features/query-params-endpoint.ts b/examples/complete-example/express-features/query-params-endpoint.ts new file mode 100644 index 0000000..beaa320 --- /dev/null +++ b/examples/complete-example/express-features/query-params-endpoint.ts @@ -0,0 +1,32 @@ +import { ApiRequest, ApiResponse, GetEndpoint } from '../../../src/index'; + +/** + * Complete Example - Query Parameters Endpoint + * + * Demonstrates accessing and parsing query parameters. + * Accessible at GET /api/express/search?q=test&page=1&limit=20 + */ +export class QueryParamsEndpoint extends GetEndpoint { + override path = '/search'; + + async handle(request: ApiRequest, response: ApiResponse) { + // Access query parameters + const queryParams = request.query; + const page = request.query['page'] || 1; + const limit = request.query['limit'] || 10; + const search = request.query['q'] || ''; + + return { + query: { + page: parseInt(page as string, 10), + limit: parseInt(limit as string, 10), + search, + }, + allParams: queryParams, + results: [ + { id: 1, title: 'Result 1' }, + { id: 2, title: 'Result 2' }, + ], + }; + } +} diff --git a/examples/complete-example/index.ts b/examples/complete-example/index.ts new file mode 100644 index 0000000..47f05ad --- /dev/null +++ b/examples/complete-example/index.ts @@ -0,0 +1,78 @@ +import { MyServer, customLogger } from './server'; +import { Log } from 'ts-tiny-log'; + +/** + * Complete Example + * + * This example demonstrates advanced REST API features including: + * - Domain-driven folder organization + * - Multiple routers for different domains + * - All HTTP methods (GET, POST, PUT, DELETE) + * - Route parameters + * - Error handling and validation + * - Custom logger configuration with ts-tiny-log + * - Express integration (headers, query params) + * + * File Structure: + * - index.ts: Entry point + * - server.ts: Server class with custom logger + * - router.ts: Main API router that groups domain routers + * - users/: User management domain + * - users-router.ts: Groups all user endpoints under /api/users + * - list-users-endpoint.ts: List all users (GET /api/users) + * - get-user-endpoint.ts: Get single user (GET /api/users/:id) + * - create-user-endpoint.ts: Create user (POST /api/users) + * - update-user-endpoint.ts: Update user (PUT /api/users/:id) + * - delete-user-endpoint.ts: Delete user (DELETE /api/users/:id) + * - express-features/: Express integration examples + * - express-features-router.ts: Groups features under /api/express + * - headers-endpoint.ts: Headers manipulation (GET /api/express/headers) + * - query-params-endpoint.ts: Query parsing (GET /api/express/search) + */ + +const log = new Log(); + +// Start server with custom options +const server = new MyServer({ + port: 3000, + maxPayloadSizeMB: 10, + log: customLogger, +}); + +server + .start() + .then(() => { + log.info('Server is running at http://localhost:3000'); + log.info('Available endpoints:'); + log.info(''); + log.info('User CRUD:'); + log.info(' GET http://localhost:3000/api/users'); + log.info(' GET http://localhost:3000/api/users/:id'); + log.info(' POST http://localhost:3000/api/users'); + log.info(' PUT http://localhost:3000/api/users/:id'); + log.info(' DELETE http://localhost:3000/api/users/:id'); + log.info(''); + log.info('Express Integration:'); + log.info(' GET http://localhost:3000/api/express/headers'); + log.info( + ' GET http://localhost:3000/api/express/search?q=test&page=1' + ); + log.info(''); + log.info('Health Check:'); + log.info(' GET http://localhost:3000/api/health'); + log.info(''); + log.info('Error Handling Examples:'); + log.info( + ' GET http://localhost:3000/api/users/0 (400 Bad Request)' + ); + log.info( + ' GET http://localhost:3000/api/users/999 (404 Not Found)' + ); + log.info( + ' POST http://localhost:3000/api/users (with invalid body)' + ); + }) + .catch((error) => { + log.error('Failed to start server:', error); + process.exit(1); + }); diff --git a/examples/complete-example/router.ts b/examples/complete-example/router.ts new file mode 100644 index 0000000..20c3494 --- /dev/null +++ b/examples/complete-example/router.ts @@ -0,0 +1,21 @@ +import { BaseApiRouter, HealthCheckEndpoint } from '../../src/index'; +import { UsersRouter } from './users/users-router'; +// eslint-disable-next-line max-len +import { ExpressFeaturesRouter } from './express-features/express-features-router'; + +/** + * Complete Example - API Router + * + * Groups all domain routers under /api, demonstrating: + * - Domain-driven organization + * - Nested routers + * - Separation of concerns + * - Pre-built endpoints (health check) + */ +export class ApiRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [UsersRouter, ExpressFeaturesRouter, HealthCheckEndpoint]; + } +} diff --git a/examples/complete-example/server.ts b/examples/complete-example/server.ts new file mode 100644 index 0000000..c10ed77 --- /dev/null +++ b/examples/complete-example/server.ts @@ -0,0 +1,19 @@ +import { RestServer } from '../../src/index'; +import { ApiRouter } from './router'; +import { Log } from 'ts-tiny-log'; + +/** + * Complete Example - Server + * + * Server class with custom logger configuration. + * Demonstrates server options including custom logging. + */ +export class MyServer extends RestServer { + override router = ApiRouter; +} + +// Create a custom logger instance +export const customLogger = new Log({ + shouldWriteLogLevel: true, + shouldWriteTimestamp: true, +}); diff --git a/examples/complete-example/users/create-user-endpoint.ts b/examples/complete-example/users/create-user-endpoint.ts new file mode 100644 index 0000000..f7e9652 --- /dev/null +++ b/examples/complete-example/users/create-user-endpoint.ts @@ -0,0 +1,39 @@ +import { ApiRequest, ApiResponse, PostEndpoint } from '../../../src/index'; +import { usersRepo, User } from './users-repository'; +import { ObjectSanitizer, EmailValidator } from 'valsan'; +import { NameValSan } from './name-valsan'; + +/** + * Complete Example - Create User Endpoint (POST) + * + * Accessible at POST /api/users/ + */ +export class CreateUserEndpoint extends PostEndpoint { + override path = '/'; + + override body = new ObjectSanitizer({ + name: new NameValSan(), + email: new EmailValidator(), + }); + + async handle(request: ApiRequest, response: ApiResponse) { + const { name, email } = request.body; + + // Generate new ID + const newId = Math.max(...Object.keys(usersRepo).map(Number)) + 1; + + // Create new user + const newUser: User = { + id: newId, + name, + email, + created: new Date(), + }; + + // Add to repository + usersRepo[newId] = newUser; + + // Return new user (PostEndpoint automatically sets 201 status) + return newUser; + } +} diff --git a/examples/complete-example/users/delete-user-endpoint.ts b/examples/complete-example/users/delete-user-endpoint.ts new file mode 100644 index 0000000..abece32 --- /dev/null +++ b/examples/complete-example/users/delete-user-endpoint.ts @@ -0,0 +1,33 @@ +import { + ApiRequest, + ApiResponse, + DeleteEndpoint, + NotFoundError, +} from '../../../src/index'; +import { usersRepo } from './users-repository'; + +/** + * Complete Example - Delete User Endpoint (DELETE) + * + * Accessible at DELETE /api/users/:id + */ +export class DeleteUserEndpoint extends DeleteEndpoint { + override path = '/:id'; + + async handle(request: ApiRequest, response: ApiResponse) { + const userId = parseInt(request.params['id'], 10); + const user = usersRepo[userId]; + + if (!user) { + throw new NotFoundError('User not found', { + details: { userId }, + }); + } + + // Delete user from repository + delete usersRepo[userId]; + + // DeleteEndpoint automatically sets 204 status + return {}; + } +} diff --git a/examples/complete-example/users/get-user-endpoint.ts b/examples/complete-example/users/get-user-endpoint.ts new file mode 100644 index 0000000..819c98f --- /dev/null +++ b/examples/complete-example/users/get-user-endpoint.ts @@ -0,0 +1,25 @@ +import { ApiRequest, ApiResponse, GetEndpoint } from '../../../src/index'; +import { NotFoundError } from '../../../src/error'; +import { usersRepo } from './users-repository'; + +/** + * Complete Example - Get User Endpoint + * + * Retrieves a single user by ID using route parameters. + * Accessible at GET /api/users/:id + */ +export class GetUserEndpoint extends GetEndpoint { + override path = '/:id'; + + async handle(request: ApiRequest, response: ApiResponse) { + const userId = parseInt(request.params['id'], 10); + const user = usersRepo[userId]; + + if (!user) { + // Throw HTTPError - server will automatically format response + throw new NotFoundError('User not found', { details: { userId } }); + } + + return user; + } +} diff --git a/examples/complete-example/users/list-users-endpoint.ts b/examples/complete-example/users/list-users-endpoint.ts new file mode 100644 index 0000000..d948df4 --- /dev/null +++ b/examples/complete-example/users/list-users-endpoint.ts @@ -0,0 +1,33 @@ +import { + ComposedValSan, + LengthValidator, + ObjectSanitizer, + TrimSanitizer, +} from 'valsan'; +import { ApiRequest, ApiResponse, GetEndpoint } from '../../../src/index'; +import { usersRepo } from './users-repository'; + +/** + * Complete Example - List Users Endpoint + * + * Returns a list of all users. + * Accessible at GET /api/users/ + */ +export class ListUsersEndpoint extends GetEndpoint { + override path = '/'; + + override params = new ObjectSanitizer({ + name: new ComposedValSan( + [new TrimSanitizer(), new LengthValidator({ minLength: 3 })], + { isOptional: true } + ), + email: new ComposedValSan( + [new TrimSanitizer(), new LengthValidator({ minLength: 5 })], + { isOptional: true } + ), + }); + + async handle(request: ApiRequest, response: ApiResponse) { + return Object.values(usersRepo); + } +} diff --git a/examples/complete-example/users/name-valsan.ts b/examples/complete-example/users/name-valsan.ts new file mode 100644 index 0000000..6b8f33b --- /dev/null +++ b/examples/complete-example/users/name-valsan.ts @@ -0,0 +1,10 @@ +import { TrimSanitizer, ComposedValSan, LengthValidator } from 'valsan'; + +export class NameValSan extends ComposedValSan { + constructor() { + super([ + new TrimSanitizer(), + new LengthValidator({ minLength: 1, maxLength: 50 }), + ]); + } +} diff --git a/examples/complete-example/users/update-user-endpoint.ts b/examples/complete-example/users/update-user-endpoint.ts new file mode 100644 index 0000000..2fefdc1 --- /dev/null +++ b/examples/complete-example/users/update-user-endpoint.ts @@ -0,0 +1,34 @@ +import { ApiRequest, PutEndpoint, NotFoundError } from '../../../src/index'; +import { usersRepo } from './users-repository'; + +/** + * Complete Example - Update User Endpoint (PUT) + * + * Accessible at PUT /api/users/:id + */ +export class UpdateUserEndpoint extends PutEndpoint { + override path = '/:id'; + + async handle(request: ApiRequest) { + const userId = parseInt(request.params['id'], 10); + const user = usersRepo[userId]; + + if (!user) { + throw new NotFoundError('User not found', { + details: { userId }, + }); + } + + // Update user properties + const { name, email } = request.body; + if (name) { + user.name = name; + } + + if (email) { + user.email = email; + } + + return user; + } +} diff --git a/examples/complete-example/users/users-repository.ts b/examples/complete-example/users/users-repository.ts new file mode 100644 index 0000000..7104e22 --- /dev/null +++ b/examples/complete-example/users/users-repository.ts @@ -0,0 +1,21 @@ +export interface User { + id: number; + name: string; + email: string; + created: Date; +} + +export const usersRepo: { [id: number]: User } = { + 1: { + id: 1, + name: 'Alice', + email: 'alice@example.com', + created: new Date('2023-01-01'), + }, + 2: { + id: 2, + name: 'Bob', + email: 'bob@example.com', + created: new Date('2023-01-02'), + }, +}; diff --git a/examples/complete-example/users/users-router.ts b/examples/complete-example/users/users-router.ts new file mode 100644 index 0000000..a211eb2 --- /dev/null +++ b/examples/complete-example/users/users-router.ts @@ -0,0 +1,29 @@ +import { BaseApiRouter } from '../../../src/index'; +import { ListUsersEndpoint } from './list-users-endpoint'; +import { GetUserEndpoint } from './get-user-endpoint'; +import { CreateUserEndpoint } from './create-user-endpoint'; +import { UpdateUserEndpoint } from './update-user-endpoint'; +import { DeleteUserEndpoint } from './delete-user-endpoint'; + +/** + * Users Router + * + * Groups all user-related endpoints, demonstrating: + * - Full CRUD operations + * - Different HTTP methods (GET, POST, PUT, DELETE) + * - Route parameters + * - Error handling and validation + */ +export class UsersRouter extends BaseApiRouter { + override path = '/users'; + + async routes() { + return [ + ListUsersEndpoint, + GetUserEndpoint, + CreateUserEndpoint, + UpdateUserEndpoint, + DeleteUserEndpoint, + ]; + } +} diff --git a/examples/quick-start/README.md b/examples/quick-start/README.md new file mode 100644 index 0000000..305a64d --- /dev/null +++ b/examples/quick-start/README.md @@ -0,0 +1,36 @@ +# Quick Start Example + +This example demonstrates the basic setup of a REST API server with a simple router and endpoint structure. It shows the minimal code needed to get a server up and running with two endpoints: a simple greeting endpoint and a users list endpoint. + +## Setup & Run + +From the root of the project, run: + +```bash +npx ts-node examples/quick-start +``` + +The server will start on `http://localhost:3000`. + +## Test the Endpoints + +Once running, you can test these endpoints: + +```bash +# Get a greeting message +curl http://localhost:3000/api/hello + +# Get a list of users +curl http://localhost:3000/api/users + +# Health check endpoint +curl http://localhost:3000/api/health +``` + +## Key Points + +- **Minimal Setup**: This example shows the absolute minimum code needed to create a working REST API server +- **Class-Based Architecture**: Notice how the server, router, and endpoints are all separate classes that extend base classes +- **File Organization**: Each component (server, router, endpoints) is in its own file for better maintainability +- **Default HTTP Method**: Endpoints use GET by default - no need to override the `register()` method +- **Automatic JSON Response**: Simply return an object or array from the `handle()` method, and it will be automatically converted to JSON diff --git a/examples/quick-start/endpoints/hello-endpoint.ts b/examples/quick-start/endpoints/hello-endpoint.ts new file mode 100644 index 0000000..cee4208 --- /dev/null +++ b/examples/quick-start/endpoints/hello-endpoint.ts @@ -0,0 +1,15 @@ +import { ApiRequest, ApiResponse, BaseApiEndpoint } from '../../../src/index'; + +/** + * Quick Start Example - Hello Endpoint + * + * A simple endpoint that returns a greeting message. + * Accessible at GET /api/hello + */ +export class HelloEndpoint extends BaseApiEndpoint { + override path = '/hello'; + + async handle(request: ApiRequest, response: ApiResponse) { + return { message: 'Hello, World!' }; + } +} diff --git a/examples/quick-start/endpoints/users-endpoint.ts b/examples/quick-start/endpoints/users-endpoint.ts new file mode 100644 index 0000000..aad5e0d --- /dev/null +++ b/examples/quick-start/endpoints/users-endpoint.ts @@ -0,0 +1,18 @@ +import { ApiRequest, ApiResponse, BaseApiEndpoint } from '../../../src/index'; + +/** + * Quick Start Example - Users Endpoint + * + * Returns a list of users. + * Accessible at GET /api/users + */ +export class UsersEndpoint extends BaseApiEndpoint { + override path = '/users'; + + async handle(request: ApiRequest, response: ApiResponse) { + return [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ]; + } +} diff --git a/examples/quick-start/index.ts b/examples/quick-start/index.ts new file mode 100644 index 0000000..29cb866 --- /dev/null +++ b/examples/quick-start/index.ts @@ -0,0 +1,36 @@ +import { MyApiServer } from './server'; +import { Log } from 'ts-tiny-log'; + +/** + * Quick Start Example + * + * This example demonstrates the basic setup of a REST API server + * with a simple router and endpoint structure. + * + * File Structure: + * - index.ts: Entry point that starts the server + * - server.ts: Server class definition + * - router.ts: Router that groups endpoints + * - endpoints/: Individual endpoint implementations + */ + +const log = new Log(); + +// Start the server +const server = new MyApiServer({ + port: 3000, +}); + +server + .start() + .then(() => { + log.info('Server is running at http://localhost:3000'); + log.info('Try these endpoints:'); + log.info(' GET http://localhost:3000/api/hello'); + log.info(' GET http://localhost:3000/api/users'); + log.info(' GET http://localhost:3000/api/health'); + }) + .catch((error) => { + log.error('Error starting server:', error); + process.exit(1); + }); diff --git a/examples/quick-start/router.ts b/examples/quick-start/router.ts new file mode 100644 index 0000000..d5c86c9 --- /dev/null +++ b/examples/quick-start/router.ts @@ -0,0 +1,16 @@ +import { BaseApiRouter, HealthCheckEndpoint } from '../../src/index'; +import { HelloEndpoint } from './endpoints/hello-endpoint'; +import { UsersEndpoint } from './endpoints/users-endpoint'; + +/** + * Quick Start Example - Router + * + * This router groups related endpoints under the /api path. + */ +export class MyRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [HelloEndpoint, UsersEndpoint, HealthCheckEndpoint]; + } +} diff --git a/examples/quick-start/server.ts b/examples/quick-start/server.ts new file mode 100644 index 0000000..76f0026 --- /dev/null +++ b/examples/quick-start/server.ts @@ -0,0 +1,11 @@ +import { RestServer } from '../../src/index'; +import { MyRouter } from './router'; + +/** + * Quick Start Example - Server + * + * This is the main server class that defines which routers to use. + */ +export class MyApiServer extends RestServer { + override router = MyRouter; +} diff --git a/package-lock.json b/package-lock.json index 4b16375..6cfcb7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,27 @@ { - "name": "ts-rest", + "name": "api-machine", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ts-rest", + "name": "api-machine", "version": "1.0.0", "license": "MIT", + "dependencies": { + "auto-oas": "^1.2.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "swagger-ui-express": "^5.0.1", + "valsan": "^2.2.0" + }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", "@types/jasmine": "^5.1.4", "@types/node": "^18.19.39", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^7.13.1", "eslint": "^8.57.0", "jasmine": "^5.1.0", @@ -19,12 +29,17 @@ "prettier": "^3.3.2", "prettier-eslint": "^16.3.0", "prettier-eslint-cli": "^8.0.1", + "ts-appconfig": "^1.2.0", + "ts-jasmine-spies": "^1.0.0", "ts-node": "^10.9.2", "ts-packager": "^1.1.0", "ts-script": "^1.0.0", "ts-tiny-log": "^1.1.1", "typedoc": "^0.26.1", "typescript": "~5.4.2" + }, + "peerDependencies": { + "valsan": "^2.2.0" } }, "node_modules/@babel/code-frame": { @@ -556,9 +571,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -972,6 +987,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@shikijs/core": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", @@ -1056,9 +1078,9 @@ "license": "MIT" }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -1083,6 +1105,62 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1093,10 +1171,17 @@ "@types/unist": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jasmine": { - "version": "5.1.12", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.12.tgz", - "integrity": "sha512-1BzPxNsFDLDfj9InVR3IeY0ZVf4o9XV+4mDqoCfyPkbsA7dYyKAPAb2co6wLFlHcvxPlt1wShm7zQdV7uTfLGA==", + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.13.tgz", + "integrity": "sha512-MYCcDkruFc92LeYZux5BC0dmqo2jk+M5UIZ4/oFnAPCXN9mCcQhLyj7F3/Za7rocVyt5YRr1MmqJqFlvQ9LVcg==", "dev": true, "license": "MIT" }, @@ -1110,6 +1195,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", @@ -1120,6 +1212,64 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1328,6 +1478,19 @@ "dev": true, "license": "ISC" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1485,6 +1648,18 @@ "node": ">=8" } }, + "node_modules/auto-oas": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/auto-oas/-/auto-oas-1.2.0.tgz", + "integrity": "sha512-WWn5Zv2Ebo9NJTlKnExnBF7O/bK2jhZTnp4VnMnZ4E4o7Wyv7uhvbuWDhbbkeo9h456pkfWkeRrG5D2x6Xk/ig==", + "license": "MIT", + "dependencies": { + "valsan": "^2.1.0" + }, + "peerDependencies": { + "valsan": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1493,15 +1668,35 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", - "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "version": "2.8.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", + "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/boolify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/boolify/-/boolify-1.0.1.tgz", @@ -1533,9 +1728,9 @@ } }, "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", "dev": true, "funding": [ { @@ -1553,10 +1748,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { @@ -1573,6 +1768,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -1589,6 +1793,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1655,9 +1888,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001754", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", - "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", "dev": true, "funding": [ { @@ -1855,6 +2088,27 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -1862,6 +2116,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-js": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", @@ -1874,6 +2146,19 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1900,7 +2185,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1947,6 +2231,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2014,6 +2307,20 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2021,10 +2328,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { - "version": "1.5.245", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.245.tgz", - "integrity": "sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==", + "version": "1.5.254", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", + "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", "dev": true, "license": "ISC" }, @@ -2042,6 +2355,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -2055,6 +2377,36 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -2072,6 +2424,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2274,6 +2632,57 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2361,6 +2770,23 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -2448,6 +2874,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -2491,6 +2935,15 @@ "dev": true, "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2511,6 +2964,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -2521,6 +2998,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -2605,6 +3095,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2652,6 +3154,18 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -2679,6 +3193,18 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -2735,6 +3261,43 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2798,9 +3361,17 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2854,6 +3425,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3054,9 +3631,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3394,6 +3971,15 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", @@ -3423,6 +4009,27 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3541,6 +4148,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3578,7 +4206,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/natural-compare": { @@ -3588,6 +4215,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -3769,11 +4405,43 @@ "dev": true, "license": "ISC" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3900,6 +4568,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3954,6 +4631,16 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4429,6 +5116,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4449,6 +5149,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4483,6 +5198,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -4639,6 +5394,22 @@ "node": "*" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4673,6 +5444,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-identifier": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", @@ -4680,6 +5471,12 @@ "dev": true, "license": "ISC" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4693,6 +5490,43 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -4700,6 +5534,12 @@ "dev": true, "license": "ISC" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4740,6 +5580,78 @@ "@types/hast": "^3.0.4" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4841,6 +5753,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4989,6 +5910,30 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5070,6 +6015,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5118,6 +6072,13 @@ "source-map-support": "^0.5.21" } }, + "node_modules/ts-jasmine-spies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ts-jasmine-spies/-/ts-jasmine-spies-1.0.0.tgz", + "integrity": "sha512-d9CpBj8ZTsajeV509I5EcHK0t9VwjLpqAhbuwsHBk7fIL7fBo6NpaOljA48WJl+u7n1kiJV8JeXvVHgTYqOk1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -5226,6 +6187,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -5380,6 +6355,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -5438,6 +6422,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valsan": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/valsan/-/valsan-2.2.0.tgz", + "integrity": "sha512-qogtVowNynknDCjCxhi1sLxyqxvfpjlIucvVI3t91+fFbQ80AsDyJsmVBkJteTZFMOwS1F2BUyIZAfB5BjtJTw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -5631,7 +6630,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 8ce2dad..dfe6e0a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ts-rest", + "name": "api-machine", "version": "1.0.0", - "description": "ts-rest", + "description": "api-machine", "private": "true", "typescript-template": { "base": "1.0.1", @@ -21,10 +21,20 @@ "script": "ts-node scripts", "publish": "npm run script -- publish" }, + "dependencies": { + "auto-oas": "^1.2.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "swagger-ui-express": "^5.0.1", + "valsan": "^2.2.0" + }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", "@types/jasmine": "^5.1.4", "@types/node": "^18.19.39", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^7.13.1", "eslint": "^8.57.0", "jasmine": "^5.1.0", @@ -32,6 +42,8 @@ "prettier": "^3.3.2", "prettier-eslint": "^16.3.0", "prettier-eslint-cli": "^8.0.1", + "ts-appconfig": "^1.2.0", + "ts-jasmine-spies": "^1.0.0", "ts-node": "^10.9.2", "ts-packager": "^1.1.0", "ts-script": "^1.0.0", @@ -39,15 +51,18 @@ "typedoc": "^0.26.1", "typescript": "~5.4.2" }, + "peerDependencies": { + "valsan": "^2.2.0" + }, "repository": { "type": "git", - "url": "git+https://github.com/ts-rest/ts-rest.git" + "url": "git+https://github.com/StatelessStudio/api-machine.git" }, - "author": "ts-rest", + "author": "StatelessStudio", "license": "MIT", "bugs": { - "url": "https://github.com/ts-rest/ts-rest/issues" + "url": "https://github.com/StatelessStudio/api-machine/issues" }, - "homepage": "https://github.com/ts-rest/ts-rest#readme", + "homepage": "https://github.com/StatelessStudio/api-machine#readme", "keywords": [] } diff --git a/scripts/openapi.ts b/scripts/openapi.ts new file mode 100644 index 0000000..94772bf --- /dev/null +++ b/scripts/openapi.ts @@ -0,0 +1,29 @@ +import { env } from '../test/env'; +import { OpenApiTestServer } from '../test/spec/api/openapi-server'; + +/** + * Starts a test server with OpenAPI documentation enabled. + */ +export default async function example(): Promise { + await new OpenApiTestServer({ + port: env.API_PORT, + swaggerEnabled: true, + }).start(); + + // eslint-disable-next-line no-console + console.log(`Test server running at http://localhost:${env.API_PORT}`); + + // Gracefully handle shutdown on SIGINT (Ctrl+C) or SIGTERM + process.on('SIGINT', () => { + console.log('Shutting down server...'); + process.exit(0); + }); + process.on('SIGTERM', () => { + console.log('Shutting down server...'); + process.exit(0); + }); + + // Keep the process alive + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await new Promise((resolve) => {}); +} diff --git a/src/authentication/authenticated-request.ts b/src/authentication/authenticated-request.ts new file mode 100644 index 0000000..91caf61 --- /dev/null +++ b/src/authentication/authenticated-request.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export interface AuthenticatedRequest extends Request { + authenticated?: boolean; +} diff --git a/src/authentication/authentication-scheme.ts b/src/authentication/authentication-scheme.ts new file mode 100644 index 0000000..594e3d6 --- /dev/null +++ b/src/authentication/authentication-scheme.ts @@ -0,0 +1,52 @@ +import { RequestHandler } from 'express'; +import { + SecuritySchemeObject, + SecurityRequirementObject, +} from 'auto-oas/oas/v3.1'; + +/** + * Abstract base class for authentication schemes + * Extend this class to create custom authentication schemes that integrate + * with both Express middleware and OpenAPI specification generation + */ +export abstract class AuthenticationScheme { + /** + * Unique name for this authentication scheme + * This will be used as the key in OpenAPI securitySchemes + */ + public abstract readonly schemeName: string; + + /** + * The type of security scheme as defined by OpenAPI + */ + public abstract readonly type: + | 'http' + | 'apiKey' + | 'oauth2' + | 'openIdConnect'; + + /** + * Generate the OpenAPI security scheme object + * This will be added to components.securitySchemes in the OpenAPI spec + * @returns SecuritySchemeObject compliant with OpenAPI 3.1 + */ + public abstract getSecurityScheme(): SecuritySchemeObject; + + /** + * Generate the OpenAPI security requirement + * This defines which security scheme is required for an operation + * Can be overridden for schemes that require specific scopes (OAuth2) + * @returns SecurityRequirementObject compliant with OpenAPI 3.1 + */ + public getSecurityRequirement(): SecurityRequirementObject { + return { [this.schemeName]: [] }; + } + + /** + * Generate the Express middleware that enforces this authentication + * The middleware should validate the authentication and throw appropriate + * HTTPError instances if authentication fails + * @returns Express RequestHandler middleware + */ + public abstract getMiddleware(): RequestHandler; +} diff --git a/src/authentication/index.ts b/src/authentication/index.ts new file mode 100644 index 0000000..532896d --- /dev/null +++ b/src/authentication/index.ts @@ -0,0 +1,4 @@ +export { AuthenticatedRequest } from './authenticated-request'; +export { AuthenticationScheme } from './authentication-scheme'; +export * from './middleware'; +export * from './schemes'; diff --git a/src/authentication/middleware/bearer-authentication.ts b/src/authentication/middleware/bearer-authentication.ts new file mode 100644 index 0000000..bf1b003 --- /dev/null +++ b/src/authentication/middleware/bearer-authentication.ts @@ -0,0 +1,76 @@ +import { Response, NextFunction } from 'express'; +import { UnauthorizedError } from '../../error'; +import { AuthenticatedRequest } from '../authenticated-request'; +import { BearerTokenValSan } from 'valsan'; +import { LogInterface } from '../../log'; + +export interface BearerAuthenticationMiddlewareOptions { + checkToken: (token: string) => Promise; + log?: LogInterface; +} + +/** + * Bearer token authentication middleware + * Validates the Authorization header against the configured bearer token + */ +export function bearerAuthenticationMiddleware( + options: BearerAuthenticationMiddlewareOptions +) { + return async function ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + const authHeader = + req.headers['authorization'] || req.headers['Authorization']; + + if (!authHeader) { + options.log?.warn( + 'Unauthorized access attempt from ' + + req.ip + + ' - No token provided' + ); + + throw new UnauthorizedError('Authorization header is missing', { + scheme: 'Bearer', + }); + } + + const validationResult = await new BearerTokenValSan().run(authHeader); + let token: string; + + if (validationResult.success) { + token = validationResult.data!; + } + else { + options.log?.warn( + 'Unauthorized access attempt from ' + + req.ip + + ' - Invalid token format', + JSON.stringify(validationResult.errors, null, 2) + ); + + throw new UnauthorizedError('Bearer token is empty or invalid', { + scheme: 'Bearer', + details: validationResult.errors, + }); + } + + if (!(await options.checkToken(token))) { + options.log?.warn( + 'Unauthorized access attempt from ' + + req.ip + + ' - Check token failed' + ); + + throw new UnauthorizedError('Bearer token check failed', { + scheme: 'Bearer', + }); + } + + req.authenticated = true; + next(); + + return; + }; +} diff --git a/src/authentication/middleware/index.ts b/src/authentication/middleware/index.ts new file mode 100644 index 0000000..8267c73 --- /dev/null +++ b/src/authentication/middleware/index.ts @@ -0,0 +1 @@ +export { bearerAuthenticationMiddleware } from './bearer-authentication'; diff --git a/src/authentication/schemes/bearer-authentication-scheme.ts b/src/authentication/schemes/bearer-authentication-scheme.ts new file mode 100644 index 0000000..c3f15d8 --- /dev/null +++ b/src/authentication/schemes/bearer-authentication-scheme.ts @@ -0,0 +1,72 @@ +import { RequestHandler } from 'express'; +import { AuthenticationScheme } from '../authentication-scheme'; +import { SecuritySchemeObject } from 'auto-oas/oas/v3.1'; +import { + bearerAuthenticationMiddleware, + BearerAuthenticationMiddlewareOptions, +} from '../middleware/bearer-authentication'; + +export interface BearerAuthenticationSchemeOptions + extends BearerAuthenticationMiddlewareOptions { + /** + * The name of the security scheme in OpenAPI + * @default 'BearerAuth' + */ + schemeName?: string; + + /** + * Format of the bearer token (e.g., 'JWT') + * This is informational and appears in the OpenAPI spec + * @default 'JWT' + */ + bearerFormat?: string; + + /** + * Description of the authentication scheme + * Appears in the OpenAPI documentation + */ + description?: string; +} + +/** + * Bearer token authentication scheme + * Validates the Authorization header with Bearer token + * Automatically generates OpenAPI security scheme documentation + */ +export class BearerAuthenticationScheme extends AuthenticationScheme { + public readonly type = 'http' as const; + public readonly schemeName: string; + + private readonly bearerFormat: string; + private readonly description?: string; + private readonly middlewareOptions: BearerAuthenticationMiddlewareOptions; + + constructor(options: BearerAuthenticationSchemeOptions) { + super(); + this.schemeName = options.schemeName || 'BearerAuth'; + this.bearerFormat = options.bearerFormat || 'JWT'; + this.description = options.description; + this.middlewareOptions = { + checkToken: options.checkToken, + log: options.log, + }; + } + + public getSecurityScheme(): SecuritySchemeObject { + const scheme: SecuritySchemeObject = { + type: 'http', + scheme: 'bearer', + bearerFormat: this.bearerFormat, + }; + + if (this.description) { + scheme.description = this.description; + } + + return scheme; + } + + public getMiddleware(): RequestHandler { + return bearerAuthenticationMiddleware(this.middlewareOptions); + } +} diff --git a/src/authentication/schemes/index.ts b/src/authentication/schemes/index.ts new file mode 100644 index 0000000..50e846e --- /dev/null +++ b/src/authentication/schemes/index.ts @@ -0,0 +1 @@ +export { BearerAuthenticationScheme } from './bearer-authentication-scheme'; diff --git a/src/error/error-options.ts b/src/error/error-options.ts new file mode 100644 index 0000000..391a97c --- /dev/null +++ b/src/error/error-options.ts @@ -0,0 +1,4 @@ +export interface HttpErrorOptions { + headers?: Record; + details?: unknown; +} diff --git a/src/error/error-response.ts b/src/error/error-response.ts new file mode 100644 index 0000000..2107bef --- /dev/null +++ b/src/error/error-response.ts @@ -0,0 +1,8 @@ +import { HttpErrorOptions } from './error-options'; + +export interface ErrorResponse { + error: string; + message: string; + timestamp: string; + options: HttpErrorOptions; +} diff --git a/src/error/http-errors/400-bad-request-error.ts b/src/error/http-errors/400-bad-request-error.ts new file mode 100644 index 0000000..8bd07ae --- /dev/null +++ b/src/error/http-errors/400-bad-request-error.ts @@ -0,0 +1,18 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 400 Bad Request + * The server cannot process the request due to client error + * (e.g., malformed request syntax, invalid request message framing, + * or deceptive request routing) + */ +export class BadRequestError extends HTTPError { + constructor(message = 'Bad Request', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 400; + } +} diff --git a/src/error/http-errors/401-unauthorized-error.ts b/src/error/http-errors/401-unauthorized-error.ts new file mode 100644 index 0000000..9cbddb7 --- /dev/null +++ b/src/error/http-errors/401-unauthorized-error.ts @@ -0,0 +1,31 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface UnauthorizedErrorOptions extends HttpErrorOptions { + realm?: string; + scheme?: 'Bearer' | 'Basic' | 'Digest'; +} + +/** + * 401 Unauthorized + * Authentication is required and has failed or has not been provided + */ +export class UnauthorizedError extends HTTPError { + constructor( + message = 'Unauthorized', + options: UnauthorizedErrorOptions = {} + ) { + const realm = options.realm || 'Access to the resource'; + const scheme = options.scheme || 'Bearer'; + const headers = { + 'WWW-Authenticate': `${scheme} realm="${realm}"`, + ...options.headers, + }; + + super(message, { ...options, headers }); + } + + public override getStatusCode(): number { + return 401; + } +} diff --git a/src/error/http-errors/402-payment-required-error.ts b/src/error/http-errors/402-payment-required-error.ts new file mode 100644 index 0000000..bf41d78 --- /dev/null +++ b/src/error/http-errors/402-payment-required-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 402 Payment Required + * Reserved for future use. Originally intended for digital payment systems + */ +export class PaymentRequiredError extends HTTPError { + constructor(message = 'Payment Required', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 402; + } +} diff --git a/src/error/http-errors/403-forbidden-error.ts b/src/error/http-errors/403-forbidden-error.ts new file mode 100644 index 0000000..e297d34 --- /dev/null +++ b/src/error/http-errors/403-forbidden-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 403 Forbidden + * The client does not have access rights to the content + */ +export class ForbiddenError extends HTTPError { + constructor(message = 'Forbidden', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 403; + } +} diff --git a/src/error/http-errors/404-not-found-error.ts b/src/error/http-errors/404-not-found-error.ts new file mode 100644 index 0000000..4619ce4 --- /dev/null +++ b/src/error/http-errors/404-not-found-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 404 Not Found + * The server cannot find the requested resource + */ +export class NotFoundError extends HTTPError { + constructor(message = 'Not Found', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 404; + } +} diff --git a/src/error/http-errors/405-method-not-allowed-error.ts b/src/error/http-errors/405-method-not-allowed-error.ts new file mode 100644 index 0000000..acc2995 --- /dev/null +++ b/src/error/http-errors/405-method-not-allowed-error.ts @@ -0,0 +1,36 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface MethodNotAllowedErrorOptions extends HttpErrorOptions { + allowedMethods?: string[]; +} + +/** + * 405 Method Not Allowed + * The request method is not supported for the requested resource + */ +export class MethodNotAllowedError extends HTTPError { + constructor( + message = 'Method Not Allowed', + options: MethodNotAllowedErrorOptions = {} + ) { + const headers: Record = {}; + + if ( + !(options.headers ? options.headers['Allow'] : null) && + options.allowedMethods && + options.allowedMethods.length > 0 + ) { + headers['Allow'] = options.allowedMethods.join(', '); + } + + super(message, { + ...options, + headers: { ...headers, ...options.headers }, + }); + } + + public override getStatusCode(): number { + return 405; + } +} diff --git a/src/error/http-errors/406-not-acceptable-error.ts b/src/error/http-errors/406-not-acceptable-error.ts new file mode 100644 index 0000000..67bdd1c --- /dev/null +++ b/src/error/http-errors/406-not-acceptable-error.ts @@ -0,0 +1,17 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 406 Not Acceptable + * The server cannot produce a response matching the list of acceptable + * values defined in the request's headers + */ +export class NotAcceptableError extends HTTPError { + constructor(message = 'Not Acceptable', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 406; + } +} diff --git a/src/error/http-errors/407-proxy-authentication-required-error.ts b/src/error/http-errors/407-proxy-authentication-required-error.ts new file mode 100644 index 0000000..b1fa45e --- /dev/null +++ b/src/error/http-errors/407-proxy-authentication-required-error.ts @@ -0,0 +1,29 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface ProxyAuthenticationRequiredErrorOptions + extends HttpErrorOptions { + realm?: string; +} + +/** + * 407 Proxy Authentication Required + * Authentication is required by a proxy server + */ +export class ProxyAuthenticationRequiredError extends HTTPError { + constructor( + message = 'Proxy Authentication Required', + options: ProxyAuthenticationRequiredErrorOptions = {} + ) { + const realm = options.realm || 'Proxy'; + const headers = { + 'Proxy-Authenticate': `Basic realm="${realm}"`, + ...options.headers, + }; + super(message, { ...options, headers }); + } + + public override getStatusCode(): number { + return 407; + } +} diff --git a/src/error/http-errors/408-request-timeout-error.ts b/src/error/http-errors/408-request-timeout-error.ts new file mode 100644 index 0000000..151979c --- /dev/null +++ b/src/error/http-errors/408-request-timeout-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 408 Request Timeout + * The server timed out waiting for the request + */ +export class RequestTimeoutError extends HTTPError { + constructor(message = 'Request Timeout', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 408; + } +} diff --git a/src/error/http-errors/409-conflict-error.ts b/src/error/http-errors/409-conflict-error.ts new file mode 100644 index 0000000..22b05a2 --- /dev/null +++ b/src/error/http-errors/409-conflict-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 409 Conflict + * The request conflicts with the current state of the server + */ +export class ConflictError extends HTTPError { + constructor(message = 'Conflict', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 409; + } +} diff --git a/src/error/http-errors/410-gone-error.ts b/src/error/http-errors/410-gone-error.ts new file mode 100644 index 0000000..24d057d --- /dev/null +++ b/src/error/http-errors/410-gone-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 410 Gone + * The requested resource is no longer available and will not be available again + */ +export class GoneError extends HTTPError { + constructor(message = 'Gone', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 410; + } +} diff --git a/src/error/http-errors/411-length-required-error.ts b/src/error/http-errors/411-length-required-error.ts new file mode 100644 index 0000000..4bbf150 --- /dev/null +++ b/src/error/http-errors/411-length-required-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 411 Length Required + * The request did not specify the length of its content + */ +export class LengthRequiredError extends HTTPError { + constructor(message = 'Length Required', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 411; + } +} diff --git a/src/error/http-errors/412-precondition-failed-error.ts b/src/error/http-errors/412-precondition-failed-error.ts new file mode 100644 index 0000000..f03c017 --- /dev/null +++ b/src/error/http-errors/412-precondition-failed-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 412 Precondition Failed + * The server does not meet one of the preconditions specified by the client + */ +export class PreconditionFailedError extends HTTPError { + constructor( + message = 'Precondition Failed', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 412; + } +} diff --git a/src/error/http-errors/413-payload-too-large-error.ts b/src/error/http-errors/413-payload-too-large-error.ts new file mode 100644 index 0000000..1c86192 --- /dev/null +++ b/src/error/http-errors/413-payload-too-large-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 413 Payload Too Large + * The request entity is larger than limits defined by server + */ +export class PayloadTooLargeError extends HTTPError { + constructor(message = 'Payload Too Large', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 413; + } +} diff --git a/src/error/http-errors/414-uri-too-long-error.ts b/src/error/http-errors/414-uri-too-long-error.ts new file mode 100644 index 0000000..eace6dc --- /dev/null +++ b/src/error/http-errors/414-uri-too-long-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 414 URI Too Long + * The URI provided was too long for the server to process + */ +export class URITooLongError extends HTTPError { + constructor(message = 'URI Too Long', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 414; + } +} diff --git a/src/error/http-errors/415-unsupported-media-type-error.ts b/src/error/http-errors/415-unsupported-media-type-error.ts new file mode 100644 index 0000000..e8b748a --- /dev/null +++ b/src/error/http-errors/415-unsupported-media-type-error.ts @@ -0,0 +1,30 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface UnsupportedMediaTypeErrorOptions extends HttpErrorOptions { + acceptedTypes?: string[]; +} + +/** + * 415 Unsupported Media Type + * The media format of the requested data is not supported by the server + */ +export class UnsupportedMediaTypeError extends HTTPError { + constructor( + message = 'Unsupported Media Type', + options: UnsupportedMediaTypeErrorOptions = {} + ) { + const headers: Record = {}; + if (options.acceptedTypes && options.acceptedTypes.length > 0) { + headers['Accept'] = options.acceptedTypes.join(', '); + } + super(message, { + ...options, + headers: { ...headers, ...options.headers }, + }); + } + + public override getStatusCode(): number { + return 415; + } +} diff --git a/src/error/http-errors/416-range-not-satisfiable-error.ts b/src/error/http-errors/416-range-not-satisfiable-error.ts new file mode 100644 index 0000000..44eda51 --- /dev/null +++ b/src/error/http-errors/416-range-not-satisfiable-error.ts @@ -0,0 +1,32 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface RangeNotSatisfiableErrorOptions extends HttpErrorOptions { + contentRange?: string; +} + +/** + * 416 Range Not Satisfiable + * The range specified by the Range header field cannot be fulfilled + */ +export class RangeNotSatisfiableError extends HTTPError { + constructor( + message = 'Range Not Satisfiable', + options: RangeNotSatisfiableErrorOptions = {} + ) { + const headers: Record = {}; + + if (options.contentRange) { + headers['Content-Range'] = options.contentRange; + } + + super(message, { + ...options, + headers: { ...headers, ...options.headers }, + }); + } + + public override getStatusCode(): number { + return 416; + } +} diff --git a/src/error/http-errors/417-expectation-failed-error.ts b/src/error/http-errors/417-expectation-failed-error.ts new file mode 100644 index 0000000..4f3a65a --- /dev/null +++ b/src/error/http-errors/417-expectation-failed-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 417 Expectation Failed + * The expectation given in the Expect request header could not be met + */ +export class ExpectationFailedError extends HTTPError { + constructor( + message = 'Expectation Failed', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 417; + } +} diff --git a/src/error/http-errors/418-im-a-teapot-error.ts b/src/error/http-errors/418-im-a-teapot-error.ts new file mode 100644 index 0000000..ef9e551 --- /dev/null +++ b/src/error/http-errors/418-im-a-teapot-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 418 I'm a teapot + * The server refuses to brew coffee because it is a teapot (RFC 2324) + */ +export class ImATeapotError extends HTTPError { + constructor(message = 'I\'m a teapot', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 418; + } +} diff --git a/src/error/http-errors/421-misdirected-request-error.ts b/src/error/http-errors/421-misdirected-request-error.ts new file mode 100644 index 0000000..9b235d1 --- /dev/null +++ b/src/error/http-errors/421-misdirected-request-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 421 Misdirected Request + * The request was directed at a server that is not able to produce a response + */ +export class MisdirectedRequestError extends HTTPError { + constructor( + message = 'Misdirected Request', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 421; + } +} diff --git a/src/error/http-errors/422-unprocessable-entity-error.ts b/src/error/http-errors/422-unprocessable-entity-error.ts new file mode 100644 index 0000000..c6b4f0b --- /dev/null +++ b/src/error/http-errors/422-unprocessable-entity-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 422 Unprocessable Entity + * The request was well-formed but contains semantic errors + */ +export class UnprocessableEntityError extends HTTPError { + constructor( + message = 'Unprocessable Entity', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 422; + } +} diff --git a/src/error/http-errors/423-locked-error.ts b/src/error/http-errors/423-locked-error.ts new file mode 100644 index 0000000..bf84419 --- /dev/null +++ b/src/error/http-errors/423-locked-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 423 Locked + * The resource being accessed is locked + */ +export class LockedError extends HTTPError { + constructor(message = 'Locked', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 423; + } +} diff --git a/src/error/http-errors/424-failed-dependency-error.ts b/src/error/http-errors/424-failed-dependency-error.ts new file mode 100644 index 0000000..541375f --- /dev/null +++ b/src/error/http-errors/424-failed-dependency-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 424 Failed Dependency + * The request failed due to failure of a previous request + */ +export class FailedDependencyError extends HTTPError { + constructor(message = 'Failed Dependency', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 424; + } +} diff --git a/src/error/http-errors/425-too-early-error.ts b/src/error/http-errors/425-too-early-error.ts new file mode 100644 index 0000000..1e5a359 --- /dev/null +++ b/src/error/http-errors/425-too-early-error.ts @@ -0,0 +1,16 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 425 Too Early + * The server is unwilling to risk processing a request that might be replayed + */ +export class TooEarlyError extends HTTPError { + constructor(message = 'Too Early', options: HttpErrorOptions = {}) { + super(message, options); + } + + public override getStatusCode(): number { + return 425; + } +} diff --git a/src/error/http-errors/426-upgrade-required-error.ts b/src/error/http-errors/426-upgrade-required-error.ts new file mode 100644 index 0000000..77d6fe6 --- /dev/null +++ b/src/error/http-errors/426-upgrade-required-error.ts @@ -0,0 +1,27 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface UpgradeRequiredErrorOptions extends HttpErrorOptions { + upgradeProtocol?: string; +} + +/** + * 426 Upgrade Required + * The client should switch to a different protocol + */ +export class UpgradeRequiredError extends HTTPError { + constructor( + message = 'Upgrade Required', + options: UpgradeRequiredErrorOptions = {} + ) { + const headers = { + Upgrade: options.upgradeProtocol || 'TLS/1.0', + ...options.headers, + }; + super(message, { ...options, headers }); + } + + public override getStatusCode(): number { + return 426; + } +} diff --git a/src/error/http-errors/428-precondition-required-error.ts b/src/error/http-errors/428-precondition-required-error.ts new file mode 100644 index 0000000..2909e67 --- /dev/null +++ b/src/error/http-errors/428-precondition-required-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 428 Precondition Required + * The origin server requires the request to be conditional + */ +export class PreconditionRequiredError extends HTTPError { + constructor( + message = 'Precondition Required', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 428; + } +} diff --git a/src/error/http-errors/429-too-many-requests-error.ts b/src/error/http-errors/429-too-many-requests-error.ts new file mode 100644 index 0000000..0183064 --- /dev/null +++ b/src/error/http-errors/429-too-many-requests-error.ts @@ -0,0 +1,30 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +export interface TooManyRequestsErrorOptions extends HttpErrorOptions { + retryAfter?: number; +} + +/** + * 429 Too Many Requests + * The user has sent too many requests in a given amount of time + */ +export class TooManyRequestsError extends HTTPError { + constructor( + message = 'Too Many Requests', + options: TooManyRequestsErrorOptions = {} + ) { + const headers: Record = {}; + if (options.retryAfter) { + headers['Retry-After'] = options.retryAfter.toString(); + } + super(message, { + ...options, + headers: { ...headers, ...options.headers }, + }); + } + + public override getStatusCode(): number { + return 429; + } +} diff --git a/src/error/http-errors/431-request-header-fields-too-large-error.ts b/src/error/http-errors/431-request-header-fields-too-large-error.ts new file mode 100644 index 0000000..4848f6f --- /dev/null +++ b/src/error/http-errors/431-request-header-fields-too-large-error.ts @@ -0,0 +1,20 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 431 Request Header Fields Too Large + * The server is unwilling to process the request because its header fields + * are too large + */ +export class RequestHeaderFieldsTooLargeError extends HTTPError { + constructor( + message = 'Request Header Fields Too Large', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 431; + } +} diff --git a/src/error/http-errors/451-unavailable-for-legal-reasons-error.ts b/src/error/http-errors/451-unavailable-for-legal-reasons-error.ts new file mode 100644 index 0000000..3c78e0d --- /dev/null +++ b/src/error/http-errors/451-unavailable-for-legal-reasons-error.ts @@ -0,0 +1,19 @@ +import { HTTPError } from './http-error'; +import { HttpErrorOptions } from '../error-options'; + +/** + * 451 Unavailable For Legal Reasons + * The user requested a resource that is not available due to legal reasons + */ +export class UnavailableForLegalReasonsError extends HTTPError { + constructor( + message = 'Unavailable For Legal Reasons', + options: HttpErrorOptions = {} + ) { + super(message, options); + } + + public override getStatusCode(): number { + return 451; + } +} diff --git a/src/error/http-errors/http-error.ts b/src/error/http-errors/http-error.ts new file mode 100644 index 0000000..efe7703 --- /dev/null +++ b/src/error/http-errors/http-error.ts @@ -0,0 +1,45 @@ +import { HttpErrorOptions } from '../error-options'; + +/** + * Base HTTP Error class + * All HTTP errors extend from this class + */ +export abstract class HTTPError extends Error { + public readonly options: HttpErrorOptions = {}; + public readonly timestamp = new Date().toISOString(); + + public get headers(): Record { + return this.options.headers || {}; + } + + public get details(): unknown { + return this.options.details; + } + + constructor(message: string, options: HttpErrorOptions = {}) { + super(message); + this.name = this.constructor.name; + this.options = options; + + // Maintains proper stack trace for where our error was thrown + Error.captureStackTrace(this, this.constructor); + } + + /** + * Abstract method to get the status code for this error + * Must be implemented by subclasses + */ + public abstract getStatusCode(): number; + + /** + * Converts the error to a JSON-serializable object + */ + public getResponseJson() { + return { + error: this.name, + message: this.message, + timestamp: this.timestamp, + options: this.options, + }; + } +} diff --git a/src/error/http-errors/index.ts b/src/error/http-errors/index.ts new file mode 100644 index 0000000..2a1a3bd --- /dev/null +++ b/src/error/http-errors/index.ts @@ -0,0 +1,56 @@ +/* eslint-disable max-len */ + +// Base HTTP Error +export { HTTPError } from './http-error'; + +// 4xx Client Errors (in order) +export { BadRequestError } from './400-bad-request-error'; +export { + UnauthorizedError, + UnauthorizedErrorOptions, +} from './401-unauthorized-error'; +export { PaymentRequiredError } from './402-payment-required-error'; +export { ForbiddenError } from './403-forbidden-error'; +export { NotFoundError } from './404-not-found-error'; +export { + MethodNotAllowedError, + MethodNotAllowedErrorOptions, +} from './405-method-not-allowed-error'; +export { NotAcceptableError } from './406-not-acceptable-error'; +export { + ProxyAuthenticationRequiredError, + ProxyAuthenticationRequiredErrorOptions, +} from './407-proxy-authentication-required-error'; +export { RequestTimeoutError } from './408-request-timeout-error'; +export { ConflictError } from './409-conflict-error'; +export { GoneError } from './410-gone-error'; +export { LengthRequiredError } from './411-length-required-error'; +export { PreconditionFailedError } from './412-precondition-failed-error'; +export { PayloadTooLargeError } from './413-payload-too-large-error'; +export { URITooLongError } from './414-uri-too-long-error'; +export { + UnsupportedMediaTypeError, + UnsupportedMediaTypeErrorOptions, +} from './415-unsupported-media-type-error'; +export { + RangeNotSatisfiableError, + RangeNotSatisfiableErrorOptions, +} from './416-range-not-satisfiable-error'; +export { ExpectationFailedError } from './417-expectation-failed-error'; +export { ImATeapotError } from './418-im-a-teapot-error'; +export { MisdirectedRequestError } from './421-misdirected-request-error'; +export { UnprocessableEntityError } from './422-unprocessable-entity-error'; +export { LockedError } from './423-locked-error'; +export { FailedDependencyError } from './424-failed-dependency-error'; +export { TooEarlyError } from './425-too-early-error'; +export { + UpgradeRequiredError, + UpgradeRequiredErrorOptions, +} from './426-upgrade-required-error'; +export { PreconditionRequiredError } from './428-precondition-required-error'; +export { + TooManyRequestsError, + TooManyRequestsErrorOptions, +} from './429-too-many-requests-error'; +export { RequestHeaderFieldsTooLargeError } from './431-request-header-fields-too-large-error'; +export { UnavailableForLegalReasonsError } from './451-unavailable-for-legal-reasons-error'; diff --git a/src/error/index.ts b/src/error/index.ts new file mode 100644 index 0000000..42d8088 --- /dev/null +++ b/src/error/index.ts @@ -0,0 +1,3 @@ +export { ErrorResponse } from './error-response'; +export { HttpErrorOptions } from './error-options'; +export * from './http-errors'; diff --git a/src/index.ts b/src/index.ts index 7e180d1..065daf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,4 @@ -export const a = true; +export * from './router'; +export * from './server'; +export * from './error'; +export * from './authentication'; diff --git a/src/log/index.ts b/src/log/index.ts new file mode 100644 index 0000000..3fcca53 --- /dev/null +++ b/src/log/index.ts @@ -0,0 +1 @@ +export { LogInterface } from './log-interface'; diff --git a/src/log/log-interface.ts b/src/log/log-interface.ts new file mode 100644 index 0000000..8b78358 --- /dev/null +++ b/src/log/log-interface.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface LogInterface { + fatal: (...args: any[]) => void; + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + info: (...args: any[]) => void; + debug: (...args: any[]) => void; +} diff --git a/src/oas/index.ts b/src/oas/index.ts new file mode 100644 index 0000000..6b57ef1 --- /dev/null +++ b/src/oas/index.ts @@ -0,0 +1,2 @@ +export { OasRestServerConverter } from './oas-rest-server-converter'; +export { OasEndpointConverter } from './oas-endpoint-converter'; diff --git a/src/oas/oas-endpoint-component-converter.ts b/src/oas/oas-endpoint-component-converter.ts new file mode 100644 index 0000000..8ecc5f1 --- /dev/null +++ b/src/oas/oas-endpoint-component-converter.ts @@ -0,0 +1,48 @@ +import { ObjectSanitizer } from 'valsan'; +import { SchemaObject } from 'auto-oas/oas/v3.1'; + +import { BaseApiEndpoint } from '../router'; +import { buildObjectSchema } from 'auto-oas/auto-oas'; + +/** + * Builds OpenAPI component schemas for an endpoint from valsans + */ +export class OasEndpointComponentConverter { + protected schemas: Record = {}; + + // Return a map of schema name -> SchemaObject for the endpoint + public getSchemas(endpoint: BaseApiEndpoint): Record { + const bodySanitizer = endpoint.body; + + if (bodySanitizer) { + this.addSchema({ + name: `${endpoint.name}Body`, + sanitizer: bodySanitizer, + example: endpoint.bodyExample, + }); + } + + return this.schemas; + } + + protected addSchema({ + name, + sanitizer, + example, + }: { + name: string; + sanitizer: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + example?: any; + }) { + const schema = buildObjectSchema(sanitizer); + + if (schema) { + if (example) { + schema.example = example; + } + + this.schemas[name] = schema; + } + } +} diff --git a/src/oas/oas-endpoint-converter.ts b/src/oas/oas-endpoint-converter.ts new file mode 100644 index 0000000..62b2535 --- /dev/null +++ b/src/oas/oas-endpoint-converter.ts @@ -0,0 +1,122 @@ +import { ObjectSanitizer } from 'valsan'; +import { + PathItemObject, + ParameterObject, + RequestBodyObject, +} from 'auto-oas/oas/v3.1'; + +import { BaseApiEndpoint } from '../router'; +import { buildParameter } from 'auto-oas/auto-oas'; +import { AuthenticationScheme } from '../authentication/authentication-scheme'; + +/** + * Returns OpenAPI path item for an endpoint, + * based on validation. + */ +export class OasEndpointConverter { + protected parameters: ParameterObject[] = []; + + public getOpenApiPath( + endpoint: BaseApiEndpoint, + authentication?: AuthenticationScheme | null + ): PathItemObject { + this.addParams({ + location: 'path', + sanitizer: endpoint.params, + example: endpoint.paramsExample, + }); + + this.addParams({ + location: 'query', + sanitizer: endpoint.query, + example: endpoint.queryExample, + }); + + this.addParams({ + location: 'header', + sanitizer: endpoint.headers, + example: endpoint.headersExample, + }); + + let requestBody: RequestBodyObject | undefined = undefined; + const bodySanitizer = endpoint.body; + + if (bodySanitizer && bodySanitizer.schema) { + requestBody = { + required: true, + content: { + 'application/json': { + schema: { + $ref: + '#/components/schemas/' + + endpoint.getName() + + 'Body', + }, + }, + }, + }; + } + + // Use statusCode for response + const status = endpoint.statusCode; + + const responses = { + [status]: { description: 'Success' }, + }; + + const errors = endpoint.getErrors(); + for (const error in errors) { + const httpError = errors[error]; + responses[httpError.getStatusCode()] = { + description: httpError.message || 'Error response', + }; + } + + // Add security requirement if authentication is present + // null means explicitly public (no auth), + // undefined inherited from parent + const security = + authentication !== undefined && authentication !== null + ? [authentication.getSecurityRequirement()] + : authentication === null + ? [] + : undefined; + + return { + [endpoint.method]: { + summary: endpoint.getName(), + description: endpoint.description, + tags: [endpoint.getTag()], + parameters: + this.parameters.length > 0 ? this.parameters : undefined, + requestBody, + responses, + security, + }, + }; + } + + protected addParams({ + location, + sanitizer, + example, + }: { + location: 'path' | 'query' | 'header'; + sanitizer?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + example?: any; + }) { + if (sanitizer && sanitizer.schema) { + for (const key in sanitizer.schema) { + const valSan = sanitizer.schema[key]; + const param = buildParameter(key, valSan, location); + + if (example && example[key] !== undefined) { + param.example = example[key]; + } + + this.parameters.push(param); + } + } + } +} diff --git a/src/oas/oas-rest-server-converter.ts b/src/oas/oas-rest-server-converter.ts new file mode 100644 index 0000000..edac932 --- /dev/null +++ b/src/oas/oas-rest-server-converter.ts @@ -0,0 +1,111 @@ +import { + ComponentsObject, + InfoObject, + OpenAPIObject, + PathItemObject, + SecuritySchemeObject, + TagObject, +} from 'auto-oas/oas/v3.1'; +import { RestServer } from '../server'; +import { BaseApiEndpoint, BaseApiRouter } from '../router'; +import { OasEndpointConverter } from './oas-endpoint-converter'; +// eslint-disable-next-line max-len +import { OasEndpointComponentConverter } from './oas-endpoint-component-converter'; + +export class OasRestServerConverter { + protected paths: Record = {}; + protected components: ComponentsObject = {}; + protected tags: TagObject[] = []; + protected securitySchemes: Record = {}; + + public async getOpenApiSpec(server: RestServer): Promise { + await this.convertRouter(server.routerInstance); + + // Add collected security schemes to components + if (Object.keys(this.securitySchemes).length > 0) { + if (!this.components.securitySchemes) { + this.components.securitySchemes = {}; + } + Object.assign( + this.components.securitySchemes, + this.securitySchemes + ); + } + + // Set global security if server has authentication + const globalSecurity = []; + if (server.authentication) { + globalSecurity.push(server.authentication.getSecurityRequirement()); + } + + return { + openapi: '3.1.0', + info: await this.getOpenApiInfo(server), + servers: [], + paths: this.paths, + components: this.components, + security: globalSecurity, + tags: this.tags, + }; + } + + public async getOpenApiInfo(server: RestServer): Promise { + return { + title: server.name, + version: server.version, + description: server.description, + }; + } + + public async convertRouter(router: BaseApiRouter) { + for (const route of router.registeredRoutes) { + if (route instanceof BaseApiEndpoint) { + await this.convertEndpoint(route); + } + else if (route instanceof BaseApiRouter) { + await this.convertRouter(route); + } + } + } + + public async convertEndpoint(endpoint: BaseApiEndpoint) { + // Get effective authentication for this endpoint + const effectiveAuth = endpoint.getEffectiveAuthentication(); + + // Collect authentication scheme if present and not null + // (null = public) + if (effectiveAuth) { + const schemeName = effectiveAuth.schemeName; + if (!this.securitySchemes[schemeName]) { + this.securitySchemes[schemeName] = + effectiveAuth.getSecurityScheme(); + } + } + + const endpointConverter = new OasEndpointConverter(); + const pathItem = endpointConverter.getOpenApiPath( + endpoint, + effectiveAuth + ); + const path = (endpoint.fullPath || '/').replace( + /:([a-zA-Z0-9_]+)/g, + '{$1}' + ); + + if (this.paths[path] === undefined) { + this.paths[path] = {}; + } + + Object.assign(this.paths[path], pathItem); + + const endpointComponentConverter = new OasEndpointComponentConverter(); + const endpointComponents = + endpointComponentConverter.getSchemas(endpoint); + + if (!this.components.schemas) { + this.components.schemas = {}; + } + + Object.assign(this.components.schemas, endpointComponents); + } +} diff --git a/src/oas/routes.ts b/src/oas/routes.ts new file mode 100644 index 0000000..5e5a597 --- /dev/null +++ b/src/oas/routes.ts @@ -0,0 +1,35 @@ +import { Express } from 'express'; +import swaggerUi from 'swagger-ui-express'; + +import { RestServer } from '../server'; +import { OasRestServerConverter } from './oas-rest-server-converter'; + +export async function oasRoutes({ + router, + server, + oasPath, + swaggerPath, +}: { + router: Express; + server: RestServer; + oasPath?: string; + swaggerPath?: string; +}) { + oasPath = oasPath || '/openapi.json'; + swaggerPath = swaggerPath || '/docs'; + const converter = new OasRestServerConverter(); + const spec = await converter.getOpenApiSpec(server); + + // Expose OpenAPI spec + router.get(oasPath, async (req, res) => { + res.json(spec); + }); + + // Serve Swagger UI static assets + router.use(swaggerPath, swaggerUi.serve); + + // Serve Swagger UI HTML + router.get(swaggerPath, async (req, res, next) => { + return swaggerUi.setup(spec)(req, res, next); + }); +} diff --git a/src/router/base.ts b/src/router/base.ts new file mode 100644 index 0000000..bf37b81 --- /dev/null +++ b/src/router/base.ts @@ -0,0 +1,77 @@ +import { Router as ExpressRouter, RequestHandler } from 'express'; +import { AuthenticationScheme } from '../authentication/authentication-scheme'; + +export abstract class BaseApiRoute { + public path: string; + public fullPath: string; + public name: string; + public description?: string; + + /** + * Optional array of Express middleware to apply + */ + public middleware: RequestHandler[] = []; + + /** + * Authentication scheme for this route + * Set to null to explicitly make a route public (no authentication) + * If undefined, inherits from parent router or server + */ + public authentication?: AuthenticationScheme | null; + + /** + * Parent route in the hierarchy (used for authentication cascading) + * @internal + */ + // eslint-disable-next-line no-use-before-define + public parentRoute?: BaseApiRoute; + + public abstract register( + parentRouter: ExpressRouter, + parentPath: string + ): Promise; + + public getName(): string { + return this.name || this.constructor.name; + } + + public registerRoutePath(parentPath: string): void { + if (!this.path) { + this.path = ''; + } + else if (!this.path.startsWith('/')) { + this.path = '/' + this.path; + } + + if (parentPath.endsWith('/')) { + parentPath = parentPath.slice(0, -1); + } + + this.fullPath = parentPath + this.path; + } + + /** + * Get the effective authentication scheme for this route + * Implements cascading logic: endpoint → router → server + * @returns The authentication scheme to use, or undefined if public + */ + public getEffectiveAuthentication(): + | AuthenticationScheme + | undefined + | null { + // If explicitly set (including null for public routes), use it + if (this.authentication !== undefined) { + return this.authentication; + } + + // Otherwise, cascade to parent + if (this.parentRoute) { + return this.parentRoute.getEffectiveAuthentication(); + } + + // No authentication at any level + return undefined; + } +} + +export type ApiRoute = { new (): BaseApiRoute }; diff --git a/src/router/endpoint.ts b/src/router/endpoint.ts new file mode 100644 index 0000000..1c14627 --- /dev/null +++ b/src/router/endpoint.ts @@ -0,0 +1,121 @@ +import { + Router as ExpressRouter, + Request as ExpressRequest, + Response as ExpressResponse, + NextFunction as ExpressNextFunction, +} from 'express'; + +import { ObjectSanitizer } from 'valsan/object-sanitizer'; + +import { BaseApiRoute } from './base'; +import { validateRequest } from './validation-middleware'; +import { BadRequestError, HTTPError, UnprocessableEntityError } from '../error'; + +export type ApiRequest = ExpressRequest; +export type ApiResponse = ExpressResponse; +export type ApiNextFunction = ExpressNextFunction; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ApiResponseData = { [key: string]: any } | { [key: string]: any }[]; + +export enum EndpointMethod { + GET = 'get', + POST = 'post', + PUT = 'put', + DELETE = 'delete', + PATCH = 'patch', +} + +export abstract class BaseApiEndpoint extends BaseApiRoute { + override path = ''; + public tag?: string; + public method: EndpointMethod = EndpointMethod.GET; + public statusCode: number = 200; + + public body?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public bodyExample?: any; + + public query?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public queryExample?: any; + + public params?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public paramsExample?: any; + + public headers?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public headersExample?: any; + + public getErrors(): { [key: string]: HTTPError } { + return { + parse: new BadRequestError(), + validation: new UnprocessableEntityError(), + }; + } + + public getTag(): string { + // Determine tag from router if available, fallback to class name + return this.tag || this.constructor.name.replace(/Endpoint$/, ''); + } + + public override async register( + parentRouter: ExpressRouter, + parentPath: string + ): Promise { + if (!this.name) { + this.name = this.constructor.name.replace(/Endpoint$/, ''); + } + + this.registerRoutePath(parentPath); + + // Collect middleware including authentication + const endpointMiddleware = [...this.middleware]; + + // Add authentication middleware based on effective authentication + // This implements the cascading: endpoint → router → server + const effectiveAuth = this.getEffectiveAuthentication(); + if (effectiveAuth) { + endpointMiddleware.push(effectiveAuth.getMiddleware()); + } + + // Register with middleware (if any) before the handler + if (endpointMiddleware.length > 0) { + parentRouter[this.method]( + this.path, + ...endpointMiddleware, + this.handleWrapper.bind(this) + ); + } + else { + parentRouter[this.method](this.path, this.handleWrapper.bind(this)); + } + } + + public async handleWrapper( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ): Promise { + await this.checkRequest(request); + const data = await this.handle(request, response, next); + + response.status(this.statusCode); + return response.send(data); + } + + public abstract handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ): Promise; + + public async checkRequest(request: ApiRequest): Promise { + // Validate request parts (body, query, params, headers) + // if sanitizers are present + await validateRequest(this, request); + } +} + +export type ApiEndpoint = { new (): BaseApiEndpoint }; diff --git a/src/router/endpoints/delete-endpoint.ts b/src/router/endpoints/delete-endpoint.ts new file mode 100644 index 0000000..c13c331 --- /dev/null +++ b/src/router/endpoints/delete-endpoint.ts @@ -0,0 +1,6 @@ +import { BaseApiEndpoint, EndpointMethod } from '../endpoint'; + +export abstract class DeleteEndpoint extends BaseApiEndpoint { + override method = EndpointMethod.DELETE; + override statusCode = 204; +} diff --git a/src/router/endpoints/get-endpoint.ts b/src/router/endpoints/get-endpoint.ts new file mode 100644 index 0000000..d7f4b42 --- /dev/null +++ b/src/router/endpoints/get-endpoint.ts @@ -0,0 +1,6 @@ +import { BaseApiEndpoint, EndpointMethod } from '../endpoint'; + +export abstract class GetEndpoint extends BaseApiEndpoint { + override method = EndpointMethod.GET; + override statusCode = 200; +} diff --git a/src/router/endpoints/health-check-endpoint.ts b/src/router/endpoints/health-check-endpoint.ts new file mode 100644 index 0000000..a761bb1 --- /dev/null +++ b/src/router/endpoints/health-check-endpoint.ts @@ -0,0 +1,32 @@ +import { ApiRequest, ApiResponse } from '../endpoint'; +import { GetEndpoint } from './get-endpoint'; + +export class HealthCheckEndpoint extends GetEndpoint { + override path = '/health'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_request: ApiRequest, _response: ApiResponse) { + return { + status: await this.getStatus(), + timestamp: await this.getTimestamp(), + uptime: await this.getUptime(), + environment: await this.getEnvironment(), + }; + } + + public async getStatus(): Promise { + return 'ok'; + } + + public async getTimestamp(): Promise { + return new Date().toISOString(); + } + + public async getUptime(): Promise { + return process.uptime(); + } + + public async getEnvironment(): Promise { + return process.env['NODE_ENV'] || 'development'; + } +} diff --git a/src/router/endpoints/index.ts b/src/router/endpoints/index.ts new file mode 100644 index 0000000..b2fb7a8 --- /dev/null +++ b/src/router/endpoints/index.ts @@ -0,0 +1,6 @@ +export { GetEndpoint } from './get-endpoint'; +export { PostEndpoint } from './post-endpoint'; +export { PutEndpoint } from './put-endpoint'; +export { DeleteEndpoint } from './delete-endpoint'; +export { PatchEndpoint } from './patch-endpoint'; +export { HealthCheckEndpoint } from './health-check-endpoint'; diff --git a/src/router/endpoints/patch-endpoint.ts b/src/router/endpoints/patch-endpoint.ts new file mode 100644 index 0000000..b6921b6 --- /dev/null +++ b/src/router/endpoints/patch-endpoint.ts @@ -0,0 +1,6 @@ +import { BaseApiEndpoint, EndpointMethod } from '../endpoint'; + +export abstract class PatchEndpoint extends BaseApiEndpoint { + override method = EndpointMethod.PATCH; + override statusCode = 200; +} diff --git a/src/router/endpoints/post-endpoint.ts b/src/router/endpoints/post-endpoint.ts new file mode 100644 index 0000000..6d3a1f3 --- /dev/null +++ b/src/router/endpoints/post-endpoint.ts @@ -0,0 +1,6 @@ +import { BaseApiEndpoint, EndpointMethod } from '../endpoint'; + +export abstract class PostEndpoint extends BaseApiEndpoint { + override method = EndpointMethod.POST; + override statusCode = 201; +} diff --git a/src/router/endpoints/put-endpoint.ts b/src/router/endpoints/put-endpoint.ts new file mode 100644 index 0000000..82b123f --- /dev/null +++ b/src/router/endpoints/put-endpoint.ts @@ -0,0 +1,6 @@ +import { BaseApiEndpoint, EndpointMethod } from '../endpoint'; + +export abstract class PutEndpoint extends BaseApiEndpoint { + override method = EndpointMethod.PUT; + override statusCode = 200; +} diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..805e6ff --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,18 @@ +export { + ApiEndpoint, + ApiRequest, + ApiResponse, + ApiNextFunction, + BaseApiEndpoint, +} from './endpoint'; + +export { ApiRouter, BaseApiRouter } from './router'; + +export { + GetEndpoint, + PostEndpoint, + PutEndpoint, + DeleteEndpoint, + PatchEndpoint, + HealthCheckEndpoint, +} from './endpoints'; diff --git a/src/router/router.ts b/src/router/router.ts new file mode 100644 index 0000000..8a398ae --- /dev/null +++ b/src/router/router.ts @@ -0,0 +1,85 @@ +import { Router as ExpressRouter } from 'express'; +import { ApiRoute, BaseApiRoute } from './base'; +import { BaseApiEndpoint, EndpointMethod } from './endpoint'; + +export abstract class BaseApiRouter extends BaseApiRoute { + protected router: ExpressRouter; + + public registeredRoutes: BaseApiRoute[] = []; + protected abstract routes(): Promise; + protected routeInstances: { [key: string]: BaseApiRoute } = {}; + + public getTag(): string { + return this.name || this.constructor.name.replace(/Router$/, ''); + } + + public async register( + parent: ExpressRouter, + parentPath: string + ): Promise { + this.router = ExpressRouter(); + this.registerRoutePath(parentPath); + + // Collect non-authentication middleware + // Authentication is handled at the endpoint level to allow overrides + const middlewareWithoutAuth = [...this.middleware]; + + // Register router with non-auth middleware if any + if (middlewareWithoutAuth.length > 0) { + parent.use(this.path, ...middlewareWithoutAuth, this.router); + } + else { + parent.use(this.path, this.router); + } + + const routes = await this.routes(); + + // Track which paths have which methods + const pathMethods = new Map>(); + const tag = this.getTag(); + + // First pass: register all endpoints and track their methods + for (const route of routes) { + const instance = new route(); + + // Set parent relationship for authentication cascading + instance.parentRoute = this; + + if (instance instanceof BaseApiEndpoint) { + instance.tag = tag; + } + + await instance.register(this.router, this.fullPath); + + this.registeredRoutes.push(instance); + + // Track endpoint methods for 405 handling + if (instance instanceof BaseApiEndpoint) { + const path = instance.path; + + if (!pathMethods.has(path)) { + pathMethods.set(path, new Set()); + } + + pathMethods.get(path)!.add(instance.method); + } + } + + // Second pass: add 405 handlers for unsupported methods + const allMethods = Object.values(EndpointMethod); + pathMethods.forEach((supportedMethods, path) => { + allMethods.forEach((method) => { + if (!supportedMethods.has(method)) { + this.router[method](path, (req, res) => { + res.status(405).send({ + error: 'Method Not Allowed', + message: `Method ${req.method} not allowed`, + }); + }); + } + }); + }); + } +} + +export type ApiRouter = { new (): BaseApiRouter }; diff --git a/src/router/validation-middleware.ts b/src/router/validation-middleware.ts new file mode 100644 index 0000000..cf4dc80 --- /dev/null +++ b/src/router/validation-middleware.ts @@ -0,0 +1,51 @@ +import { ObjectSanitizer } from 'valsan'; +import { ApiRequest, BaseApiEndpoint } from './endpoint'; +import { UnprocessableEntityError } from '../error'; + +/** + * Runs a valsan ObjectSanitizer on a value, + * throws with error details if validation fails. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function runSanitizer(sanitizer: ObjectSanitizer, value: any) { + const result = await sanitizer.run(value); + + if (!result.success) { + throw new UnprocessableEntityError('Validation failed', { + details: result.errors, + }); + } + + return result.data; +} + +/** + * Validates and sanitizes request parts + * (body, query, params, headers) if the + * endpoint defines a ObjectSanitizers + */ +export async function validateRequest( + endpoint: BaseApiEndpoint, + request: ApiRequest +): Promise { + for (const part of ['body', 'query', 'params', 'headers'] as const) { + const sanitizer = endpoint[part]; + + if (!sanitizer) { + continue; + } + + const sanitized = await runSanitizer(sanitizer, request[part]); + + if (part === 'query') { + // Mutate the query object instead of reassigning + Object.keys(request.query).forEach((key) => { + delete request.query[key]; + }); + Object.assign(request.query, sanitized); + } + else { + request[part] = sanitized; + } + } +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..dbc0b62 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,2 @@ +export { RestServer } from './server'; +export { RestServerOptions, defaultRestServerOptions } from './server-options'; diff --git a/src/server/server-options.ts b/src/server/server-options.ts new file mode 100644 index 0000000..3d932cc --- /dev/null +++ b/src/server/server-options.ts @@ -0,0 +1,73 @@ +import { LogInterface } from '../log'; +import { AuthenticationScheme } from '../authentication/authentication-scheme'; + +export interface SecurityHeadersOptions { + /** + * Disable the X-Powered-By header to prevent server fingerprinting + * @default true + */ + disableXPoweredBy: boolean; + + /** + * Set X-Content-Type-Options: nosniff to prevent MIME type sniffing + * @default true + */ + noSniff: boolean; + + /** + * Set X-Frame-Options header to prevent clickjacking + * Options: 'DENY', 'SAMEORIGIN', or false to disable + * @default 'DENY' + */ + frameOptions: 'DENY' | 'SAMEORIGIN' | false; + + /** + * Set X-XSS-Protection header for legacy browser protection + * @default true + */ + xssProtection: boolean; + + /** + * Set Strict-Transport-Security header (only use with HTTPS) + * Provide max-age value or false to disable + * @default false + */ + hsts: number | false; +} + +export interface RestServerOptions { + port: number; + maxPayloadSizeMB: number; + maxUrlEncodedSizeMB: number; + log: LogInterface; + securityHeaders: SecurityHeadersOptions; + swaggerEnabled?: boolean; + + /** + * Server-level authentication scheme + * This will be applied to all routers and endpoints unless overridden + */ + authentication?: AuthenticationScheme; +} + +export const defaultSecurityHeadersOptions: SecurityHeadersOptions = { + disableXPoweredBy: true, + noSniff: true, + frameOptions: 'DENY', + xssProtection: true, + hsts: false, // Disabled by default, enable when using HTTPS +}; + +export const defaultRestServerOptions: RestServerOptions = { + port: 5000, + maxPayloadSizeMB: 10, + maxUrlEncodedSizeMB: 1, + log: { + ...console, + // eslint-disable-next-line no-console + fatal: console.error, + // eslint-disable-next-line no-console + info: console.log, + }, + securityHeaders: defaultSecurityHeadersOptions, +}; diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..ff8dc49 --- /dev/null +++ b/src/server/server.ts @@ -0,0 +1,231 @@ +import express from 'express'; +import cors from 'cors'; +import http from 'http'; + +import { defaultRestServerOptions, RestServerOptions } from './server-options'; +import { ApiRouter, BaseApiRouter } from '../router'; +import { HTTPError, NotFoundError, ErrorResponse } from '../error'; +import { AuthenticationScheme } from '../authentication/authentication-scheme'; + +import { LogInterface } from '../log'; +import { oasRoutes } from '../oas/routes'; + +export abstract class RestServer { + public name = 'RestServer'; + public description = 'REST API Server'; + public version = ''; + + public router: ApiRouter; + public routerInstance: BaseApiRouter; + + public readonly port: number; + public readonly maxPayloadSizeMB: number; + public readonly maxUrlEncodedSizeMB: number; + public readonly securityHeaders: RestServerOptions['securityHeaders']; + public swaggerEnabled = false; + public authentication?: AuthenticationScheme; + + protected app: express.Express; + protected listener: http.Server; + protected log: LogInterface; + + constructor(options: Partial) { + options = { ...defaultRestServerOptions, ...options }; + + this.port = options.port!; + this.maxPayloadSizeMB = options.maxPayloadSizeMB!; + this.maxUrlEncodedSizeMB = options.maxUrlEncodedSizeMB!; + this.log = options.log!; + this.securityHeaders = { + ...defaultRestServerOptions.securityHeaders, + ...options.securityHeaders, + }; + this.swaggerEnabled = options.swaggerEnabled ?? false; + this.authentication = options.authentication; + } + + public async start() { + await this.setupExpress(); + await this.createRouter(); + await this.registerAuthentication(); + await this.registerRoutes(); + await this.registerSwagger(); + await this.setup404Handler(); + await this.setupErrorHandler(); + await this.startListening(); + } + + public async stop(): Promise { + if (this.listener) { + this.listener.close(); + } + } + + protected async createRouter(): Promise { + this.routerInstance = new this.router(); + } + + protected async registerAuthentication(): Promise { + // Set server authentication on router instance if not already set + if ( + this.authentication && + this.routerInstance.authentication === undefined + ) { + this.routerInstance.authentication = this.authentication; + } + } + + protected async registerRoutes(): Promise { + await this.routerInstance.register(this.app, ''); + } + + protected async registerSwagger(): Promise { + if (this.swaggerEnabled) { + await oasRoutes({ + router: this.app, + server: this, + }); + } + } + + protected async setupExpress(): Promise { + this.app = express(); + await this.setupSecurityHeaders(); + await this.setupExpressCors(); + await this.setupExpressJson(); + await this.setupExpressUrlEncoded(); + } + + protected async setupSecurityHeaders(): Promise { + // Disable X-Powered-By header to prevent server fingerprinting + if (this.securityHeaders.disableXPoweredBy) { + this.app.disable('x-powered-by'); + } + + // Add security headers middleware + this.app.use( + ( + request: express.Request, + response: express.Response, + next: express.NextFunction + ) => { + // X-Content-Type-Options: nosniff + if (this.securityHeaders.noSniff) { + response.setHeader('X-Content-Type-Options', 'nosniff'); + } + + // X-Frame-Options + if (this.securityHeaders.frameOptions) { + response.setHeader( + 'X-Frame-Options', + this.securityHeaders.frameOptions + ); + } + + // X-XSS-Protection + if (this.securityHeaders.xssProtection) { + response.setHeader('X-XSS-Protection', '1; mode=block'); + } + + // Strict-Transport-Security (HSTS) + if (this.securityHeaders.hsts) { + const hstsValue = + `max-age=${this.securityHeaders.hsts}; ` + + 'includeSubDomains'; + response.setHeader('Strict-Transport-Security', hstsValue); + } + + next(); + } + ); + } + + protected async setupExpressCors(): Promise { + this.app.use(cors()); + } + + protected async setupExpressJson(): Promise { + this.app.use(express.json({ limit: `${this.maxPayloadSizeMB}mb` })); + } + + protected async setupExpressUrlEncoded(): Promise { + this.app.use( + express.urlencoded({ + extended: true, + limit: `${this.maxUrlEncodedSizeMB}mb`, + }) + ); + } + + protected async setup404Handler(): Promise { + this.app.use(() => { + throw new NotFoundError('The requested endpoint does not exist.'); + }); + } + + protected async setupErrorHandler(): Promise { + this.app.use( + ( + error: string | Error, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: express.Request, + response: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + next: express.NextFunction + ) => { + // Handle HTTPError instances + if (error instanceof HTTPError) { + // Set custom headers if provided + Object.entries(error.headers).forEach(([key, value]) => { + response.setHeader(key, value); + }); + + return response + .status(error.getStatusCode()) + .json(error.getResponseJson()); + } + + if ( + error instanceof Error && + 'expose' in error && + (error as { expose: boolean }).expose === true + ) { + let statusCode = 500; + + if ('statusCode' in error) { + statusCode = (error as { statusCode: number }) + .statusCode; + } + + return response.status(statusCode).json({ + error: error.name, + message: error.message, + }); + } + + // Handle generic errors + this.log?.error('Unhandled error:', error); + + return response.status(500).json({ + error: 'InternalServerError', + message: 'Internal server error', + timestamp: new Date().toISOString(), + options: {}, + }); + } + ); + } + + protected async startListening(): Promise { + await new Promise((accept, reject) => { + this.listener = this.app.listen(this.port, (error?: Error) => { + if (error) { + reject(error); + return; + } + + accept(); + }); + }); + } +} diff --git a/test/auth-headers.ts b/test/auth-headers.ts new file mode 100644 index 0000000..1c0d36d --- /dev/null +++ b/test/auth-headers.ts @@ -0,0 +1,6 @@ +import { env } from './env'; + +export const AUTH_HEADERS = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.BEARER_TOKEN}`, +}; diff --git a/test/env.ts b/test/env.ts new file mode 100644 index 0000000..346054a --- /dev/null +++ b/test/env.ts @@ -0,0 +1,9 @@ +import { AppConfig, configure } from 'ts-appconfig'; + +export class TestEnv extends AppConfig { + readonly API_PORT: number = 4000; + readonly API_URL: string = 'http://localhost:${API_PORT}'; + readonly BEARER_TOKEN: string = 'asdf'; +} + +export const env: TestEnv = configure(TestEnv); diff --git a/test/spec/api/api.spec.ts b/test/spec/api/api.spec.ts new file mode 100644 index 0000000..e07e793 --- /dev/null +++ b/test/spec/api/api.spec.ts @@ -0,0 +1,153 @@ +import 'jasmine'; +import { MockConsole } from 'ts-jasmine-spies'; + +import { env } from '../../env'; +import { server } from '../../test-server'; +import { ErrorResponse } from '../../../src/error'; +import { defaultRestServerOptions } from '../../../src'; + +describe('API Server', function () { + const baseUrl = env.API_URL + '/test'; + let mockConsole: MockConsole; + + beforeEach(() => { + mockConsole = new MockConsole(); + }); + + describe('Server Status', function () { + it('should be running and accessible', async function () { + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + }); + + it('should respond within reasonable time', async function () { + const startTime = Date.now(); + const response = await fetch(baseUrl); + const endTime = Date.now(); + const responseTime = endTime - startTime; + + expect(response.status).toBe(200); + // Should respond within 1 second + expect(responseTime).toBeLessThan(1000); + }); + + it('should serve JSON responses', async function () { + const response = await fetch(baseUrl); + const contentType = response.headers.get('content-type'); + expect(contentType).toContain('application/json'); + }); + }); + + describe('Configuration Options', function () { + it('should export default REST server options', function () { + expect(defaultRestServerOptions).toBeDefined(); + expect(defaultRestServerOptions.port).toBeDefined(); + expect(defaultRestServerOptions.maxPayloadSizeMB).toBeDefined(); + expect(defaultRestServerOptions.maxUrlEncodedSizeMB).toBeDefined(); + expect(defaultRestServerOptions.log).toBeDefined(); + }); + }); + + describe('CORS Support', function () { + it('should include CORS headers', async function () { + const response = await fetch(baseUrl); + const corsHeader = response.headers.get( + 'access-control-allow-origin' + ); + + // CORS should be enabled (could be * or specific origin) + expect(corsHeader).toBeDefined(); + }); + + it('should handle preflight OPTIONS requests', async function () { + const response = await fetch(baseUrl, { + method: 'OPTIONS', + }); + + // Should handle OPTIONS for CORS preflight + expect([200, 204]).toContain(response.status); + }); + }); + + describe('Request/Response Format', function () { + it('should handle JSON request bodies', async function () { + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + expect(response.status).toBe(200); + }); + + it('should reject invalid HTTP methods', async function () { + const invalidMethods = ['POST', 'PUT', 'DELETE', 'PATCH']; + + for (const method of invalidMethods) { + const response = await fetch(baseUrl, { + method: method, + }); + + // Should return 404 or 405 for unsupported methods + expect([404, 405]).toContain(response.status); + } + }); + }); + + describe('Startup errors', function () { + it('should throw error for port already in use', async function () { + await expectAsync(server.start()).toBeRejectedWithError( + 'listen EADDRINUSE: address already in use :::' + env.API_PORT + ); + }); + }); + + describe('Error Handling', function () { + it('should return 404 for non-existent endpoints', async function () { + const response = await fetch(`${baseUrl}/non-existent-endpoint`); + expect(response.status).toBe(404); + + const data = (await response.json()) as ErrorResponse; + expect(data.error).toBe('NotFoundError'); + expect(data.message).toBe('The requested endpoint does not exist.'); + expect(data.timestamp).toBeDefined(); + expect(data.options.details).toBeUndefined(); + }); + + it('should return JSON error responses', async function () { + const response = await fetch(`${baseUrl}/non-existent-endpoint`); + const contentType = response.headers.get('content-type'); + expect(contentType).toContain('application/json'); + }); + + it('should handle server errors gracefully', async function () { + const response = await fetch(`${baseUrl}/error-test`); + expect(response.status).toBe(500); + + const data = (await response.json()) as ErrorResponse; + expect(data.error).toBe('InternalServerError'); + expect(data.message).toBe('Internal server error'); + expect(data.timestamp).toBeDefined(); + expect(data.options).toBeDefined(); + + mockConsole.expectStderrContains('Error: Test error'); + }); + + it('can set headers from HTTPError instances', async function () { + const response = await fetch(`${baseUrl}/http-error-with-headers`); + expect(response.status).toBe(401); + + // Check that custom headers are set + expect(response.headers.get('WWW-Authenticate')).toContain( + 'TestRealm' + ); + expect(response.headers.get('X-Custom-Header')).toBe('test-value'); + + const data = (await response.json()) as ErrorResponse; + expect(data.error).toBe('UnauthorizedError'); + expect(data.message).toBe('Custom auth error'); + }); + }); +}); diff --git a/test/spec/api/openapi-server.ts b/test/spec/api/openapi-server.ts new file mode 100644 index 0000000..26fa772 --- /dev/null +++ b/test/spec/api/openapi-server.ts @@ -0,0 +1,352 @@ +import { ObjectSanitizer } from 'valsan/object-sanitizer'; + +import { NotFoundError, RestServer } from '../../../src'; +import { + BaseApiRouter, + PostEndpoint, + ApiRequest, + DeleteEndpoint, + PatchEndpoint, + PutEndpoint, + GetEndpoint, +} from '../../../src/router'; + +import { + ComposedValSan, + EmailValidator, + IntegerValidator, + LengthValidator, +} from 'valsan'; +import { StringToNumberValSan } from 'valsan'; +import { RangeValidator } from 'valsan'; + +const idValidator = new LengthValidator({ minLength: 1, maxLength: 50 }); +const nameValidator = new ComposedValSan([ + new LengthValidator({ minLength: 2, maxLength: 50 }), +]); +const emailValidator = new ComposedValSan([new EmailValidator()]); + +/** + * Users + */ +const usersDb: Record = {}; + +const userNotFoundError = new NotFoundError('User not found'); + +class CreateUserEndpoint extends PostEndpoint { + override path = '/users'; + override description = 'Creates a new user'; + + override body = new ObjectSanitizer({ + name: nameValidator, + email: emailValidator, + }); + + override bodyExample = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + + async handle(request: ApiRequest) { + const id = Math.random().toString(36).slice(2); + usersDb[id] = { id, ...request.body }; + + return usersDb[`${id}`]; + } +} + +class GetUserEndpoint extends GetEndpoint { + override path = '/users/:id'; + + override getErrors() { + return { + ...super.getErrors(), + not_found: userNotFoundError, + }; + } + + override params = new ObjectSanitizer({ + id: idValidator, + }); + + override headers = new ObjectSanitizer({ + 'x-request-id': new LengthValidator({ + minLength: 5, + maxLength: 50, + }), + }); + + override headersExample = { + 'x-request-id': 'req-12345', + }; + + async handle(request: ApiRequest) { + const user = usersDb[request.params['id']]; + + if (!user) { + throw this.getErrors().not_found; + } + + return user; + } +} + +class ListUsersEndpoint extends GetEndpoint { + override path = '/users'; + + override query = new ObjectSanitizer({ + limit: new ComposedValSan( + [ + new StringToNumberValSan(), + new IntegerValidator(), + new RangeValidator({ min: 1, max: 100 }), + ], + { isOptional: true } + ), + name: nameValidator.copy({ isOptional: true }), + email: emailValidator.copy({ isOptional: true }), + }); + + async handle(request: ApiRequest) { + const users = Object.values(usersDb); + + return request.query['limit'] + ? users.slice(0, Number(request.query['limit'])) + : users; + } +} + +class UpdateUserEndpoint extends PatchEndpoint { + override path = '/users/:id'; + + override params = new ObjectSanitizer({ + id: idValidator, + }); + + override body = new ObjectSanitizer({ + name: nameValidator.copy({ isOptional: true }), + email: emailValidator.copy({ isOptional: true }), + }); + + override bodyExample = { + name: 'Updated Name', + }; + + override getErrors() { + return { + ...super.getErrors(), + not_found: userNotFoundError, + }; + } + + async handle(request: ApiRequest) { + if (!usersDb[request.params['id']]) { + throw this.getErrors().not_found; + } + + const filteredBody = Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Object.entries(request.body).filter(([_, v]) => v !== undefined) + ); + + usersDb[request.params['id']] = { + ...usersDb[request.params['id']], + ...filteredBody, + }; + + return usersDb[request.params['id']]; + } +} + +class DeleteUserEndpoint extends DeleteEndpoint { + override path = '/users/:id'; + override params = new ObjectSanitizer({ + id: idValidator, + }); + + override getErrors() { + return { + ...super.getErrors(), + not_found: userNotFoundError, + }; + } + + async handle(request: ApiRequest) { + const deleted = usersDb[request.params['id']]; + + if (!deleted) { + throw this.getErrors().not_found; + } + + delete usersDb[request.params['id']]; + + return {}; + } +} + +class UserRouter extends BaseApiRouter { + override path = '/api'; + + override async routes() { + return [ + CreateUserEndpoint, + GetUserEndpoint, + ListUsersEndpoint, + UpdateUserEndpoint, + DeleteUserEndpoint, + ]; + } +} + +/** + * Posts + */ +// --- Posts CRUD Endpoints --- +const postsDb: Record = + {}; + +const postNotFoundError = new NotFoundError('Post not found'); + +class CreatePostEndpoint extends PostEndpoint { + override path = '/posts'; + + override body = new ObjectSanitizer({ + title: new LengthValidator({ minLength: 2, maxLength: 100 }), + content: new LengthValidator({ minLength: 1, maxLength: 1000 }), + }); + + async handle(request: ApiRequest) { + const id = Math.random().toString(36).slice(2); + postsDb[id] = { id, ...request.body }; + + return postsDb[id]; + } +} + +class GetPostEndpoint extends GetEndpoint { + override path = '/posts/:id'; + + override params = new ObjectSanitizer({ + id: new LengthValidator({ minLength: 1, maxLength: 50 }), + }); + + override getErrors() { + return { + ...super.getErrors(), + not_found: postNotFoundError, + }; + } + + async handle(request: ApiRequest) { + const post = postsDb[request.params['id']]; + + if (!post) { + throw this.getErrors().not_found; + } + + return post; + } +} + +class ListPostsEndpoint extends GetEndpoint { + override path = '/posts'; + async handle() { + return Object.values(postsDb); + } +} + +class UpdatePostEndpoint extends PutEndpoint { + override path = '/posts/:id'; + + override params = new ObjectSanitizer({ + id: new LengthValidator({ minLength: 1, maxLength: 50 }), + }); + + override body = new ObjectSanitizer({ + title: new LengthValidator({ minLength: 2, maxLength: 100 }), + content: new LengthValidator({ minLength: 1, maxLength: 1000 }), + }); + + override getErrors() { + return { + ...super.getErrors(), + not_found: postNotFoundError, + }; + } + + async handle(request: ApiRequest) { + if (!postsDb[request.params['id']]) { + throw this.getErrors().not_found; + } + + postsDb[request.params['id']] = { + ...postsDb[request.params['id']], + ...request.body, + }; + + return postsDb[request.params['id']]; + } +} + +class DeletePostEndpoint extends DeleteEndpoint { + override path = '/posts/:id'; + + override params = new ObjectSanitizer({ + id: new LengthValidator({ minLength: 1, maxLength: 50 }), + }); + + override getErrors() { + return { + ...super.getErrors(), + not_found: postNotFoundError, + }; + } + + async handle(request: ApiRequest) { + const deleted = postsDb[request.params['id']]; + + if (!deleted) { + throw this.getErrors().not_found; + } + + delete postsDb[request.params['id']]; + + return {}; + } +} + +class PostRouter extends BaseApiRouter { + override path = '/api'; + override description = 'Blog Posts'; + + override async routes() { + return [ + CreatePostEndpoint, + GetPostEndpoint, + ListPostsEndpoint, + UpdatePostEndpoint, + DeletePostEndpoint, + ]; + } +} + +class OpenApiRouter extends BaseApiRouter { + override path = '/'; + + override async routes() { + return [UserRouter, PostRouter]; + } +} + +export class OpenApiTestServer extends RestServer { + override name = 'Self-Documenting OpenAPI Test Server'; + override version = '1.0.0'; + override description = 'Demo of api-machine\'s self-documenting feature'; + + override router = OpenApiRouter; +} + +export const server = new OpenApiTestServer({ + port: 5050, + swaggerEnabled: true, +}); diff --git a/test/spec/api/openapi.spec.ts b/test/spec/api/openapi.spec.ts new file mode 100644 index 0000000..cf4688b --- /dev/null +++ b/test/spec/api/openapi.spec.ts @@ -0,0 +1,56 @@ +import 'jasmine'; +import { server } from './openapi-server'; +import { OpenAPIObject } from 'auto-oas/oas/v3.1'; +import { env } from '../../env'; + +describe('OpenAPI Spec', () => { + beforeAll(async () => { + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('should serve OpenAPI spec at /openapi.json', async () => { + const url = `http://localhost:${server.port}/openapi.json`; + const res = await fetch(url); + expect(res.status).toBe(200); + const body = (await res.json()) as OpenAPIObject; + expect(body).toBeDefined(); + expect(body.openapi).toBe('3.1.0'); + expect(body.paths).toBeDefined(); + expect(body.components).toBeDefined(); + expect(Array.isArray(body.tags)).toBeTrue(); + }); + + it('should include user endpoint in OpenAPI paths', async () => { + const url = `http://localhost:${server.port}/openapi.json`; + const res = await fetch(url); + const body = (await res.json()) as OpenAPIObject; + + expect(Object.keys(body.paths ?? {})).toContain('/api/users'); + }); + + it('should serve Swagger UI at /docs', async () => { + const url = `http://localhost:${server.port}/docs`; + const res = await fetch(url); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain('Swagger UI'); + }); + + describe('Security - Swagger disabled by default', () => { + it('should return 404 for /docs', async () => { + const url = env.API_URL.replace(/\/$/, '') + '/docs'; + const res = await fetch(url); + expect(res.status).toBe(404); + }); + + it('should return 404 for /openapi.json', async () => { + const url = env.API_URL.replace(/\/$/, '') + '/openapi.json'; + const res = await fetch(url); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/test/spec/api/security-headers.spec.ts b/test/spec/api/security-headers.spec.ts new file mode 100644 index 0000000..0cced84 --- /dev/null +++ b/test/spec/api/security-headers.spec.ts @@ -0,0 +1,273 @@ +import 'jasmine'; +import { RestServer } from '../../../src'; +import { BaseApiRouter, BaseApiEndpoint } from '../../../src/router'; + +describe('Security Headers Configuration', function () { + class TestRouter extends BaseApiRouter { + override path = '/security-test'; + + override async routes() { + return [ + class extends BaseApiEndpoint { + override async handle() { + return { test: true }; + } + }, + ]; + } + } + + class SecureServer extends RestServer { + override router = TestRouter; + } + + describe('Default Security (Secure by Default)', function () { + let server: SecureServer; + const testPort = 4001; + const baseUrl = `http://localhost:${testPort}/security-test`; + + afterEach(async function () { + if (server) { + await server.stop(); + } + }); + + it('should remove X-Powered-By by default', async function () { + server = new SecureServer({ port: testPort }); + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + // X-Powered-By should be removed for security + const poweredBy = response.headers.get('X-Powered-By'); + expect(poweredBy).toBeNull(); + }); + + it('should set X-Content-Type-Options by default', async function () { + server = new SecureServer({ port: testPort }); + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const contentTypeOptions = response.headers.get( + 'X-Content-Type-Options' + ); + expect(contentTypeOptions).toBe('nosniff'); + }); + + it('should set X-Frame-Options to DENY by default', async function () { + server = new SecureServer({ port: testPort }); + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const frameOptions = response.headers.get('X-Frame-Options'); + expect(frameOptions).toBe('DENY'); + }); + + it('should set X-XSS-Protection by default', async function () { + server = new SecureServer({ port: testPort }); + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const xssProtection = response.headers.get('X-XSS-Protection'); + expect(xssProtection).toBe('1; mode=block'); + }); + + it('should NOT set HSTS by default (HTTP-safe)', async function () { + server = new SecureServer({ port: testPort }); + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + // HSTS should not be set by default (only use with HTTPS) + const hsts = response.headers.get('Strict-Transport-Security'); + expect(hsts).toBeNull(); + }); + }); + + describe('HSTS Configuration', function () { + let server: SecureServer; + const testPort = 4001; + const baseUrl = `http://localhost:${testPort}/security-test`; + + afterEach(async function () { + if (server) { + await server.stop(); + } + }); + + it('should set HSTS header when enabled', async function () { + // Create server with HSTS enabled + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: true, + frameOptions: 'DENY', + xssProtection: true, + hsts: 31536000, // 1 year + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + // Verify HSTS header is set + const hsts = response.headers.get('Strict-Transport-Security'); + expect(hsts).toBe('max-age=31536000; includeSubDomains'); + }); + + it('should allow custom HSTS max-age', async function () { + // Create server with custom HSTS duration + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: true, + frameOptions: 'DENY', + xssProtection: true, + hsts: 86400, // 1 day + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + // Verify HSTS header has correct max-age + const hsts = response.headers.get('Strict-Transport-Security'); + expect(hsts).toBe('max-age=86400; includeSubDomains'); + }); + }); + + describe('Security Headers Customization', function () { + let server: SecureServer; + const testPort = 4002; + const baseUrl = `http://localhost:${testPort}/security-test`; + + afterEach(async function () { + if (server) { + await server.stop(); + } + }); + + it('should allow X-Powered-By if needed', async function () { + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: false, + noSniff: true, + frameOptions: 'DENY', + xssProtection: true, + hsts: false, + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + // X-Powered-By should be present when not disabled + const poweredBy = response.headers.get('X-Powered-By'); + expect(poweredBy).toBeDefined(); + expect(poweredBy).toContain('Express'); + }); + + it('should allow SAMEORIGIN frame options', async function () { + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: true, + frameOptions: 'SAMEORIGIN', + xssProtection: true, + hsts: false, + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const frameOptions = response.headers.get('X-Frame-Options'); + expect(frameOptions).toBe('SAMEORIGIN'); + }); + + it('should allow disabling frame options', async function () { + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: true, + frameOptions: false, + xssProtection: true, + hsts: false, + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const frameOptions = response.headers.get('X-Frame-Options'); + expect(frameOptions).toBeNull(); + }); + + it('should allow disabling noSniff if required', async function () { + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: false, + frameOptions: 'DENY', + xssProtection: true, + hsts: false, + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const contentTypeOptions = response.headers.get( + 'X-Content-Type-Options' + ); + expect(contentTypeOptions).toBeNull(); + }); + + it('should allow disabling XSS protection', async function () { + server = new SecureServer({ + port: testPort, + securityHeaders: { + disableXPoweredBy: true, + noSniff: true, + frameOptions: 'DENY', + xssProtection: false, + hsts: false, + }, + }); + + await server.start(); + + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + + const xssProtection = response.headers.get('X-XSS-Protection'); + expect(xssProtection).toBeNull(); + }); + }); +}); diff --git a/test/spec/authentication/authentication-scheme.spec.ts b/test/spec/authentication/authentication-scheme.spec.ts new file mode 100644 index 0000000..a8b4e0b --- /dev/null +++ b/test/spec/authentication/authentication-scheme.spec.ts @@ -0,0 +1,52 @@ +import 'jasmine'; +import { + AuthenticationScheme, + BearerAuthenticationScheme, +} from '../../../src/authentication'; +import { ApiNextFunction, ApiRequest, ApiResponse } from '../../../src'; + +describe('AuthenticationScheme (Base Class)', () => { + describe('Interface Contract', () => { + it('should have required abstract properties', () => { + class TestScheme extends AuthenticationScheme { + readonly schemeName = 'TestScheme'; + readonly type = 'http' as const; + + getSecurityScheme() { + return { type: 'http' as const, scheme: 'test' }; + } + + getMiddleware() { + return ( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) => next(); + } + } + + const scheme = new TestScheme(); + expect(scheme.schemeName).toBe('TestScheme'); + expect(scheme.type).toBe('http'); + }); + + it('should have getSecurityRequirement method', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const requirement = scheme.getSecurityRequirement(); + expect(requirement).toEqual({ BearerAuth: [] }); + }); + }); + + describe('Scheme Types', () => { + it('should support http scheme type', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + expect(scheme.type).toBe('http'); + }); + }); +}); diff --git a/test/spec/authentication/authentication.server.ts b/test/spec/authentication/authentication.server.ts new file mode 100644 index 0000000..452a6bc --- /dev/null +++ b/test/spec/authentication/authentication.server.ts @@ -0,0 +1,28 @@ +import { bearerAuthenticationMiddleware } from '../../../src/authentication'; +import { BaseApiEndpoint, BaseApiRouter } from '../../../src/router'; +import { RequestHandler } from 'express'; + +// Authentication middleware matching the test expectations +export const authMiddleware: RequestHandler = bearerAuthenticationMiddleware({ + checkToken: async (token: string) => token === 'validtoken', + // eslint-disable-next-line no-console + log: { ...console, fatal: console.error }, +}); + +export class AuthenticatedEndpoint extends BaseApiEndpoint { + override path = '/test'; + override middleware = [authMiddleware]; + + async handle() { + return { ok: true }; + } +} + +export class ProtectedRouter extends BaseApiRouter { + override path = '/protected'; + override middleware = [authMiddleware]; + + async routes() { + return [AuthenticatedEndpoint]; + } +} diff --git a/test/spec/authentication/authentication.spec.ts b/test/spec/authentication/authentication.spec.ts new file mode 100644 index 0000000..a104612 --- /dev/null +++ b/test/spec/authentication/authentication.spec.ts @@ -0,0 +1,365 @@ +import 'jasmine'; +import { MockConsole } from 'ts-jasmine-spies'; + +import { env } from '../../env'; +import { ErrorResponse } from '../../../src/error'; + +describe('Authentication Middleware', function () { + const baseUrl = env.API_URL; + const validToken = 'validtoken'; + + let mockConsole: MockConsole; + + beforeEach(async function () { + mockConsole = new MockConsole(); + }); + + describe('Protected Routes (/protected/*)', function () { + const protectedEndpoint = `${baseUrl}/protected/test`; + + it('should reject requests without Authorization', async function () { + const response = await fetch(protectedEndpoint); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Authorization header is missing'); + expect(data.error).toBe('UnauthorizedError'); + expect(data.timestamp).toBeDefined(); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - No token provided' + ); + }); + + it('should reject requests with missing token', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: 'Bearer' }, + }); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Bearer token is empty or invalid'); + expect(data.error).toBe('UnauthorizedError'); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - Invalid token format' + ); + mockConsole.expectStderrContains('"code": "bearer_prefix",'); + mockConsole.expectStderrContains('"message": "Missing login token'); + }); + + it('should reject empty Authorization', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: '' }, + }); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Authorization header is missing'); + expect(data.error).toBe('UnauthorizedError'); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - No token provided' + ); + }); + + it('should reject requests with wrong token', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: 'Bearer invalid-token' }, + }); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Bearer token check failed'); + expect(data.error).toBe('UnauthorizedError'); + expect(data.timestamp).toBeDefined(); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - Check token failed' + ); + }); + + it('should reject malformed Authorization', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: 'InvalidFormat token' }, + }); + + // Middleware extracts "token" and compares to bearer token + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Bearer token is empty or invalid'); + expect(data.error).toBe('UnauthorizedError'); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - Invalid token' + ); + }); + + it('should reject requests with Basic auth', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: 'Basic dXNlcjpwYXNzd29yZA==' }, + }); + + // Middleware extracts "dXNlcjpwYXNzd29yZA==" and compares + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Bearer token is empty or invalid'); + expect(data.error).toBe('UnauthorizedError'); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - Invalid token' + ); + }); + + it('should accept requests with valid Bearer token', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: `Bearer ${validToken}` }, + }); + + expect(response.status).toBe(200); + + const data = (await response.json()) as { ok: boolean }; + + expect(data).toEqual({ ok: true }); + + mockConsole.expectStdout(''); + mockConsole.expectStderr(''); + }); + + it('should handle case-sensitive Authorization', async function () { + const response = await fetch(protectedEndpoint, { + headers: { authorization: `Bearer ${validToken}` }, + }); + + expect(response.status).toBe(200); + + mockConsole.expectStdout(''); + mockConsole.expectStderr(''); + }); + + it('should handle extra whitespace in Bearer token', async function () { + const response = await fetch(protectedEndpoint, { + headers: { Authorization: `Bearer ${validToken} ` }, + }); + + // Multiple spaces result in extracting empty/wrong token + expect(response.status).toBe(200); + + const data = (await response.json()) as { ok: boolean }; + expect(data).toEqual({ ok: true }); + mockConsole.expectStdout(''); + mockConsole.expectStderr(''); + }); + + it('should include timestamp in error', async function () { + const beforeRequest = new Date(); + const response = await fetch(protectedEndpoint); + const afterRequest = new Date(); + + const data = (await response.json()) as ErrorResponse; + const responseTimestamp = new Date(data.timestamp); + + expect(responseTimestamp.getTime()).toBeGreaterThanOrEqual( + beforeRequest.getTime() - 1000 + ); + expect(responseTimestamp.getTime()).toBeLessThanOrEqual( + afterRequest.getTime() + 1000 + ); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - No token provided' + ); + }); + }); + + describe('Authentication Error Response Format', function () { + it('should return JSON for auth errors', async function () { + const response = await fetch(`${baseUrl}/protected/test`); + + const contentType = response.headers.get('content-type'); + expect(contentType).toContain('application/json'); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data).toEqual({ + error: jasmine.any(String), + message: jasmine.any(String), + timestamp: jasmine.any(String), + options: jasmine.any(Object), + }); + + expect(Object.keys(data)).toEqual([ + 'error', + 'message', + 'timestamp', + 'options', + ]); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - No token provided' + ); + }); + + it('should return consistent error for 401', async function () { + const response = await fetch(`${baseUrl}/protected/test`); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data).toEqual({ + error: jasmine.any(String), + message: jasmine.any(String), + timestamp: jasmine.any(String), + options: jasmine.any(Object), + }); + + expect(Object.keys(data)).toEqual([ + 'error', + 'message', + 'timestamp', + 'options', + ]); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - No token provided' + ); + }); + + it('should return 401 for invalid token', async function () { + const response = await fetch(`${baseUrl}/protected/test`, { + headers: { + Authorization: 'Bearer wrong-token', + }, + }); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + expect(data).toEqual({ + error: jasmine.any(String), + message: jasmine.any(String), + timestamp: jasmine.any(String), + options: jasmine.any(Object), + }); + + expect(Object.keys(data)).toEqual([ + 'error', + 'message', + 'timestamp', + 'options', + ]); + + mockConsole.expectStderrContains( + 'Unauthorized access attempt from ::1 - Check token failed' + ); + }); + }); + + describe('Security Headers and Behavior', function () { + it('should not expose sensitive internal details', async function () { + const response = await fetch(`${baseUrl}/protected/test`); + + const data = (await response.json()) as ErrorResponse; + expect(data.error).not.toContain('env'); + expect(data.error).not.toContain('BEARER_TOKEN'); + expect(data.error).not.toContain('your-secret-bearer-token-here'); + expect(data.error).not.toContain('secret'); + }); + + it('should handle concurrent requests', async function () { + const requests = Array.from({ length: 5 }, async () => + fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: `Bearer ${validToken}` }, + }) + ); + + const responses = await Promise.all(requests); + + for (const response of responses) { + expect(response.status).toBe(200); + } + + mockConsole.expectStdout(''); + mockConsole.expectStderr(''); + }); + + it('should handle mixed tokens concurrently', async function () { + const validRequest = fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: `Bearer ${validToken}` }, + }); + + const invalidRequest = fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: 'Bearer invalid-token' }, + }); + + const noAuthRequest = fetch(`${baseUrl}/protected/test`); + + const [validResponse, invalidResponse, noAuthResponse] = + await Promise.all([ + validRequest, + invalidRequest, + noAuthRequest, + ]); + + expect(validResponse.status).toBe(200); // Passes auth + expect(invalidResponse.status).toBe(401); // Invalid token + expect(noAuthResponse.status).toBe(401); // No token + }); + }); + + describe('Authorization Header Variations', function () { + it('should work with proper Bearer token format', async function () { + const response = await fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: `Bearer ${validToken}` }, + }); + + expect(response.status).toBe(200); + }); + + it('should not allow non-Bearer schemes', async function () { + // Test that the middleware extracts tokens properly + const response = await fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: `SomeScheme ${validToken}` }, + }); + + expect(response.status).toBe(401); + }); + + it('should reject empty Bearer token', async function () { + const response = await fetch(`${baseUrl}/protected/test`, { + headers: { Authorization: 'Bearer ' }, + }); + + expect(response.status).toBe(401); + }); + }); + + describe('Performance and Reliability', function () { + it('should handle concurrent auth failures', async function () { + const requests = Array.from({ length: 10 }, async () => { + const startTime = Date.now(); + const response = await fetch(`${baseUrl}/protected/test`); + const endTime = Date.now(); + + return { + status: response.status, + responseTime: endTime - startTime, + }; + }); + + const results = await Promise.all(requests); + + for (const result of results) { + expect(result.status).toBe(401); + expect(result.responseTime).toBeLessThan(1000); + } + }); + }); +}); diff --git a/test/spec/authentication/bearer-scheme.spec.ts b/test/spec/authentication/bearer-scheme.spec.ts new file mode 100644 index 0000000..72566f4 --- /dev/null +++ b/test/spec/authentication/bearer-scheme.spec.ts @@ -0,0 +1,291 @@ +import 'jasmine'; +// eslint-disable-next-line max-len +import { BearerAuthenticationScheme } from '../../../src/authentication/schemes/bearer-authentication-scheme'; +import { UnauthorizedError } from '../../../src/error'; +import { AuthenticatedRequest } from '../../../src/authentication'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyResponse = any; + +describe('BearerAuthenticationScheme', () => { + describe('Initialization', () => { + it('should initialize with required options', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + expect(scheme.schemeName).toBe('BearerAuth'); + expect(scheme.type).toBe('http'); + }); + + it('should use custom scheme name if provided', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'CustomBearer', + }); + + expect(scheme.schemeName).toBe('CustomBearer'); + }); + + it('should use custom bearer format if provided', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + bearerFormat: 'OAuth2', + }); + + const securityScheme = scheme.getSecurityScheme(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).bearerFormat).toBe('OAuth2'); + }); + + it('should default bearer format to JWT', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const securityScheme = scheme.getSecurityScheme(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).bearerFormat).toBe('JWT'); + }); + + it('should include description if provided', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + description: 'JWT Bearer token for API access', + }); + + const securityScheme = scheme.getSecurityScheme(); + expect(securityScheme.description).toBe( + 'JWT Bearer token for API access' + ); + }); + + it('should omit description if not provided', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const securityScheme = scheme.getSecurityScheme(); + expect(securityScheme.description).toBeUndefined(); + }); + }); + + describe('getSecurityScheme()', () => { + it('should return http security scheme object', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const securityScheme = scheme.getSecurityScheme(); + + expect(securityScheme).toEqual( + jasmine.objectContaining({ + type: 'http', + }) + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).scheme).toBe('bearer'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).bearerFormat).toBe('JWT'); + }); + + it('should match OpenAPI SecuritySchemeObject structure', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + description: 'Test Bearer Auth', + }); + + const securityScheme = scheme.getSecurityScheme(); + + // Validate required fields + expect(securityScheme.type).toBe('http'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).scheme).toBe('bearer'); + + // Validate optional fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((securityScheme as any).bearerFormat).toBeDefined(); + expect(securityScheme.description).toBeDefined(); + }); + }); + + describe('getSecurityRequirement()', () => { + it('should return security requirement with scheme name', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const requirement = scheme.getSecurityRequirement(); + + expect(requirement).toEqual({ BearerAuth: [] }); + }); + + it('should use custom scheme name in requirement', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'AdminBearer', + }); + + const requirement = scheme.getSecurityRequirement(); + + expect(requirement).toEqual({ AdminBearer: [] }); + }); + }); + + describe('getMiddleware()', () => { + it('should return a function', () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const middleware = scheme.getMiddleware(); + + expect(typeof middleware).toBe('function'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((middleware as any).length).toBeGreaterThanOrEqual(3); + }); + + it('should return middleware that accepts valid tokens', async () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'valid-token', + }); + + const middleware = scheme.getMiddleware(); + + const req = { + headers: { authorization: 'Bearer valid-token' }, + ip: '127.0.0.1', + authenticated: false, + } as unknown as AuthenticatedRequest; + + const res = {} as unknown as AnyResponse; + const next = jasmine.createSpy('next'); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authenticated).toBe(true); + }); + + it('should reject invalid tokens', async () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'valid-token', + }); + + const middleware = scheme.getMiddleware(); + + const req = { + headers: { authorization: 'Bearer invalid-token' }, + ip: '127.0.0.1', + authenticated: false, + } as unknown as AuthenticatedRequest; + + const res = {} as unknown as AnyResponse; + const next = jasmine.createSpy('next'); + + try { + await middleware(req, res, next); + fail('Should have thrown UnauthorizedError'); + } + catch (error) { + expect(error).toBeInstanceOf(UnauthorizedError); + expect((error as UnauthorizedError).message).toBe( + 'Bearer token check failed' + ); + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject missing Authorization header', async () => { + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const middleware = scheme.getMiddleware(); + + const req = { + headers: {}, + ip: '127.0.0.1', + authenticated: false, + } as unknown as AuthenticatedRequest; + + const res = {} as unknown as AnyResponse; + const next = jasmine.createSpy('next'); + + try { + await middleware(req, res, next); + fail('Should have thrown UnauthorizedError'); + } + catch (error) { + expect(error).toBeInstanceOf(UnauthorizedError); + expect((error as UnauthorizedError).message).toBe( + 'Authorization header is missing' + ); + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should support optional log parameter', async () => { + const mockLog = { + warn: jasmine.createSpy('warn'), + info: jasmine.createSpy('info'), + error: jasmine.createSpy('error'), + debug: jasmine.createSpy('debug'), + fatal: jasmine.createSpy('fatal'), + }; + + const scheme = new BearerAuthenticationScheme({ + checkToken: async () => false, + log: mockLog, + }); + + const middleware = scheme.getMiddleware(); + + const req = { + headers: { authorization: 'Bearer token' }, + ip: '127.0.0.1', + authenticated: false, + } as unknown as AuthenticatedRequest; + + const res = {} as unknown as AnyResponse; + const next = jasmine.createSpy('next'); + + try { + await middleware(req, res, next); + } + catch (error) { + // Expected error + } + + expect(mockLog.warn).toHaveBeenCalled(); + }); + }); + + describe('Configuration Options', () => { + it('should pass checkToken option to middleware', async () => { + const checkTokenSpy = jasmine + .createSpy('checkToken') + .and.returnValue(Promise.resolve(true)); + + const scheme = new BearerAuthenticationScheme({ + checkToken: checkTokenSpy, + }); + + const middleware = scheme.getMiddleware(); + + const req = { + headers: { authorization: 'Bearer test-token' }, + ip: '127.0.0.1', + authenticated: false, + } as unknown as AuthenticatedRequest; + + const res = {} as unknown as AnyResponse; + const next = jasmine.createSpy('next'); + + await middleware(req, res, next); + + expect(checkTokenSpy).toHaveBeenCalledWith('test-token'); + }); + }); +}); diff --git a/test/spec/authentication/cascading-auth.spec.ts b/test/spec/authentication/cascading-auth.spec.ts new file mode 100644 index 0000000..a1ce750 --- /dev/null +++ b/test/spec/authentication/cascading-auth.spec.ts @@ -0,0 +1,287 @@ +import 'jasmine'; +import { BaseApiEndpoint } from '../../../src/router/endpoint'; +import { BaseApiRouter } from '../../../src/router/router'; +import { BearerAuthenticationScheme } from '../../../src/authentication'; + +describe('Authentication Cascading', () => { + const serverAuth = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'server-token', + schemeName: 'ServerAuth', + }); + + const routerAuth = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'router-token', + schemeName: 'RouterAuth', + }); + + const endpointAuth = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'endpoint-token', + schemeName: 'EndpointAuth', + }); + + describe('getEffectiveAuthentication()', () => { + it('endpoint auth overrides router and server', () => { + class TestEndpoint extends BaseApiEndpoint { + override authentication = endpointAuth; + + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = { authentication: routerAuth } as any; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBe(endpointAuth); + }); + + it('router auth used when endpoint auth undefined', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + const mockRouter = { + authentication: routerAuth, + getEffectiveAuthentication: () => routerAuth, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = mockRouter as any; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBe(routerAuth); + }); + + it('server auth when endpoint and router undefined', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + const routerWithServerAuth = { + authentication: undefined, + getEffectiveAuthentication: () => serverAuth, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = routerWithServerAuth as any; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBe(serverAuth); + }); + + it('returns undefined when no auth at any level', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.parentRoute = undefined; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBeUndefined(); + }); + + it('endpoint null explicitly makes it public', () => { + class TestEndpoint extends BaseApiEndpoint { + override authentication = null; + + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = { authentication: routerAuth } as any; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBeNull(); + }); + + it('null (public) vs undefined (inherited)', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const publicEndpoint = new TestEndpoint(); + publicEndpoint.authentication = null; + const mockRouter1 = { + authentication: routerAuth, + getEffectiveAuthentication: () => routerAuth, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + publicEndpoint.parentRoute = mockRouter1 as any; + + const inheritedEndpoint = new TestEndpoint(); + inheritedEndpoint.authentication = undefined; + const mockRouter2 = { + authentication: routerAuth, + getEffectiveAuthentication: () => routerAuth, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + inheritedEndpoint.parentRoute = mockRouter2; + + expect(publicEndpoint.getEffectiveAuthentication()).toBeNull(); + expect(inheritedEndpoint.getEffectiveAuthentication()).toBe( + routerAuth + ); + }); + }); + + describe('Router-level cascading', () => { + it('router auth cascades to endpoints without auth', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + class TestRouter extends BaseApiRouter { + override authentication = routerAuth; + + override async routes() { + return [TestEndpoint]; + } + } + + const endpoint = new TestEndpoint(); + endpoint.parentRoute = new TestRouter(); + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBe(routerAuth); + }); + + it('endpoint auth overrides router auth', () => { + class TestEndpoint extends BaseApiEndpoint { + override authentication = endpointAuth; + + override async handle() { + return {}; + } + } + + class TestRouter extends BaseApiRouter { + override authentication = routerAuth; + + override async routes() { + return [TestEndpoint]; + } + } + + const endpoint = new TestEndpoint(); + endpoint.parentRoute = new TestRouter(); + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBe(endpointAuth); + }); + + it('endpoint null makes route public with router auth', () => { + class TestEndpoint extends BaseApiEndpoint { + override authentication = null; + + override async handle() { + return {}; + } + } + + class TestRouter extends BaseApiRouter { + override authentication = routerAuth; + + override async routes() { + return [TestEndpoint]; + } + } + + const endpoint = new TestEndpoint(); + endpoint.parentRoute = new TestRouter(); + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective).toBeNull(); + }); + }); + + describe('Mixed Authentication Schemes', () => { + it('different schemes at different levels', () => { + const scheme1 = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'Scheme1', + }); + + const scheme2 = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'Scheme2', + }); + + class Endpoint1 extends BaseApiEndpoint { + override authentication = scheme1; + + override async handle() { + return {}; + } + } + + class Endpoint2 extends BaseApiEndpoint { + override authentication = scheme2; + + override async handle() { + return {}; + } + } + + const e1 = new Endpoint1(); + const e2 = new Endpoint2(); + + expect(e1.getEffectiveAuthentication()).toBe(scheme1); + expect(e2.getEffectiveAuthentication()).toBe(scheme2); + }); + }); + + describe('Authentication Scheme Names', () => { + it('preserved through cascading', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = new (class extends BaseApiRouter { + override authentication = routerAuth; + + override async routes() { + return []; + } + })(); + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective?.schemeName).toBe('RouterAuth'); + }); + + it('endpoints override parent scheme names', () => { + class TestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.authentication = endpointAuth; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoint.parentRoute = { authentication: routerAuth } as any; + + const effective = endpoint.getEffectiveAuthentication(); + expect(effective?.schemeName).toBe('EndpointAuth'); + }); + }); +}); diff --git a/test/spec/authentication/integration-auth.spec.ts b/test/spec/authentication/integration-auth.spec.ts new file mode 100644 index 0000000..16bf43e --- /dev/null +++ b/test/spec/authentication/integration-auth.spec.ts @@ -0,0 +1,451 @@ +import 'jasmine'; +import { RestServer } from '../../../src/server'; +import { BaseApiRouter } from '../../../src/router/router'; +import { BaseApiEndpoint } from '../../../src/router/endpoint'; +import { BearerAuthenticationScheme } from '../../../src/authentication'; +import { ErrorResponse } from '../../../src/error'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyServer = any; + +describe('Authentication Integration Tests', () => { + describe('Server-Level Authentication', () => { + class ServerAuthEndpoint extends BaseApiEndpoint { + override path = '/test'; + + async handle() { + return { authenticated: true }; + } + } + + class ServerAuthRouter extends BaseApiRouter { + override path = '/api'; + + async routes() { + return [ServerAuthEndpoint]; + } + } + + class ServerWithAuth extends RestServer { + override router = ServerAuthRouter; + override name = 'ServerWithAuth'; + override version = '1.0.0'; + + constructor() { + super({ + port: 0, + authentication: new BearerAuthenticationScheme({ + checkToken: async (token: string) => + token === 'server-token', + schemeName: 'ServerAuth', + }), + }); + } + } + + it('should apply server auth to endpoints', async () => { + const server = new ServerWithAuth(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + // Request without auth should fail + const noAuthResponse = await fetch(`${baseUrl}/api/test`); + expect(noAuthResponse.status).toBe(401); + + // Request with valid token should succeed + const authResponse = await fetch(`${baseUrl}/api/test`, { + headers: { + Authorization: 'Bearer server-token', + }, + }); + expect(authResponse.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await authResponse.json()) as any; + expect(data.authenticated).toBe(true); + } + finally { + await server.stop(); + } + }); + + it('should reject invalid server tokens', async () => { + const server = new ServerWithAuth(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + const response = await fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer wrong-token' }, + }); + + expect(response.status).toBe(401); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await response.json()) as ErrorResponse; + expect(data.error).toBe('UnauthorizedError'); + expect(data.message).toBe('Bearer token check failed'); + } + finally { + await server.stop(); + } + }); + }); + + describe('Router-Level Authentication Override', () => { + class PublicEndpoint extends BaseApiEndpoint { + override path = '/public'; + + async handle() { + return { public: true }; + } + } + + class ProtectedEndpoint extends BaseApiEndpoint { + override path = '/protected'; + + async handle() { + return { protected: true }; + } + } + + class PublicRouter extends BaseApiRouter { + override path = '/api'; + override authentication = null; + + async routes() { + return [PublicEndpoint]; + } + } + + class ProtectedRouter extends BaseApiRouter { + override path = '/secure'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'router-token', + schemeName: 'RouterAuth', + }); + + async routes() { + return [ProtectedEndpoint]; + } + } + + class MixedAuthServer extends RestServer { + override router = class extends BaseApiRouter { + async routes() { + return [PublicRouter, ProtectedRouter]; + } + }; + override name = 'MixedAuthServer'; + override version = '1.0.0'; + + constructor() { + super({ + port: 0, + authentication: new BearerAuthenticationScheme({ + checkToken: async () => false, + schemeName: 'DefaultAuth', + }), + }); + } + } + + it('should allow public router to bypass server auth', async () => { + const server = new MixedAuthServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + const response = await fetch(`${baseUrl}/api/public`); + expect(response.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await response.json()) as any; + expect(data.public).toBe(true); + } + finally { + await server.stop(); + } + }); + + it('should use router auth when specified', async () => { + const server = new MixedAuthServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + // Without router token should fail + const noAuthResponse = await fetch( + `${baseUrl}/secure/protected` + ); + expect(noAuthResponse.status).toBe(401); + + // With router token should succeed + const authResponse = await fetch( + `${baseUrl}/secure/protected`, + { + headers: { + Authorization: 'Bearer router-token', + }, + } + ); + expect(authResponse.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await authResponse.json()) as any; + expect(data.protected).toBe(true); + } + finally { + await server.stop(); + } + }); + }); + + describe('Endpoint-Level Authentication Override', () => { + class AdminEndpoint extends BaseApiEndpoint { + override path = '/admin'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'admin-token', + schemeName: 'AdminAuth', + }); + + async handle() { + return { admin: true }; + } + } + + class RegularEndpoint extends BaseApiEndpoint { + override path = '/regular'; + + async handle() { + return { regular: true }; + } + } + + class PublicEndpoint extends BaseApiEndpoint { + override path = '/info'; + override authentication = null; + + async handle() { + return { info: 'public' }; + } + } + + class MixedEndpointRouter extends BaseApiRouter { + override path = '/api'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'user-token', + schemeName: 'UserAuth', + }); + + async routes() { + return [AdminEndpoint, RegularEndpoint, PublicEndpoint]; + } + } + + class EndpointAuthServer extends RestServer { + override router = class extends BaseApiRouter { + async routes() { + return [MixedEndpointRouter]; + } + }; + override name = 'EndpointAuthServer'; + override version = '1.0.0'; + + constructor() { + super({ port: 0 }); + } + } + + it('should use endpoint auth when specified', async () => { + const server = new EndpointAuthServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + // Admin endpoint requires admin token + const wrongTokenResponse = await fetch(`${baseUrl}/api/admin`, { + headers: { Authorization: 'Bearer user-token' }, + }); + expect(wrongTokenResponse.status).toBe(401); + + // Admin endpoint succeeds with admin token + const adminResponse = await fetch(`${baseUrl}/api/admin`, { + headers: { Authorization: 'Bearer admin-token' }, + }); + expect(adminResponse.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const adminData = (await adminResponse.json()) as any; + expect(adminData.admin).toBe(true); + } + finally { + await server.stop(); + } + }); + + it('should use router auth when endpoint not specified', async () => { + const server = new EndpointAuthServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + // Regular endpoint uses router auth + const wrongTokenResponse = await fetch( + `${baseUrl}/api/regular`, + { + headers: { Authorization: 'Bearer admin-token' }, + } + ); + expect(wrongTokenResponse.status).toBe(401); + + const userTokenResponse = await fetch( + `${baseUrl}/api/regular`, + { + headers: { Authorization: 'Bearer user-token' }, + } + ); + expect(userTokenResponse.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await userTokenResponse.json()) as any; + expect(data.regular).toBe(true); + } + finally { + await server.stop(); + } + }); + + it('should allow public endpoints despite router auth', async () => { + const server = new EndpointAuthServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + // Public endpoint should succeed without any auth + const response = await fetch(`${baseUrl}/api/info`); + expect(response.status).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await response.json()) as any; + expect(data.info).toBe('public'); + } + finally { + await server.stop(); + } + }); + }); + + describe('Authentication Middleware Integration', () => { + class TestEndpoint extends BaseApiEndpoint { + override path = '/test'; + + async handle() { + return { success: true }; + } + } + + class TestRouter extends BaseApiRouter { + override path = '/api'; + override authentication = new BearerAuthenticationScheme({ + checkToken: async (token: string) => token === 'valid-token', + }); + + async routes() { + return [TestEndpoint]; + } + } + + class TestServer extends RestServer { + override router = class extends BaseApiRouter { + async routes() { + return [TestRouter]; + } + }; + override name = 'TestServer'; + override version = '1.0.0'; + + constructor() { + super({ port: 0 }); + } + } + + it('should handle concurrent requests correctly', async () => { + const server = new TestServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + const requests = [ + fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer valid-token' }, + }), + fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer invalid-token' }, + }), + fetch(`${baseUrl}/api/test`), + fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer valid-token' }, + }), + ]; + + const responses = await Promise.all(requests); + expect(responses[0].status).toBe(200); + expect(responses[1].status).toBe(401); + expect(responses[2].status).toBe(401); + expect(responses[3].status).toBe(200); + } + finally { + await server.stop(); + } + }); + + it('should preserve request isolation', async () => { + const server = new TestServer(); + await server.start(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const port = ((server as AnyServer).listener as any).address().port; + const baseUrl = `http://localhost:${port}`; + + try { + const validResponse = await fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer valid-token' }, + }); + + const invalidResponse = await fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer invalid-token' }, + }); + + expect(validResponse.status).toBe(200); + expect(invalidResponse.status).toBe(401); + + // Second valid request should still work + const validResponse2 = await fetch(`${baseUrl}/api/test`, { + headers: { Authorization: 'Bearer valid-token' }, + }); + expect(validResponse2.status).toBe(200); + } + finally { + await server.stop(); + } + }); + }); +}); diff --git a/test/spec/endpoint/endpoint-methods.server.ts b/test/spec/endpoint/endpoint-methods.server.ts new file mode 100644 index 0000000..d9a4012 --- /dev/null +++ b/test/spec/endpoint/endpoint-methods.server.ts @@ -0,0 +1,173 @@ +import { + BaseApiRouter, + ApiRequest, + GetEndpoint, + PostEndpoint, + PutEndpoint, + DeleteEndpoint, + PatchEndpoint, +} from '../../../src'; + +// Mock in-memory data store +const items = [ + { id: 1, name: 'Item 1', description: 'First item' }, + { id: 2, name: 'Item 2', description: 'Second item' }, + { id: 3, name: 'Item 3', description: 'Third item' }, +]; +let nextId = 4; + +export class MethodsRouter extends BaseApiRouter { + override path = '/methods'; + + override async routes() { + return [ + // GET: List all items + class extends GetEndpoint { + override path = '/items'; + + override async handle() { + return items; + } + }, + + // GET: Get single item + class extends GetEndpoint { + override path = '/items/:id'; + + override async handle(request: ApiRequest) { + const id = parseInt(request.params['id'], 10); + const item = items.find((i) => i.id === id); + return item || { id, name: 'Not found', description: '' }; + } + }, + + // POST: Create item + class extends PostEndpoint { + override path = '/items'; + + override async handle(request: ApiRequest) { + const newItem = { + id: nextId++, + name: request.body?.name || 'Unnamed', + description: request.body?.description || '', + }; + items.push(newItem); + return newItem; + } + }, + + // POST-only endpoint for method testing + class extends PostEndpoint { + override path = '/post-only'; + + override async handle() { + return { method: 'post' }; + } + }, + + // PUT: Update item + class extends PutEndpoint { + override path = '/items/:id'; + + override async handle(request: ApiRequest) { + const id = parseInt(request.params['id'], 10); + const index = items.findIndex((i) => i.id === id); + + if (index >= 0) { + items[index] = { + id, + name: request.body?.name || items[index].name, + description: + request.body?.description || + items[index].description, + }; + return items[index]; + } + + // Create new item with specified ID + const newItem = { + id, + name: request.body?.name || 'Unnamed', + description: request.body?.description || '', + }; + items.push(newItem); + return newItem; + } + }, + + // PUT-only endpoint for method testing + class extends PutEndpoint { + override path = '/put-only'; + + override async handle() { + return { method: 'put' }; + } + }, + + // DELETE: Delete item + class extends DeleteEndpoint { + override path = '/items/:id'; + + override async handle(request: ApiRequest) { + const id = parseInt(request.params['id'], 10); + const index = items.findIndex((i) => i.id === id); + + if (index >= 0) { + items.splice(index, 1); + return { success: true, id }; + } + + return { success: false, id, message: 'Not found' }; + } + }, + + // DELETE-only endpoint for method testing + class extends DeleteEndpoint { + override path = '/delete-only'; + + override async handle() { + return { method: 'delete' }; + } + }, + + // PATCH: Partial update item + class extends PatchEndpoint { + override path = '/items/:id'; + + override async handle(request: ApiRequest) { + const id = parseInt(request.params['id'], 10); + const index = items.findIndex((i) => i.id === id); + + if (index >= 0) { + items[index] = { + ...items[index], + ...(request.body || {}), + id, // Ensure ID doesn't change + }; + return items[index]; + } + + return { id, message: 'Not found' }; + } + }, + + // PATCH-only endpoint for method testing + class extends PatchEndpoint { + override path = '/patch-only'; + + override async handle() { + return { method: 'patch' }; + } + }, + + // Default method test (should be GET) + class extends GetEndpoint { + override path = '/default-method'; + + override async handle() { + return { method: 'get' }; + } + }, + ]; + } +} diff --git a/test/spec/endpoint/endpoint-methods.spec.ts b/test/spec/endpoint/endpoint-methods.spec.ts new file mode 100644 index 0000000..fc9144b --- /dev/null +++ b/test/spec/endpoint/endpoint-methods.spec.ts @@ -0,0 +1,187 @@ +import 'jasmine'; +import { env } from '../../env'; + +describe('Endpoint Methods', function () { + const baseUrl = env.API_URL + '/methods'; + + describe('GET Method', function () { + it('should handle GET requests', async function () { + const response = await fetch(`${baseUrl}/items`); + expect(response.status).toBe(200); + + const data = (await response.json()) as unknown[]; + expect(Array.isArray(data)).toBe(true); + }); + + it('returns 405 for unsupported methods', async function () { + const methods = ['POST', 'PUT', 'DELETE', 'PATCH']; + + for (const method of methods) { + const response = await fetch(`${baseUrl}/default-method`, { + method: method, + }); + + // Should return 404 (route not found) or + // 405 (method not allowed) + expect(response.status).toBe(405); + } + }); + }); + + describe('POST Method', function () { + it('should handle POST requests', async function () { + const response = await fetch(`${baseUrl}/items`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { id: number }; + expect(data.id).toBeDefined(); + }); + + it('should reject other methods on POST endpoint', async function () { + const methods = ['GET', 'PUT', 'DELETE', 'PATCH']; + + for (const method of methods) { + const response = await fetch(`${baseUrl}/post-only`, { + method: method, + }); + + expect(response.status).toBe(405); + } + }); + }); + + describe('PUT Method', function () { + it('should handle PUT requests', async function () { + const response = await fetch(`${baseUrl}/items/1`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Updated' }), + }); + + expect(response.status).toBe(200); + }); + + it('should reject other methods on PUT endpoint', async function () { + const methods = ['GET', 'POST', 'DELETE', 'PATCH']; + + for (const method of methods) { + const response = await fetch(`${baseUrl}/put-only`, { + method: method, + }); + + expect(response.status).toBe(405); + } + }); + }); + + describe('DELETE Method', function () { + it('should handle DELETE requests', async function () { + const response = await fetch(`${baseUrl}/items/1`, { + method: 'DELETE', + }); + + expect(response.status).toBe(204); + }); + + it('should reject other methods on DELETE endpoint', async function () { + const methods = ['GET', 'POST', 'PUT', 'PATCH']; + + for (const method of methods) { + const response = await fetch(`${baseUrl}/delete-only`, { + method: method, + }); + + expect(response.status).toBe(405); + } + }); + }); + + describe('PATCH Method', function () { + it('should handle PATCH requests', async function () { + const response = await fetch(`${baseUrl}/items/1`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Patched' }), + }); + + expect(response.status).toBe(200); + }); + + it('should reject other methods on PATCH endpoint', async function () { + const methods = ['GET', 'POST', 'PUT', 'DELETE']; + + for (const method of methods) { + const response = await fetch(`${baseUrl}/patch-only`, { + method: method, + }); + + expect(response.status).toBe(405); + } + }); + }); + + describe('Method Property', function () { + it('uses GET as default method', async function () { + const response = await fetch(`${baseUrl}/default-method`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { method: string }; + expect(data.method).toBe('get'); + }); + + it('should allow overriding the default method', async function () { + // Test that POST endpoint works + const postResponse = await fetch(`${baseUrl}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test' }), + }); + expect(postResponse.status).toBe(201); + + // Test that GET on same path works (different endpoint) + const getResponse = await fetch(`${baseUrl}/items`); + expect(getResponse.status).toBe(200); + }); + }); + + describe('Multiple Endpoints Same Path', function () { + it('should support different methods on same path', async function () { + const path = `${baseUrl}/items/1`; + + // GET should work + const getResponse = await fetch(path); + expect(getResponse.status).toBe(200); + + // PUT should work + const putResponse = await fetch(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test' }), + }); + expect(putResponse.status).toBe(200); + + // PATCH should work + const patchResponse = await fetch(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test' }), + }); + expect(patchResponse.status).toBe(200); + + // DELETE should work + const deleteResponse = await fetch(path, { method: 'DELETE' }); + expect(deleteResponse.status).toBe(204); + }); + }); +}); diff --git a/test/spec/endpoint/endpoint-request-response.server.ts b/test/spec/endpoint/endpoint-request-response.server.ts new file mode 100644 index 0000000..584141d --- /dev/null +++ b/test/spec/endpoint/endpoint-request-response.server.ts @@ -0,0 +1,195 @@ +import { + BaseApiRouter, + ApiRequest, + ApiResponse, + GetEndpoint, + PostEndpoint, + DeleteEndpoint, + BadRequestError, +} from '../../../src'; + +export class RequestResponseRouter extends BaseApiRouter { + override path = '/request-response'; + + override async routes() { + return [ + // Echo endpoint for body parsing tests + class extends PostEndpoint { + override path = '/echo'; + + override async handle(request: ApiRequest) { + return { received: request.body }; + } + }, + + // Headers endpoint + class extends GetEndpoint { + override path = '/headers'; + + override async handle(request: ApiRequest) { + return { headers: request.headers }; + } + }, + + // Custom response headers + class extends GetEndpoint { + override path = '/custom-headers'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.setHeader('X-Custom-Response', 'test-value'); + // Override the globally disabled X-Powered-By + response.setHeader('X-Powered-By', 'api-machine'); + return { message: 'Headers set' }; + } + }, + + // Cacheable endpoint + class extends GetEndpoint { + override path = '/cacheable'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.setHeader('Cache-Control', 'max-age=3600'); + return { cached: true }; + } + }, + + // Created status (201) + class extends PostEndpoint { + override path = '/created'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.status(201); + return { id: 1, name: request.body?.name }; + } + }, + + // No content (204) + class extends DeleteEndpoint { + override path = '/no-content'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.status(204); + return {}; + } + }, + + // Validation error (400) + class extends PostEndpoint { + override path = '/validate'; + + override async handle(): Promise { + throw new BadRequestError('Invalid data'); + } + }, + + // Request info + class extends PostEndpoint { + override path = '/request-info'; + + override async handle(request: ApiRequest) { + return { + method: request.method, + path: request.path, + }; + } + }, + + // Request info (GET) + class extends GetEndpoint { + override path = '/request-info'; + + override async handle(request: ApiRequest) { + return { + method: request.method, + path: request.path, + }; + } + }, + + // CORS headers + class extends GetEndpoint { + override path = '/cors-headers'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, DELETE, OPTIONS' + ); + response.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization' + ); + response.setHeader('Access-Control-Max-Age', '86400'); + return { cors: 'enabled' }; + } + }, + + // Custom content type + class extends GetEndpoint { + override path = '/custom-content-type'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.setHeader( + 'Content-Type', + 'application/vnd.api+json' + ); + return { data: { type: 'custom' } }; + } + }, + + // Redirect with location header + class extends GetEndpoint { + override path = '/redirect-header'; + override statusCode = 302; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + response.setHeader( + 'Location', + '/api/request-response/new-location' + ); + return {}; + } + }, + + // Cache validation headers + class extends GetEndpoint { + override path = '/cache-validation'; + + override async handle( + request: ApiRequest, + response: ApiResponse + ) { + const lastModified = new Date('2025-01-01T00:00:00Z'); + response.setHeader('ETag', '"abc123"'); + response.setHeader( + 'Last-Modified', + lastModified.toUTCString() + ); + return { data: 'cached content' }; + } + }, + ]; + } +} diff --git a/test/spec/endpoint/endpoint-request-response.spec.ts b/test/spec/endpoint/endpoint-request-response.spec.ts new file mode 100644 index 0000000..ec43ed5 --- /dev/null +++ b/test/spec/endpoint/endpoint-request-response.spec.ts @@ -0,0 +1,339 @@ +import 'jasmine'; +import { env } from '../../env'; + +describe('Request and Response Handling', function () { + const baseUrl = env.API_URL + '/request-response'; + + describe('Global Security Headers', function () { + it('should remove X-Powered-By header by default', async function () { + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(201); + expect(response.headers.get('X-Powered-By')).toBeNull(); + }); + + it('should set global security headers', async function () { + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(201); + + // Verify global security headers + expect(response.headers.get('X-Content-Type-Options')).toBe( + 'nosniff' + ); + expect(response.headers.get('X-Frame-Options')).toBe('DENY'); + expect(response.headers.get('X-XSS-Protection')).toBe( + '1; mode=block' + ); + }); + + it('should not set HSTS header by default', async function () { + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(201); + expect( + response.headers.get('Strict-Transport-Security') + ).toBeNull(); + }); + }); + + describe('Request Body Parsing', function () { + it('should parse JSON request body', async function () { + const requestBody = { + name: 'Test Item', + value: 42, + }; + + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + received: typeof requestBody; + }; + expect(data.received.name).toBe(requestBody.name); + expect(data.received.value).toBe(requestBody.value); + }); + + it('should handle nested JSON objects', async function () { + const requestBody = { + user: { + name: 'John', + age: 30, + }, + settings: { + notifications: true, + theme: 'dark', + }, + }; + + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + received: typeof requestBody; + }; + expect(data.received.user.name).toBe('John'); + expect(data.received.settings.theme).toBe('dark'); + }); + + it('should handle arrays in request body', async function () { + const requestBody = { + items: ['apple', 'banana', 'cherry'], + numbers: [1, 2, 3], + }; + + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + received: typeof requestBody; + }; + expect(data.received.items.length).toBe(3); + expect(data.received.items[0]).toBe('apple'); + expect(data.received.numbers[2]).toBe(3); + }); + + it('should handle empty request body', async function () { + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + received: Record; + }; + expect(Object.keys(data.received).length).toBe(0); + }); + + it('should handle large request bodies', async function () { + const largeArray = Array.from({ length: 100 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + })); + + const response = await fetch(`${baseUrl}/echo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ items: largeArray }), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + received: { items: typeof largeArray }; + }; + expect(data.received.items.length).toBe(100); + }); + }); + + describe('Request Headers', function () { + it('should access standard request headers', async function () { + const response = await fetch(`${baseUrl}/headers`, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'TestAgent/1.0', + }, + }); + + expect(response.status).toBe(200); + + const data = (await response.json()) as { + headers: Record; + }; + expect(data.headers['content-type']).toContain('application/json'); + expect(data.headers['accept']).toBe('application/json'); + }); + + it('should access custom request headers', async function () { + const response = await fetch(`${baseUrl}/headers`, { + headers: { + 'X-Custom-Header': 'custom-value', + 'X-Request-ID': '12345', + }, + }); + + expect(response.status).toBe(200); + + const data = (await response.json()) as { + headers: Record; + }; + expect(data.headers['x-custom-header']).toBe('custom-value'); + expect(data.headers['x-request-id']).toBe('12345'); + }); + }); + + describe('Response Headers', function () { + it('should set custom response headers', async function () { + const response = await fetch(`${baseUrl}/custom-headers`); + expect(response.status).toBe(200); + + const customHeader = response.headers.get('X-Custom-Response'); + expect(customHeader).toBe('test-value'); + + const poweredBy = response.headers.get('X-Powered-By'); + expect(poweredBy).toBe('api-machine'); + }); + + it('should set Content-Type header automatically', async function () { + const response = await fetch(`${baseUrl}/custom-headers`); + expect(response.status).toBe(200); + + const contentType = response.headers.get('Content-Type'); + expect(contentType).toContain('application/json'); + }); + + it('should set cache control headers', async function () { + const response = await fetch(`${baseUrl}/cacheable`); + expect(response.status).toBe(200); + + const cacheControl = response.headers.get('Cache-Control'); + expect(cacheControl).toBeDefined(); + expect(cacheControl).toContain('max-age'); + }); + + it('should set CORS headers', async function () { + const response = await fetch(`${baseUrl}/cors-headers`); + expect(response.status).toBe(200); + + // Verify CORS headers + expect(response.headers.get('Access-Control-Allow-Origin')).toBe( + '*' + ); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe( + 'GET, POST, PUT, DELETE, OPTIONS' + ); + expect(response.headers.get('Access-Control-Allow-Headers')).toBe( + 'Content-Type, Authorization' + ); + expect(response.headers.get('Access-Control-Max-Age')).toBe( + '86400' + ); + }); + + it('should set custom content-type header', async function () { + const response = await fetch(`${baseUrl}/custom-content-type`); + expect(response.status).toBe(200); + + // Verify custom content type is set + const contentType = response.headers.get('Content-Type'); + expect(contentType).toBe('application/vnd.api+json; charset=utf-8'); + }); + + it('should set location header for redirects', async function () { + const response = await fetch(`${baseUrl}/redirect-header`, { + redirect: 'manual', + }); + expect(response.status).toBe(302); + + // Verify location header + expect(response.headers.get('Location')).toBe( + '/api/request-response/new-location' + ); + }); + + it('should set ETag and Last-Modified headers', async function () { + const response = await fetch(`${baseUrl}/cache-validation`); + expect(response.status).toBe(200); + + // Verify cache validation headers + expect(response.headers.get('ETag')).toBe('"abc123"'); + expect(response.headers.get('Last-Modified')).toMatch( + /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/ + ); + }); + }); + + describe('Response Status Codes', function () { + it('should set custom status codes', async function () { + const response = await fetch(`${baseUrl}/created`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Item' }), + }); + + expect(response.status).toBe(201); + }); + + it('should handle no content responses', async function () { + const response = await fetch(`${baseUrl}/no-content`, { + method: 'DELETE', + }); + + expect(response.status).toBe(204); + }); + + it('should handle bad request responses', async function () { + const response = await fetch(`${baseUrl}/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invalid: 'data' }), + }); + + expect(response.status).toBe(400); + }); + }); + + describe('Request Method and Path', function () { + it('should provide access to request method', async function () { + const response = await fetch(`${baseUrl}/request-info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { method: string }; + expect(data.method).toBe('POST'); + }); + + it('should provide access to request path', async function () { + const response = await fetch(`${baseUrl}/request-info`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { path: string }; + expect(data.path).toContain('/request-info'); + }); + }); +}); diff --git a/test/spec/endpoint/endpoint-routing.server.ts b/test/spec/endpoint/endpoint-routing.server.ts new file mode 100644 index 0000000..460ef4f --- /dev/null +++ b/test/spec/endpoint/endpoint-routing.server.ts @@ -0,0 +1,99 @@ +import { + BaseApiRouter, + GetEndpoint, + ApiRequest, + RestServer, +} from '../../../src'; + +export class RoutingRouter extends BaseApiRouter { + override path = '/routing'; + + override async routes() { + return [ + // Single path parameter + class extends GetEndpoint { + override path = '/items/:id'; + + override async handle(request: ApiRequest) { + const id = parseInt(request.params['id'], 10); + return { id }; + } + }, + + // Multiple path parameters + class extends GetEndpoint { + override path = '/users/:userId/posts/:postId'; + + override async handle(request: ApiRequest) { + const userId = parseInt(request.params['userId'], 10); + const postId = parseInt(request.params['postId'], 10); + return { userId, postId }; + } + }, + + // Slug parameter + class extends GetEndpoint { + override path = '/slug/:slug'; + + override async handle(request: ApiRequest) { + return { slug: request.params['slug'] }; + } + }, + + // Encoded parameter + class extends GetEndpoint { + override path = '/encoded/:value'; + + override async handle(request: ApiRequest) { + return { value: request.params['value'] }; + } + }, + + // Query parameters + class extends GetEndpoint { + override path = '/search'; + + override async handle(request: ApiRequest) { + return { query: request.query }; + } + }, + + // Combined path and query params + class extends GetEndpoint { + override path = '/users/:userId/activity'; + + override async handle(request: ApiRequest) { + const userId = parseInt(request.params['userId'], 10); + return { userId, query: request.query }; + } + }, + + // Exact path matching + class extends GetEndpoint { + override path = '/exact'; + + override async handle() { + return { matched: 'exact' }; + } + }, + ]; + } +} + +export class NoPathEndpoint extends GetEndpoint { + // No path defined here + override async handle() { + return { message: 'No path defined' }; + } +} + +export class NoPathRouter extends BaseApiRouter { + // No path defined here + override async routes() { + return [NoPathEndpoint]; + } +} + +export class NoPathServer extends RestServer { + override router = NoPathRouter; +} diff --git a/test/spec/endpoint/endpoint-routing.spec.ts b/test/spec/endpoint/endpoint-routing.spec.ts new file mode 100644 index 0000000..652afa0 --- /dev/null +++ b/test/spec/endpoint/endpoint-routing.spec.ts @@ -0,0 +1,209 @@ +import 'jasmine'; +import { env } from '../../env'; +import { NoPathServer } from './endpoint-routing.server'; +import { OpenAPIObject } from 'auto-oas'; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 9999999; + +describe('Endpoint Routing', function () { + const baseUrl = env.API_URL + '/routing'; + + describe('Path Parameters', function () { + it('should handle single path parameter', async function () { + const itemId = '123'; + const response = await fetch(`${baseUrl}/items/${itemId}`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { id: number }; + expect(data.id).toBe(123); + }); + + it('should handle multiple path parameters', async function () { + const userId = '42'; + const postId = '7'; + const response = await fetch( + `${baseUrl}/users/${userId}/posts/${postId}` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + userId: number; + postId: number; + }; + expect(data.userId).toBe(42); + expect(data.postId).toBe(7); + }); + + it('supports path params with special characters', async function () { + const slug = 'hello-world'; + const response = await fetch(`${baseUrl}/slug/${slug}`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { slug: string }; + expect(data.slug).toBe(slug); + }); + + it('should decode URL-encoded path parameters', async function () { + const encodedValue = encodeURIComponent('hello world'); + const response = await fetch(`${baseUrl}/encoded/${encodedValue}`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { value: string }; + expect(data.value).toBe('hello world'); + }); + }); + + describe('Query Parameters', function () { + it('should handle single query parameter', async function () { + const response = await fetch(`${baseUrl}/search?q=test`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: Record; + }; + expect(data.query['q']).toBe('test'); + }); + + it('should handle multiple query parameters', async function () { + const response = await fetch( + `${baseUrl}/search?q=test&page=2&limit=10` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: Record; + }; + expect(data.query['q']).toBe('test'); + expect(data.query['page']).toBe('2'); + expect(data.query['limit']).toBe('10'); + }); + + it('supports query params with special characters', async function () { + const searchTerm = 'hello world!'; + const encoded = encodeURIComponent(searchTerm); + const response = await fetch(`${baseUrl}/search?q=${encoded}`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: Record; + }; + expect(data.query['q']).toBe(searchTerm); + }); + + it('should handle missing query parameters', async function () { + const response = await fetch(`${baseUrl}/search`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: Record; + }; + expect(Object.keys(data.query).length).toBe(0); + }); + + it('should handle array query parameters', async function () { + const response = await fetch( + `${baseUrl}/search?tags=javascript&tags=typescript` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: Record; + }; + // Express parses multiple same-name params as array + expect(data.query['tags']).toBeDefined(); + }); + }); + + describe('Combined Routing', function () { + it('supports path params and query params together', async function () { + const userId = '99'; + const response = await fetch( + `${baseUrl}/users/${userId}/activity?type=posts&limit=5` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + userId: number; + query: Record; + }; + expect(data.userId).toBe(99); + expect(data.query['type']).toBe('posts'); + expect(data.query['limit']).toBe('5'); + }); + }); + + describe('Route Matching', function () { + it('should match exact paths', async function () { + const response = await fetch(`${baseUrl}/exact`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { matched: string }; + expect(data.matched).toBe('exact'); + }); + + it('should return 404 for non-existent routes', async function () { + const response = await fetch(`${baseUrl}/does-not-exist`); + expect(response.status).toBe(404); + }); + + it('should handle trailing slashes consistently', async function () { + const withSlash = await fetch(`${baseUrl}/exact/`); + const withoutSlash = await fetch(`${baseUrl}/exact`); + + // Both should work (Express default behavior) + expect([200, 301, 404]).toContain(withSlash.status); + expect(withoutSlash.status).toBe(200); + }); + + it('should handle router without leading slash', async function () { + const response = await fetch( + env.API_URL + '/test-without-leading-slash/' + ); + expect(response.status).toBe(200); + }); + + it('should handle endpoint without leading slash', async function () { + const response = await fetch( + env.API_URL + '/test/no-leading-slash' + ); + expect(response.status).toBe(200); + }); + + it('should provide correct fullPath in endpoint', async function () { + const response = await fetch(env.API_URL + '/test/full-path'); + const { fullPath } = (await response.json()) as { + fullPath: string; + }; + + expect(fullPath).toBe('/test/full-path'); + expect(response.status).toBe(200); + }); + + it('supports no paths defined', async function () { + const noPathServer = new NoPathServer({ + port: 4040, + swaggerEnabled: true, + }); + await noPathServer.start(); + + const response = await fetch('http://localhost:4040/'); + expect(response.status).toBe(200); + + const body = (await response.json()) as { message: string }; + expect(body.message).toBe('No path defined'); + + const openapiResponse = await fetch( + 'http://localhost:4040/openapi.json' + ); + expect(openapiResponse.status).toBe(200); + + const openapi = (await openapiResponse.json()) as OpenAPIObject; + const pathObject = openapi.paths?.['/']?.get; + + expect(pathObject).toBeDefined(); + expect(pathObject?.summary).toBe('NoPath'); + expect(pathObject?.tags).toEqual(['NoPath']); + + await noPathServer.stop(); + }); + }); +}); diff --git a/test/spec/endpoint/health-check.server.ts b/test/spec/endpoint/health-check.server.ts new file mode 100644 index 0000000..0000127 --- /dev/null +++ b/test/spec/endpoint/health-check.server.ts @@ -0,0 +1,17 @@ +import { BaseApiRouter } from '../../../src'; +// eslint-disable-next-line max-len +import { HealthCheckEndpoint } from '../../../src'; + +export class HealthCheckRouter extends BaseApiRouter { + override path = '/health-check'; + + override async routes() { + return [ + HealthCheckEndpoint, + // Custom path example + class extends HealthCheckEndpoint { + override path = '/custom-health'; + }, + ]; + } +} diff --git a/test/spec/endpoint/health-check.spec.ts b/test/spec/endpoint/health-check.spec.ts new file mode 100644 index 0000000..2f9ed3e --- /dev/null +++ b/test/spec/endpoint/health-check.spec.ts @@ -0,0 +1,100 @@ +import 'jasmine'; +import { env } from '../../env'; + +describe('HealthCheckEndpoint', function () { + const baseUrl = env.API_URL + '/health-check'; + + describe('GET /health', function () { + it('should return health check response', async function () { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + expect(data.status).toBe('ok'); + expect(data.timestamp).toBeTruthy(); + expect(data.uptime).toBeTruthy(); + expect(data.environment).toBeTruthy(); + }); + + it('should return valid timestamp in ISO format', async function () { + const response = await fetch(`${baseUrl}/health`); + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + const timestamp = new Date(data.timestamp); + expect(timestamp.toISOString()).toBe(data.timestamp); + }); + + it('should return numeric uptime', async function () { + const response = await fetch(`${baseUrl}/health`); + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + expect(typeof data.uptime).toBe('number'); + expect(data.uptime).toBeGreaterThan(0); + }); + + it('should return environment value', async function () { + const response = await fetch(`${baseUrl}/health`); + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + expect(typeof data.environment).toBe('string'); + expect(data.environment).toBeTruthy(); + }); + + it('should default environment to development', async function () { + const originalEnv = process.env['NODE_ENV']; + delete process.env['NODE_ENV']; + + const response = await fetch(`${baseUrl}/health`); + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + expect(data.environment).toBe('development'); + + // Restore original value + if (originalEnv !== undefined) { + process.env['NODE_ENV'] = originalEnv; + } + }); + }); + + describe('Custom path', function () { + it('should work with custom path override', async function () { + const response = await fetch(`${baseUrl}/custom-health`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + status: string; + timestamp: string; + uptime: number; + environment: string; + }; + + expect(data.status).toBe('ok'); + }); + }); +}); diff --git a/test/spec/endpoint/query-params.server.ts b/test/spec/endpoint/query-params.server.ts new file mode 100644 index 0000000..f3c1897 --- /dev/null +++ b/test/spec/endpoint/query-params.server.ts @@ -0,0 +1,11 @@ +import { BaseApiRouter } from '../../../src'; +// eslint-disable-next-line max-len +import { QueryParamsEndpoint } from '../../../examples/complete-example/express-features/query-params-endpoint'; + +export class QueryParamsRouter extends BaseApiRouter { + override path = '/query-params'; + + override async routes() { + return [QueryParamsEndpoint]; + } +} diff --git a/test/spec/endpoint/query-params.spec.ts b/test/spec/endpoint/query-params.spec.ts new file mode 100644 index 0000000..f923c03 --- /dev/null +++ b/test/spec/endpoint/query-params.spec.ts @@ -0,0 +1,207 @@ +import 'jasmine'; +import { env } from '../../env'; + +describe('QueryParamsEndpoint', function () { + const baseUrl = env.API_URL + '/query-params'; + + describe('Query Parameter Parsing', function () { + it('should parse single query parameter', async function () { + const response = await fetch(`${baseUrl}/search?q=typescript`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe('typescript'); + expect(data.query.page).toBe(1); // default value + expect(data.query.limit).toBe(10); // default value + }); + + it('should parse multiple query parameters', async function () { + const response = await fetch( + `${baseUrl}/search?q=node&page=2&limit=20` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe('node'); + expect(data.query.page).toBe(2); + expect(data.query.limit).toBe(20); + }); + + it('should apply default values', async function () { + const response = await fetch(`${baseUrl}/search`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe(''); + expect(data.query.page).toBe(1); + expect(data.query.limit).toBe(10); + }); + + it('should handle special characters', async function () { + const searchTerm = 'hello world & friends'; + const encoded = encodeURIComponent(searchTerm); + const response = await fetch(`${baseUrl}/search?q=${encoded}`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe(searchTerm); + }); + + it('should convert page parameter to integer', async function () { + const response = await fetch(`${baseUrl}/search?page=5`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(typeof data.query.page).toBe('number'); + expect(data.query.page).toBe(5); + }); + + it('should convert limit parameter to integer', async function () { + const response = await fetch(`${baseUrl}/search?limit=50`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(typeof data.query.limit).toBe('number'); + expect(data.query.limit).toBe(50); + }); + + it('should handle numeric string params correctly', async function () { + const response = await fetch( + `${baseUrl}/search?q=123&page=3&limit=15` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe('123'); + expect(data.query.page).toBe(3); + expect(data.query.limit).toBe(15); + }); + + it('should include all query params in allParams', async function () { + const url = `${baseUrl}/search?q=test&page=1&limit=10&extra=val`; + const response = await fetch(url); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.allParams['q']).toBe('test'); + expect(data.allParams['page']).toBe('1'); + expect(data.allParams['limit']).toBe('10'); + expect(data.allParams['extra']).toBe('val'); + }); + + it('should return results array', async function () { + const response = await fetch(`${baseUrl}/search?q=test`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(Array.isArray(data.results)).toBe(true); + expect(data.results.length).toBe(2); + expect(data.results[0]).toEqual({ id: 1, title: 'Result 1' }); + expect(data.results[1]).toEqual({ id: 2, title: 'Result 2' }); + }); + + it('should handle empty search parameter', async function () { + const response = await fetch(`${baseUrl}/search?q=`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe(''); + }); + + it('should handle zero values for params', async function () { + const response = await fetch(`${baseUrl}/search?page=0&limit=0`); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.page).toBe(0); + expect(data.query.limit).toBe(0); + }); + + it('should handle large page numbers', async function () { + const response = await fetch( + `${baseUrl}/search?page=999&limit=100` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.page).toBe(999); + expect(data.query.limit).toBe(100); + }); + + it('should handle URL-encoded special characters', async function () { + const searchTerm = 'test+query'; + const response = await fetch( + `${baseUrl}/search?q=${encodeURIComponent(searchTerm)}` + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + query: { page: number; limit: number; search: string }; + allParams: Record; + results: { id: number; title: string }[]; + }; + + expect(data.query.search).toBe(searchTerm); + }); + }); +}); diff --git a/test/spec/endpoint/validation.server.ts b/test/spec/endpoint/validation.server.ts new file mode 100644 index 0000000..6d90366 --- /dev/null +++ b/test/spec/endpoint/validation.server.ts @@ -0,0 +1,161 @@ +import { + ApiRequest, + BaseApiRouter, + GetEndpoint, + PostEndpoint, +} from '../../../src/router'; +import { + ObjectSanitizer, + EmailValidator, + LengthValidator, + StringToNumberValSan, + MinValidator, + ComposedValSan, + TrimSanitizer, +} from 'valsan'; +// eslint-disable-next-line max-len +import { NameValSan } from '../../../examples/complete-example/users/name-valsan'; +import { ApiResponseData } from '../../../src/router/endpoint'; + +export class ValidationRouter extends BaseApiRouter { + override path = '/validation'; + + override async routes() { + return [ + class TestHeadersEndpoint extends PostEndpoint { + override path = '/headers'; + + override headers = new ObjectSanitizer({ + 'x-custom-header': new ComposedValSan([ + new TrimSanitizer(), + new LengthValidator({ minLength: 5 }), + ]), + }); + + override async handle( + request: ApiRequest + ): Promise { + return { + received: + request.headers['x-custom-header'] || + request.headers['X-Custom-Header'], + }; + } + }, + class TestQueryParamsEndpoint extends GetEndpoint { + override path = '/query-params'; + + override query = new ObjectSanitizer({ + search: new ComposedValSan([ + new TrimSanitizer(), + new LengthValidator({ minLength: 3 }), + ]), + }); + + override async handle( + request: ApiRequest + ): Promise { + return { received: request.query['search'] }; + } + }, + class TestRouteParamsEndpoint extends PostEndpoint { + override path = '/route-params/:itemId'; + + override params = new ObjectSanitizer({ + itemId: new ComposedValSan([ + new StringToNumberValSan(), + new MinValidator({ min: 1 }), + ]), + }); + + override async handle( + request: ApiRequest + ): Promise { + return { received: request.params['itemId'] }; + } + }, + class TestBodyEndpoint extends PostEndpoint { + override path = '/body'; + + override body = new ObjectSanitizer({ + name: new NameValSan(), + email: new EmailValidator(), + }); + + override async handle( + request: ApiRequest + ): Promise { + return { + name: request.body.name, + email: request.body.email, + }; + } + }, + class TestAllValidationEndpoint extends PostEndpoint { + override path = '/all/:userId'; + + override body = new ObjectSanitizer({ + name: new NameValSan(), + email: new EmailValidator(), + }); + + override query = new ObjectSanitizer({ + age: new ComposedValSan([ + new StringToNumberValSan(), + new MinValidator({ min: 0 }), + ]), + }); + + override params = new ObjectSanitizer({ + userId: new ComposedValSan([ + new StringToNumberValSan(), + new MinValidator({ min: 1 }), + ]), + }); + + override headers = new ObjectSanitizer({ + 'X-User-Token': new ComposedValSan([ + new TrimSanitizer(), + new LengthValidator({ minLength: 10 }), + ]), + }); + + async handle(request: ApiRequest) { + return { + name: request.body.name, + email: request.body.email, + age: request.query['age'], + userId: request.params['userId'], + userToken: + request.headers['x-user-token'] || + request.headers['X-User-Token'], + }; + } + }, + class OptionalValidationEndpoint extends GetEndpoint { + override path = '/optional-validation'; + override query = new ObjectSanitizer({ + name: new ComposedValSan( + [ + new TrimSanitizer(), + new LengthValidator({ minLength: 3 }), + ], + { isOptional: true } + ), + email: new ComposedValSan( + [new TrimSanitizer(), new EmailValidator()], + { isOptional: true } + ), + }); + + override async handle( + request: ApiRequest + ): Promise { + return { + request: request.query, + }; + } + }, + ]; + } +} diff --git a/test/spec/endpoint/validation.spec.ts b/test/spec/endpoint/validation.spec.ts new file mode 100644 index 0000000..984abd1 --- /dev/null +++ b/test/spec/endpoint/validation.spec.ts @@ -0,0 +1,255 @@ +import 'jasmine'; +import { env } from '../../env'; +import { ValidationError } from 'valsan'; +import { ErrorResponse } from '../../../src'; + +describe('Validation', function () { + const baseUrl = env.API_URL + '/validation'; + + it('should create user with valid data', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Alice', + email: 'alice@example.com', + }), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + name: string; + email: string; + }; + + expect(data.name).toBe('Alice'); + expect(data.email).toBe('alice@example.com'); + }); + + it('should reject missing name', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'alice@example.com' }), + }); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + }); + + it('should reject invalid email', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Alice', email: 'not-an-email' }), + }); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + }); + + it('should reject empty name', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '', email: 'alice@example.com' }), + }); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + }); + + it('should return helpful error', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: ' ', email: 'invalid-email' }), + }); + + expect(response.status).toBe(422); + const data = (await response.json()) as ErrorResponse; + + expect(data.error).toBe('UnprocessableEntityError'); + expect(data.message).toBe('Validation failed'); + expect(data.timestamp).toBeDefined(); + expect(data.options).toBeDefined(); + expect(data.options.details).toBeDefined(); + + const details = data.options.details as ValidationError[]; + expect(details.length).toBe(2); + + expect(details[0].field).toBe('name'); + expect(details[0].code).toBe('string_min_len'); + expect(details[0].message).toBe( + 'Input must be at least 1 character(s)' + ); + expect(details[0].context).toEqual({ minLength: 1 }); + + expect(details[1].field).toBe('email'); + expect(details[1].code).toBe('email_format'); + expect(details[1].message).toBe('Input is not a valid email address'); + }); + + it('should sanitize query params', async function () { + const response = await fetch( + baseUrl + '/query-params?age=25&search=test+search ', + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as { received: string }; + + // The search query param should be trimmed + expect(data.received).toBe('test search'); + }); + + it('should sanitize route params', async function () { + const response = await fetch(baseUrl + '/route-params/42', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(201); + const data = (await response.json()) as { received: number }; + expect(data.received).toBe(42); + }); + + it('should sanitize headers', async function () { + const response = await fetch(baseUrl + '/headers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom header value ', + }, + }); + + expect(response.status).toBe(201); + const data = (await response.json()) as { received: string }; + + // The header value should be trimmed + expect(data.received).toBe('custom header value'); + }); + + it('should sanitize the body (trims name)', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: ' Alice ', + email: 'alice@example.com', + }), + }); + + expect(response.status).toBe(201); + + const data = (await response.json()) as { + name: string; + email: string; + }; + + // The name should be trimmed + expect(data.name).toBe('Alice'); + expect(data.email).toBe('alice@example.com'); + }); + + it('should reject invalid query params', async function () { + const response = await fetch( + baseUrl + '/query-params?age=not-a-number', + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + + const details = data.options.details as ValidationError[]; + expect(details.some((d) => d.field === 'search')).toBeTrue(); + }); + + it('should reject invalid router params', async function () { + const url = baseUrl + '/route-params/not-a-number'; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + + const details = data.options.details as ValidationError[]; + expect(details.some((d) => d.field === 'itemId')).toBeTrue(); + }); + + it('should reject invalid headers', async function () { + const response = await fetch(baseUrl + '/headers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + + const details = data.options.details as ValidationError[]; + expect(details.some((d) => d.field === 'x-custom-header')).toBeTrue(); + }); + + it('should reject invalid body', async function () { + const response = await fetch(baseUrl + '/body', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '', email: 'not-an-email' }), + }); + expect(response.status).toBe(422); + + const data = (await response.json()) as ErrorResponse; + expect(data.message).toBe('Validation failed'); + expect(data.options.details).toBeDefined(); + + const details = data.options.details as ValidationError[]; + expect(details.some((d) => d.field === 'name')).toBeTrue(); + expect(details.some((d) => d.field === 'email')).toBeTrue(); + }); + + it('should support optional query params', async function () { + const response = await fetch(baseUrl + '/optional-validation', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + request: Record; + }; + + expect(data).toEqual({ + request: {}, + }); + }); +}); diff --git a/test/spec/http-errors/express-errors.spec.ts b/test/spec/http-errors/express-errors.spec.ts new file mode 100644 index 0000000..d1c3224 --- /dev/null +++ b/test/spec/http-errors/express-errors.spec.ts @@ -0,0 +1,26 @@ +import { env } from '../../env'; + +describe('Express Errors', () => { + it('should map 400 errors correctly', async () => { + const result = await fetch(env.API_URL + '/test', { + method: 'POST', + body: 'invalid-json', + headers: { + 'Content-Type': 'application/json', + }, + }); + + expect(result.status).toBe(400); + expect(result.statusText).toBe('Bad Request'); + + const body = (await result.json()) as { + error: string; + message: string; + }; + + expect(body).toEqual({ + error: 'SyntaxError', + message: 'Unexpected token \'i\', "invalid-json" is not valid JSON', + }); + }); +}); diff --git a/test/spec/http-errors/http-errors.spec.ts b/test/spec/http-errors/http-errors.spec.ts new file mode 100644 index 0000000..6472e12 --- /dev/null +++ b/test/spec/http-errors/http-errors.spec.ts @@ -0,0 +1,513 @@ +import { + HTTPError, + BadRequestError, + UnauthorizedError, + PaymentRequiredError, + ForbiddenError, + NotFoundError, + MethodNotAllowedError, + NotAcceptableError, + ProxyAuthenticationRequiredError, + RequestTimeoutError, + ConflictError, + GoneError, + LengthRequiredError, + PreconditionFailedError, + PayloadTooLargeError, + URITooLongError, + UnsupportedMediaTypeError, + RangeNotSatisfiableError, + ExpectationFailedError, + ImATeapotError, + MisdirectedRequestError, + UnprocessableEntityError, + LockedError, + FailedDependencyError, + TooEarlyError, + UpgradeRequiredError, + PreconditionRequiredError, + TooManyRequestsError, + RequestHeaderFieldsTooLargeError, + UnavailableForLegalReasonsError, +} from '../../../src/error'; + +describe('HTTPError Classes', () => { + describe('HTTPError base class', () => { + class TestError extends HTTPError { + public override getStatusCode(): number { + return 499; + } + } + + it('should create error with message only', () => { + const error = new TestError('Test error'); + + expect(error.message).toBe('Test error'); + expect(error.getStatusCode()).toBe(499); + expect(error.headers).toEqual({}); + expect(error.details).toBeUndefined(); + }); + + it('should create error with message and options', () => { + const error = new TestError('Test error', { + headers: { 'X-Custom': 'header' }, + details: { foo: 'bar' }, + }); + + expect(error.message).toBe('Test error'); + expect(error.getStatusCode()).toBe(499); + expect(error.headers).toEqual({ 'X-Custom': 'header' }); + expect(error.details).toEqual({ foo: 'bar' }); + }); + + it('should serialize to JSON correctly', () => { + const error = new TestError('Test error', { + details: { code: 'TEST_ERROR' }, + }); + + const json = error.getResponseJson(); + expect(json.error).toBe('TestError'); + expect(json.message).toBe('Test error'); + expect(json.options.details).toEqual({ code: 'TEST_ERROR' }); + expect(json.timestamp).toBeDefined(); + }); + }); + + describe('Simple error classes', () => { + it('BadRequestError should have correct status code', () => { + const error = new BadRequestError('Invalid input'); + expect(error.getStatusCode()).toBe(400); + expect(error.message).toBe('Invalid input'); + }); + + it('PaymentRequiredError should have correct status code', () => { + const error = new PaymentRequiredError('Payment required'); + expect(error.getStatusCode()).toBe(402); + expect(error.message).toBe('Payment required'); + }); + + it('PaymentRequiredError should use default message', () => { + const error = new PaymentRequiredError(); + expect(error.getStatusCode()).toBe(402); + expect(error.message).toBe('Payment Required'); + }); + + it('ForbiddenError should have correct status code', () => { + const error = new ForbiddenError(); + expect(error.getStatusCode()).toBe(403); + expect(error.message).toBe('Forbidden'); + }); + + it('NotFoundError should have correct status code', () => { + const error = new NotFoundError('Resource not found', { + details: { resource: 'user', id: 123 }, + }); + expect(error.getStatusCode()).toBe(404); + expect(error.message).toBe('Resource not found'); + expect(error.details).toEqual({ resource: 'user', id: 123 }); + }); + + it('NotAcceptableError should have correct status code', () => { + const error = new NotAcceptableError('Not acceptable'); + expect(error.getStatusCode()).toBe(406); + }); + + it('NotAcceptableError should use default message', () => { + const error = new NotAcceptableError(); + expect(error.message).toBe('Not Acceptable'); + }); + + it('RequestTimeoutError should have correct status code', () => { + const error = new RequestTimeoutError(); + expect(error.getStatusCode()).toBe(408); + expect(error.message).toBe('Request Timeout'); + }); + + it('ConflictError should have correct status code', () => { + const error = new ConflictError('Resource already exists'); + expect(error.getStatusCode()).toBe(409); + }); + + it('ConflictError should use default message', () => { + const error = new ConflictError(); + expect(error.message).toBe('Conflict'); + }); + + it('GoneError should have correct status code', () => { + const error = new GoneError('Resource is gone'); + expect(error.getStatusCode()).toBe(410); + }); + + it('GoneError should use default message', () => { + const error = new GoneError(); + expect(error.message).toBe('Gone'); + }); + + it('LengthRequiredError should have correct status code', () => { + const error = new LengthRequiredError(); + expect(error.getStatusCode()).toBe(411); + expect(error.message).toBe('Length Required'); + }); + + it('PreconditionFailedError should have correct status code', () => { + const error = new PreconditionFailedError('Precondition failed'); + expect(error.getStatusCode()).toBe(412); + }); + + it('PreconditionFailedError should use default message', () => { + const error = new PreconditionFailedError(); + expect(error.message).toBe('Precondition Failed'); + }); + + it('PayloadTooLargeError should have correct status code', () => { + const error = new PayloadTooLargeError(); + expect(error.getStatusCode()).toBe(413); + expect(error.message).toBe('Payload Too Large'); + }); + + it('URITooLongError should have correct status code', () => { + const error = new URITooLongError('URI too long'); + expect(error.getStatusCode()).toBe(414); + }); + + it('URITooLongError should use default message', () => { + const error = new URITooLongError(); + expect(error.message).toBe('URI Too Long'); + }); + + it('ExpectationFailedError should have correct status code', () => { + const error = new ExpectationFailedError(); + expect(error.getStatusCode()).toBe(417); + expect(error.message).toBe('Expectation Failed'); + }); + + it('ImATeapotError should have correct status code', () => { + const error = new ImATeapotError(); + expect(error.getStatusCode()).toBe(418); + expect(error.message).toBe('I\'m a teapot'); + }); + + it('MisdirectedRequestError should have correct status code', () => { + const error = new MisdirectedRequestError('Misdirected request'); + expect(error.getStatusCode()).toBe(421); + }); + + it('MisdirectedRequestError should use default message', () => { + const error = new MisdirectedRequestError(); + expect(error.message).toBe('Misdirected Request'); + }); + + it('UnprocessableEntityError should have correct status code', () => { + const error = new UnprocessableEntityError('Validation failed', { + details: { errors: ['field1', 'field2'] }, + }); + expect(error.getStatusCode()).toBe(422); + expect(error.details).toEqual({ errors: ['field1', 'field2'] }); + }); + + it('UnprocessableEntityError should use default message', () => { + const error = new UnprocessableEntityError(); + expect(error.message).toBe('Unprocessable Entity'); + }); + + it('LockedError should have correct status code', () => { + const error = new LockedError(); + expect(error.getStatusCode()).toBe(423); + expect(error.message).toBe('Locked'); + }); + + it('FailedDependencyError should have correct status code', () => { + const error = new FailedDependencyError('Dependency failed'); + expect(error.getStatusCode()).toBe(424); + }); + + it('FailedDependencyError should use default message', () => { + const error = new FailedDependencyError(); + expect(error.message).toBe('Failed Dependency'); + }); + + it('TooEarlyError should have correct status code', () => { + const error = new TooEarlyError(); + expect(error.getStatusCode()).toBe(425); + expect(error.message).toBe('Too Early'); + }); + + it('PreconditionRequiredError should have correct status code', () => { + const error = new PreconditionRequiredError( + 'Precondition required' + ); + expect(error.getStatusCode()).toBe(428); + }); + + it('PreconditionRequiredError should use default message', () => { + const error = new PreconditionRequiredError(); + expect(error.message).toBe('Precondition Required'); + }); + + it('RequestHeaderFieldsTooLargeError has correct status code', () => { + const error = new RequestHeaderFieldsTooLargeError(); + expect(error.getStatusCode()).toBe(431); + expect(error.message).toBe('Request Header Fields Too Large'); + }); + + it('UnavailableForLegalReasonsError has correct status code', () => { + const error = new UnavailableForLegalReasonsError('Blocked by law'); + expect(error.getStatusCode()).toBe(451); + }); + + it('UnavailableForLegalReasonsError should use default message', () => { + const error = new UnavailableForLegalReasonsError(); + expect(error.message).toBe('Unavailable For Legal Reasons'); + }); + }); + + describe('UnauthorizedError', () => { + it('should use default realm and scheme', () => { + const error = new UnauthorizedError(); + expect(error.getStatusCode()).toBe(401); + expect(error.headers['WWW-Authenticate']).toBe( + 'Bearer realm="Access to the resource"' + ); + }); + + it('should use custom realm', () => { + const error = new UnauthorizedError('Invalid token', { + realm: 'API', + }); + expect(error.headers['WWW-Authenticate']).toBe( + 'Bearer realm="API"' + ); + }); + + it('should use custom scheme', () => { + const error = new UnauthorizedError('Invalid credentials', { + realm: 'Admin', + scheme: 'Basic', + }); + expect(error.headers['WWW-Authenticate']).toBe( + 'Basic realm="Admin"' + ); + }); + + it('should support additional details', () => { + const error = new UnauthorizedError('Token expired', { + realm: 'API', + details: { expiredAt: '2024-01-01' }, + }); + expect(error.details).toEqual({ expiredAt: '2024-01-01' }); + }); + }); + + describe('MethodNotAllowedError', () => { + it('should create error without allowed methods', () => { + const error = new MethodNotAllowedError(); + expect(error.getStatusCode()).toBe(405); + expect(error.headers['Allow']).toBeUndefined(); + }); + + it('should include Allow header with allowed methods', () => { + const error = new MethodNotAllowedError('Method not supported', { + allowedMethods: ['GET', 'POST', 'PUT'], + }); + expect(error.getStatusCode()).toBe(405); + expect(error.headers['Allow']).toBe('GET, POST, PUT'); + }); + + it('should support additional details', () => { + const error = new MethodNotAllowedError('DELETE not allowed', { + allowedMethods: ['GET', 'POST'], + details: { attemptedMethod: 'DELETE' }, + }); + expect(error.details).toEqual({ attemptedMethod: 'DELETE' }); + }); + }); + + describe('TooManyRequestsError', () => { + it('should create error without retry-after', () => { + const error = new TooManyRequestsError(); + expect(error.getStatusCode()).toBe(429); + expect(error.headers['Retry-After']).toBeUndefined(); + }); + + it('should include Retry-After header', () => { + const error = new TooManyRequestsError('Rate limit exceeded', { + retryAfter: 60, + }); + expect(error.getStatusCode()).toBe(429); + expect(error.headers['Retry-After']).toBe('60'); + }); + + it('should support additional details', () => { + const error = new TooManyRequestsError('Too many requests', { + retryAfter: 120, + details: { limit: 100, current: 150 }, + }); + expect(error.details).toEqual({ limit: 100, current: 150 }); + }); + }); + + describe('UnsupportedMediaTypeError', () => { + it('should create error without accepted types', () => { + const error = new UnsupportedMediaTypeError(); + expect(error.getStatusCode()).toBe(415); + expect(error.headers['Accept']).toBeUndefined(); + }); + + it('should include Accept header with accepted types', () => { + const error = new UnsupportedMediaTypeError( + 'Invalid content type', + { + acceptedTypes: ['application/json', 'application/xml'], + } + ); + expect(error.getStatusCode()).toBe(415); + expect(error.headers['Accept']).toBe( + 'application/json, application/xml' + ); + }); + }); + + describe('RangeNotSatisfiableError', () => { + it('should create error without content range', () => { + const error = new RangeNotSatisfiableError(); + expect(error.getStatusCode()).toBe(416); + expect(error.headers['Content-Range']).toBeUndefined(); + }); + + it('should include Content-Range header', () => { + const error = new RangeNotSatisfiableError('Invalid range', { + contentRange: 'bytes */1000', + }); + expect(error.getStatusCode()).toBe(416); + expect(error.headers['Content-Range']).toBe('bytes */1000'); + }); + }); + + describe('ProxyAuthenticationRequiredError', () => { + it('should use default realm', () => { + const error = new ProxyAuthenticationRequiredError(); + expect(error.getStatusCode()).toBe(407); + expect(error.headers['Proxy-Authenticate']).toBe( + 'Basic realm="Proxy"' + ); + }); + + it('should use custom realm', () => { + const error = new ProxyAuthenticationRequiredError( + 'Proxy auth required', + { realm: 'Corporate Proxy' } + ); + expect(error.headers['Proxy-Authenticate']).toBe( + 'Basic realm="Corporate Proxy"' + ); + }); + }); + + describe('UpgradeRequiredError', () => { + it('should use default protocol', () => { + const error = new UpgradeRequiredError(); + expect(error.getStatusCode()).toBe(426); + expect(error.headers['Upgrade']).toBe('TLS/1.0'); + }); + + it('should use custom protocol', () => { + const error = new UpgradeRequiredError('Please upgrade', { + upgradeProtocol: 'HTTP/2.0', + }); + expect(error.headers['Upgrade']).toBe('HTTP/2.0'); + }); + }); + + describe('Error inheritance and type checking', () => { + it('all error classes should extend HTTPError', () => { + const errors = [ + new BadRequestError(), + new UnauthorizedError(), + new ForbiddenError(), + new NotFoundError(), + new MethodNotAllowedError(), + ]; + + errors.forEach((error) => { + expect(error instanceof HTTPError).toBe(true); + expect(error instanceof Error).toBe(true); + }); + }); + + it('should have proper error names', () => { + expect(new BadRequestError().name).toBe('BadRequestError'); + expect(new UnauthorizedError().name).toBe('UnauthorizedError'); + expect(new NotFoundError().name).toBe('NotFoundError'); + }); + }); + + describe('Custom headers and details combination', () => { + it('should merge custom headers with error-specific headers', () => { + const error = new UnauthorizedError('Token invalid', { + realm: 'API', + headers: { + 'X-Request-Id': '123', + 'X-Custom': 'value', + }, + details: { reason: 'expired' }, + }); + + expect(error.headers).toEqual({ + 'WWW-Authenticate': 'Bearer realm="API"', + 'X-Request-Id': '123', + 'X-Custom': 'value', + }); + expect(error.details).toEqual({ reason: 'expired' }); + }); + + it('should allow user headers to override default headers', () => { + const error = new UnauthorizedError('Custom auth', { + realm: 'API', + headers: { + 'WWW-Authenticate': 'Custom scheme', + }, + }); + + expect(error.headers['WWW-Authenticate']).toBe('Custom scheme'); + }); + + it('should merge custom headers with MethodNotAllowedError', () => { + const error = new MethodNotAllowedError('Not allowed', { + allowedMethods: ['GET', 'POST'], + headers: { 'X-API-Version': '1.0' }, + }); + + expect(error.headers).toEqual({ + Allow: 'GET, POST', + 'X-API-Version': '1.0', + }); + }); + + it('should allow user to override Allow header', () => { + const error = new MethodNotAllowedError('Not allowed', { + allowedMethods: ['GET', 'POST'], + headers: { Allow: 'GET, POST, PUT, DELETE' }, + }); + + expect(error.headers['Allow']).toBe('GET, POST, PUT, DELETE'); + }); + + it('should allow user to override Upgrade header', () => { + const error = new UpgradeRequiredError('Upgrade needed', { + upgradeProtocol: 'HTTP/2.0', + headers: { Upgrade: 'HTTP/3.0' }, + }); + + expect(error.headers['Upgrade']).toBe('HTTP/3.0'); + }); + + it('should allow user to override Retry-After header', () => { + const error = new TooManyRequestsError('Rate limited', { + retryAfter: 60, + headers: { 'Retry-After': '3600' }, + }); + + expect(error.options.headers?.['Retry-After']).toBe('3600'); + }); + }); +}); diff --git a/test/spec/index.spec.ts b/test/spec/index.spec.ts index 70356df..d252203 100644 --- a/test/spec/index.spec.ts +++ b/test/spec/index.spec.ts @@ -1,8 +1,10 @@ import 'jasmine'; -import * as index from '../../src'; +import { server } from '../test-server'; -describe('ts-rest', () => { - it('exports a', () => { - expect(index.a).toBeTrue(); - }); +beforeAll(async () => { + await server.start(); +}); + +afterAll(async () => { + await server.stop(); }); diff --git a/test/spec/oas-authentication.spec.ts b/test/spec/oas-authentication.spec.ts new file mode 100644 index 0000000..1d1f235 --- /dev/null +++ b/test/spec/oas-authentication.spec.ts @@ -0,0 +1,534 @@ +import 'jasmine'; +import { OasEndpointConverter, OasRestServerConverter } from '../../src/oas'; +// eslint-disable-next-line max-len +import { BearerAuthenticationScheme } from '../../src/authentication/schemes/bearer-authentication-scheme'; +import { BaseApiEndpoint } from '../../src/router/endpoint'; + +describe('OAS Authentication Integration', () => { + describe('OasEndpointConverter with authentication', () => { + it('adds security requirement when auth present', () => { + const converter = new OasEndpointConverter(); + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'BearerAuth', + }); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'testEndpoint', + description: 'Test endpoint', + getTag: () => 'tests', + getErrors: () => ({}), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any, auth); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.security).toBeDefined(); + expect(operation.security).toEqual([{ BearerAuth: [] }]); + }); + + it('adds empty security array when auth is null (public)', () => { + const converter = new OasEndpointConverter(); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'publicEndpoint', + description: 'Public endpoint', + getTag: () => 'public', + getErrors: () => ({}), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any, null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.security).toBeDefined(); + expect(operation.security).toEqual([]); + }); + + it('omits security when auth undefined (inherits)', () => { + const converter = new OasEndpointConverter(); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'inheritedEndpoint', + description: 'Inherited auth endpoint', + getTag: () => 'inherited', + getErrors: () => ({}), + }; + + const path = converter.getOpenApiPath( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fakeEndpoint as any, + undefined + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.security).toBeUndefined(); + }); + + it('includes security with custom scheme name', () => { + const converter = new OasEndpointConverter(); + const customAuth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'AdminBearer', + }); + + const fakeEndpoint = { + method: 'post', + statusCode: 201, + getName: () => 'adminEndpoint', + description: 'Admin endpoint', + getTag: () => 'admin', + getErrors: () => ({}), + }; + + const path = converter.getOpenApiPath( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fakeEndpoint as any, + customAuth + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['post']; + + expect(operation.security).toEqual([{ AdminBearer: [] }]); + }); + + it('preserves other endpoint properties', () => { + const converter = new OasEndpointConverter(); + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'testEndpoint', + description: 'Test description', + getTag: () => 'TestTag', + getErrors: () => ({}), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any, auth); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.summary).toBe('testEndpoint'); + expect(operation.description).toBe('Test description'); + expect(operation.tags).toEqual(['TestTag']); + expect(operation.security).toEqual([{ BearerAuth: [] }]); + }); + }); + + describe('OasRestServerConverter with authentication', () => { + it('collects security schemes from endpoints', async () => { + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'BearerAuth', + }); + + class TestEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test'; + override fullPath = '/test'; + + override getEffectiveAuthentication() { + return auth; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const converter = new OasRestServerConverter(); + const endpoint = new TestEndpoint(); + + await converter.convertEndpoint(endpoint); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemes = (converter as any).securitySchemes; + expect(schemes['BearerAuth']).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((schemes['BearerAuth'] as any).type).toBe('http'); + }); + + it('avoids duplicate security schemes', async () => { + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'SharedAuth', + }); + + class Endpoint1 extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test1'; + override fullPath = '/test1'; + + override getEffectiveAuthentication() { + return auth; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + class Endpoint2 extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'post' as any; + override path = '/test2'; + override fullPath = '/test2'; + + override getEffectiveAuthentication() { + return auth; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const converter = new OasRestServerConverter(); + await converter.convertEndpoint(new Endpoint1()); + await converter.convertEndpoint(new Endpoint2()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemes = (converter as any).securitySchemes; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(Object.keys(schemes).length).toBe(1); + expect(schemes['SharedAuth']).toBeDefined(); + }); + + it('handles public endpoints (null auth)', async () => { + class PublicEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/public'; + override fullPath = '/public'; + + override getEffectiveAuthentication() { + return null; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const converter = new OasRestServerConverter(); + await converter.convertEndpoint(new PublicEndpoint()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemes = (converter as any).securitySchemes; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(Object.keys(schemes).length).toBe(0); + }); + + it('handles endpoints without auth (inherited)', async () => { + class InheritedAuthEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/inherited'; + override fullPath = '/inherited'; + + override getEffectiveAuthentication() { + return undefined; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const converter = new OasRestServerConverter(); + await converter.convertEndpoint(new InheritedAuthEndpoint()); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemes = (converter as any).securitySchemes; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(Object.keys(schemes).length).toBe(0); + }); + + it('adds security schemes to components', async () => { + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'BearerAuth', + description: 'JWT Bearer Authentication', + }); + + class TestEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test'; + override fullPath = '/test'; + + override getEffectiveAuthentication() { + return auth; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const mockServer = { + name: 'Test Server', + version: '1.0.0', + description: 'Test', + routerInstance: { + registeredRoutes: [new TestEndpoint()], + }, + authentication: undefined, + }; + + const converter = new OasRestServerConverter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec = await converter.getOpenApiSpec(mockServer as any); + + expect(spec.components?.securitySchemes).toBeDefined(); + expect( + spec.components?.securitySchemes?.['BearerAuth'] + ).toBeDefined(); + }); + + it('sets global security with server auth', async () => { + const serverAuth = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'GlobalAuth', + }); + + class TestEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test'; + override fullPath = '/test'; + + override getEffectiveAuthentication() { + return undefined; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const mockServer = { + name: 'Test Server', + version: '1.0.0', + description: 'Test', + routerInstance: { + registeredRoutes: [new TestEndpoint()], + }, + authentication: serverAuth, + }; + + const converter = new OasRestServerConverter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec = await converter.getOpenApiSpec(mockServer as any); + + expect(spec.security).toBeDefined(); + expect(spec.security).toEqual([{ GlobalAuth: [] }]); + }); + + it('excludes empty security without server auth', async () => { + class TestEndpoint extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test'; + override fullPath = '/test'; + + override getEffectiveAuthentication() { + return undefined; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const mockServer = { + name: 'Test Server', + version: '1.0.0', + description: 'Test', + routerInstance: { + registeredRoutes: [new TestEndpoint()], + }, + authentication: undefined, + }; + + const converter = new OasRestServerConverter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec = await converter.getOpenApiSpec(mockServer as any); + + expect(spec.security).toEqual([]); + }); + + it('collects multiple different auth schemes', async () => { + const auth1 = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'BearerAuth', + }); + + const auth2 = new BearerAuthenticationScheme({ + checkToken: async () => true, + schemeName: 'AdminBearer', + }); + + class Endpoint1 extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'get' as any; + override path = '/test1'; + override fullPath = '/test1'; + + override getEffectiveAuthentication() { + return auth1; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + class Endpoint2 extends BaseApiEndpoint { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override method = 'post' as any; + override path = '/test2'; + override fullPath = '/test2'; + + override getEffectiveAuthentication() { + return auth2; + } + + override getErrors() { + return {}; + } + + override async handle() { + return {}; + } + } + + const mockServer = { + name: 'Test Server', + version: '1.0.0', + description: 'Test', + routerInstance: { + registeredRoutes: [new Endpoint1(), new Endpoint2()], + }, + authentication: undefined, + }; + + const converter = new OasRestServerConverter(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec = await converter.getOpenApiSpec(mockServer as any); + + expect(spec.components?.securitySchemes).toBeDefined(); + const schemeKeys = Object.keys( + spec.components?.securitySchemes || {} + ); + expect(schemeKeys).toContain('BearerAuth'); + expect(schemeKeys).toContain('AdminBearer'); + }); + }); + + describe('OasEndpointConverter auth with other properties', () => { + it('combines authentication with error responses', () => { + const converter = new OasEndpointConverter(); + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const fakeError = { + getStatusCode: () => 401, + message: 'Unauthorized', + }; + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'secureEndpoint', + description: 'Secure endpoint', + getTag: () => 'secure', + getErrors: () => ({ unauthorized: fakeError }), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any, auth); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.security).toBeDefined(); + expect(operation.responses['401']).toBeDefined(); + }); + + it('authentication with parameters', () => { + const converter = new OasEndpointConverter(); + const auth = new BearerAuthenticationScheme({ + checkToken: async () => true, + }); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'userEndpoint', + description: 'Get user', + getTag: () => 'users', + getErrors: () => ({}), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any, auth); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operation = (path as any)['get']; + + expect(operation.security).toBeDefined(); + }); + }); +}); diff --git a/test/spec/oas-endpoint-converter.spec.ts b/test/spec/oas-endpoint-converter.spec.ts new file mode 100644 index 0000000..a4d5316 --- /dev/null +++ b/test/spec/oas-endpoint-converter.spec.ts @@ -0,0 +1,30 @@ +import 'jasmine'; +import { OasEndpointConverter } from '../../src/oas/oas-endpoint-converter'; + +describe('OasEndpointConverter', () => { + it('uses fallback description when error message missing', () => { + const converter = new OasEndpointConverter(); + + const fakeEndpoint = { + method: 'get', + statusCode: 200, + getName: () => 'testEndpoint', + description: 'desc', + getTag: () => 'tag', + getErrors: () => ({ + missing_message: { + getStatusCode: () => 400, + // intentionally no message (undefined) to hit fallback + message: undefined, + }, + }), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const path = converter.getOpenApiPath(fakeEndpoint as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const responses = (path as any)['get'].responses; + + expect(responses['400'].description).toBe('Error response'); + }); +}); diff --git a/test/spec/router/middleware.server.ts b/test/spec/router/middleware.server.ts new file mode 100644 index 0000000..9053390 --- /dev/null +++ b/test/spec/router/middleware.server.ts @@ -0,0 +1,108 @@ +import { UnauthorizedError } from '../../../src'; +import { + BaseApiEndpoint, + EndpointMethod, + ApiRequest, + ApiResponse, +} from '../../../src/router/endpoint'; +import { BaseApiRouter } from '../../../src/router/router'; +import { RequestHandler } from 'express'; + +export const callOrder: { [key: string]: string[] } = {}; + +function getTestId(req: ApiRequest): string { + return (req.headers['test_id' as const] as string) || 'unknown'; +} + +function pushCallOrder(req: ApiRequest, label: string) { + const testId = getTestId(req); + + if (!callOrder[testId]) { + callOrder[testId] = []; + } + + callOrder[testId].push(label); +} + +const routerMiddleware: RequestHandler = (req, res, next) => { + pushCallOrder(req, 'router-mw'); + next(); +}; + +const endpointMiddleware: RequestHandler = (req, res, next) => { + pushCallOrder(req, 'endpoint-mw'); + next(); +}; + +export class MiddlewareEndpoint1 extends BaseApiEndpoint { + override path = '/router'; + override method = EndpointMethod.GET; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(req: ApiRequest, res: ApiResponse) { + pushCallOrder(req, 'endpoint'); + return { ok: true }; + } +} + +export class MiddlewareEndpoint2 extends BaseApiEndpoint { + override path = '/endpoint'; + override method = EndpointMethod.GET; + override middleware = [endpointMiddleware]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_req: ApiRequest, _res: ApiResponse) { + pushCallOrder(_req, 'endpoint'); + return { ok: true }; + } +} + +export class MiddlewareEndpoint3 extends BaseApiEndpoint { + override path = '/both'; + override method = EndpointMethod.GET; + override middleware = [endpointMiddleware]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(_req: ApiRequest, _res: ApiResponse) { + pushCallOrder(_req, 'endpoint'); + return { ok: true }; + } +} + +// Auth middleware for testing +export const authMiddleware: RequestHandler = (req, res, next) => { + if (req.headers['authorization'] === 'Bearer validtoken') { + pushCallOrder(req, 'auth-mw'); + next(); + } + else { + pushCallOrder(req, 'auth-mw'); + throw new UnauthorizedError('Invalid token'); + } +}; + +export class AuthEndpoint extends BaseApiEndpoint { + override path = '/auth'; + override method = EndpointMethod.GET; + override middleware = [authMiddleware]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(req: ApiRequest, res: ApiResponse) { + pushCallOrder(req, 'auth-handle'); + return { ok: true }; + } +} + +export class MiddlewareRouter extends BaseApiRouter { + override path = '/middleware-test'; + override middleware = [routerMiddleware]; + + async routes() { + return [ + MiddlewareEndpoint1, + MiddlewareEndpoint2, + MiddlewareEndpoint3, + AuthEndpoint, + ]; + } +} diff --git a/test/spec/router/middleware.spec.ts b/test/spec/router/middleware.spec.ts new file mode 100644 index 0000000..f246f59 --- /dev/null +++ b/test/spec/router/middleware.spec.ts @@ -0,0 +1,91 @@ +import 'jasmine'; +import { env } from '../../env'; +import { callOrder } from './middleware.server'; +import { ErrorResponse } from '../../../src'; + +describe('Router and Endpoint Middleware (Jasmine)', function () { + const baseUrl = env.API_URL + '/middleware-test'; + + it('should call router middleware before endpoint', async function () { + const response = await fetch(baseUrl + '/router', { + headers: { test_id: 'middleware-first' }, + }); + + expect(response.status).toBe(200); + + const data = (await response.json()) as { ok: boolean }; + + expect(data.ok).toBe(true); + expect(callOrder['middleware-first']).toEqual([ + 'router-mw', + 'endpoint', + ]); + }); + + it('should call endpoint middleware before handler', async function () { + const response = await fetch(baseUrl + '/endpoint', { + headers: { test_id: 'endpoint-mw' }, + }); + expect(response.status).toBe(200); + + const data = (await response.json()) as { ok: boolean }; + + expect(data.ok).toBe(true); + expect(callOrder['endpoint-mw']).toEqual([ + 'router-mw', + 'endpoint-mw', + 'endpoint', + ]); + }); + + it('should call all middleware in order', async function () { + const response = await fetch(baseUrl + '/both', { + headers: { test_id: 'both' }, + }); + expect(response.status).toBe(200); + + const data = (await response.json()) as { ok: boolean }; + + expect(data.ok).toBe(true); + expect(callOrder['both']).toEqual([ + 'router-mw', + 'endpoint-mw', + 'endpoint', + ]); + }); + + const authUrl = baseUrl + '/auth'; + + it('blocks unauthorized and does not call endpoint', async function () { + const response = await fetch(authUrl, { + headers: { test_id: 'unauthorized' }, + }); + + expect(response.status).toBe(401); + + const data = (await response.json()) as ErrorResponse; + + expect(data.error).toBe('UnauthorizedError'); + expect(data.message).toBe('Invalid token'); + expect(callOrder['unauthorized']).toEqual(['router-mw', 'auth-mw']); + }); + + it('allows authorized requests, calls endpoint', async function () { + const response = await fetch(authUrl, { + headers: { + Authorization: 'Bearer validtoken', + test_id: 'authorized', + }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { ok: boolean }; + + expect(data.ok).toBe(true); + expect(callOrder['authorized']).toEqual([ + 'router-mw', + 'auth-mw', + 'auth-handle', + ]); + }); +}); diff --git a/test/spec/router/register.spec.ts b/test/spec/router/register.spec.ts new file mode 100644 index 0000000..313dc25 --- /dev/null +++ b/test/spec/router/register.spec.ts @@ -0,0 +1,75 @@ +import { BaseApiEndpoint, EndpointMethod } from '../../../src/router/endpoint'; +import { Router as ExpressRouter } from 'express'; + +describe('router register behavior', () => { + it('composes fullPath when parentPath lacks trailing slash', () => { + class TestEndpoint extends BaseApiEndpoint { + override path = 'child'; + override async handle() { + return { ok: true }; + } + } + + const e = new TestEndpoint(); + + // exercise branch: parentPath without trailing slash + const callRegister = e as unknown as { + registerRoutePath(parentPath: string): void; + }; + callRegister.registerRoutePath('/parent'); + + expect(e.path).toBe('/child'); + expect(e.fullPath).toBe('/parent/child'); + }); + + it('getName falls back to constructor name when name is not set', () => { + class NameTestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const inst = new NameTestEndpoint(); + + expect(inst.getName()).toBe('NameTestEndpoint'); + }); + + it('getTag falls back to constructor name without suffix', () => { + class TagTestEndpoint extends BaseApiEndpoint { + override async handle() { + return {}; + } + } + + const inst = new TagTestEndpoint(); + + expect(inst.getTag()).toBe('TagTest'); + }); + + it('registers endpoint without middleware', async () => { + class NoMiddlewareEndpoint extends BaseApiEndpoint { + override path = '/nomw'; + override method = EndpointMethod.GET; + + override async handle() { + return { ok: true }; + } + } + + const ep = new NoMiddlewareEndpoint(); + + // fake express router to capture calls + const fakeRouterObj = { get: jasmine.createSpy('get') }; + const fakeRouter = fakeRouterObj as unknown as ExpressRouter; + + await ep.register(fakeRouter, '/parent'); + + const spy = (fakeRouter as unknown as { get: jasmine.Spy }).get; + + expect(spy).toHaveBeenCalled(); + const args = spy.calls.mostRecent().args as unknown[]; + expect(args[0]).toBe('/nomw'); + // second arg should be the handler function (bound) + expect(typeof args[1]).toBe('function'); + }); +}); diff --git a/test/test-server.ts b/test/test-server.ts new file mode 100644 index 0000000..81e1fbd --- /dev/null +++ b/test/test-server.ts @@ -0,0 +1,107 @@ +import { RestServer } from '../src'; +import { BaseApiEndpoint, BaseApiRouter } from '../src/router'; +import { UnauthorizedError } from '../src/error'; +import { env } from './env'; +import { MethodsRouter } from './spec/endpoint/endpoint-methods.server'; +import { RoutingRouter } from './spec/endpoint/endpoint-routing.server'; +// eslint-disable-next-line max-len +import { RequestResponseRouter } from './spec/endpoint/endpoint-request-response.server'; +// eslint-disable-next-line max-len +import { QueryParamsRouter } from './spec/endpoint/query-params.server'; +// eslint-disable-next-line max-len +import { HealthCheckRouter } from './spec/endpoint/health-check.server'; +import { MiddlewareRouter } from './spec/router/middleware.server'; +import { ProtectedRouter } from './spec/authentication/authentication.server'; +import { ValidationRouter } from './spec/endpoint/validation.server'; + +export class TestRouter extends BaseApiRouter { + override path = '/test'; + + override async routes() { + return [ + class extends BaseApiEndpoint { + override async handle() { + return { works: true }; + } + }, + class extends BaseApiEndpoint { + override path = '/error-test'; + + override async handle() { + throw new Error('Test error'); + + return {}; + } + }, + class extends BaseApiEndpoint { + override path = 'no-leading-slash'; + + override async handle() { + return { works: true }; + } + }, + class extends BaseApiEndpoint { + override path = '/full-path'; + + override async handle() { + return { fullPath: this.fullPath }; + } + }, + class extends BaseApiEndpoint { + override path = '/http-error-with-headers'; + + override async handle() { + throw new UnauthorizedError('Custom auth error', { + realm: 'TestRealm', + headers: { + 'X-Custom-Header': 'test-value', + }, + }); + + return {}; + } + }, + ]; + } +} + +export class TestRouterWithoutLeadingSlash extends BaseApiRouter { + override path = 'test-without-leading-slash'; + + override async routes() { + return [ + class extends BaseApiEndpoint { + override async handle() { + return { works: true }; + } + }, + ]; + } +} + +export class MainRouter extends BaseApiRouter { + override path = '/'; + + override async routes() { + return [ + TestRouter, + TestRouterWithoutLeadingSlash, + MethodsRouter, + RoutingRouter, + RequestResponseRouter, + QueryParamsRouter, + HealthCheckRouter, + MiddlewareRouter, + ProtectedRouter, + ValidationRouter, + ]; + } +} + +export class TestServer extends RestServer { + public override router = MainRouter; +} + +export const server = new TestServer({ + port: env.API_PORT, +});