diff --git a/README.md b/README.md index 527fa5d..9935336 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# api-machine - REST API Server Framework +# API-Machine A lightweight, TypeScript-first REST API framework built on Express with a class-based routing architecture. @@ -85,39 +85,10 @@ The [`examples/`](examples/) directory contains comprehensive examples: - 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({ +const server = new MyApiServer({ port: 8080, maxPayloadSizeMB: 20, maxUrlEncodedSizeMB: 2, @@ -125,43 +96,6 @@ const server = new MyServer({ }); ``` -## 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: @@ -227,55 +161,11 @@ class DeleteUserEndpoint extends DeleteEndpoint { } ``` -**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)**. +A ready-to-use health check endpoint that returns system status information. Simply include it in your router. 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 @@ -303,7 +193,7 @@ class GetUserEndpoint extends GetEndpoint { ``` **Key Features:** -- 29 built-in error classes covering HTTP status codes 400-451 +- 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 @@ -376,7 +266,7 @@ See **[Authentication Documentation](docs/authentication.md)** for complete usag ## Middleware -Routers and endpoints support Express-style middleware for logging, validation, and more. See [Middleware Support](docs/middleware.md) for usage and examples. +Routers and endpoints support Express middleware for logging, validation, and more. See [Middleware Support](docs/middleware.md) for usage and examples. ## Contributing & Development diff --git a/examples/complete-example/index.ts b/examples/complete-example/index.ts index 47f05ad..620ae24 100644 --- a/examples/complete-example/index.ts +++ b/examples/complete-example/index.ts @@ -37,6 +37,7 @@ const server = new MyServer({ port: 3000, maxPayloadSizeMB: 10, log: customLogger, + swaggerEnabled: true, }); server diff --git a/examples/complete-example/users/create-user-endpoint.ts b/examples/complete-example/users/create-user-endpoint.ts index f7e9652..819e286 100644 --- a/examples/complete-example/users/create-user-endpoint.ts +++ b/examples/complete-example/users/create-user-endpoint.ts @@ -1,6 +1,6 @@ import { ApiRequest, ApiResponse, PostEndpoint } from '../../../src/index'; import { usersRepo, User } from './users-repository'; -import { ObjectSanitizer, EmailValidator } from 'valsan'; +import { ObjectSanitizer, EmailValidator, IntegerValidator } from 'valsan'; import { NameValSan } from './name-valsan'; /** @@ -11,11 +11,29 @@ import { NameValSan } from './name-valsan'; export class CreateUserEndpoint extends PostEndpoint { override path = '/'; + override bodyExample = { + name: 'John Doe', + email: 'john@example.com', + }; + override body = new ObjectSanitizer({ name: new NameValSan(), email: new EmailValidator(), }); + override responseExample = { + id: 3, + name: 'John Doe', + email: 'john@example.com', + created: new Date(), + }; + + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + name: new NameValSan(), + email: new EmailValidator(), + }); + async handle(request: ApiRequest, response: ApiResponse) { const { name, email } = request.body; diff --git a/examples/complete-example/users/get-user-endpoint.ts b/examples/complete-example/users/get-user-endpoint.ts index 819c98f..d048162 100644 --- a/examples/complete-example/users/get-user-endpoint.ts +++ b/examples/complete-example/users/get-user-endpoint.ts @@ -1,6 +1,8 @@ import { ApiRequest, ApiResponse, GetEndpoint } from '../../../src/index'; import { NotFoundError } from '../../../src/error'; import { usersRepo } from './users-repository'; +import { ObjectSanitizer, IntegerValidator, EmailValidator } from 'valsan'; +import { NameValSan } from './name-valsan'; /** * Complete Example - Get User Endpoint @@ -11,6 +13,19 @@ import { usersRepo } from './users-repository'; export class GetUserEndpoint extends GetEndpoint { override path = '/:id'; + override responseExample = { + id: 1, + name: 'Alice', + email: 'alice@example.com', + created: new Date('2023-01-01'), + }; + + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + name: new NameValSan(), + email: new EmailValidator(), + }); + async handle(request: ApiRequest, response: ApiResponse) { const userId = parseInt(request.params['id'], 10); const user = usersRepo[userId]; diff --git a/package-lock.json b/package-lock.json index a8181f3..3b1ab8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "api-machine", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "api-machine", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "dependencies": { "auto-oas": "^1.2.1", @@ -6423,9 +6423,9 @@ "license": "MIT" }, "node_modules/valsan": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/valsan/-/valsan-2.2.0.tgz", - "integrity": "sha512-qogtVowNynknDCjCxhi1sLxyqxvfpjlIucvVI3t91+fFbQ80AsDyJsmVBkJteTZFMOwS1F2BUyIZAfB5BjtJTw==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/valsan/-/valsan-2.2.1.tgz", + "integrity": "sha512-vJjAA1CiWTiqR4Edukepy6kSfkgipg5ZrbOqcAWKN6kowA+0mSrGbVthQfQpNa/2+G/K+JPffe/GszvmJktMBg==", "license": "MIT" }, "node_modules/vary": { diff --git a/package.json b/package.json index 7002941..0b3a890 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "api-machine", - "version": "1.0.1", + "version": "1.1.0", "description": "api-machine", "private": "true", "typescript-template": { diff --git a/src/oas/oas-endpoint-component-converter.ts b/src/oas/oas-endpoint-component-converter.ts index 8ecc5f1..cb1ae87 100644 --- a/src/oas/oas-endpoint-component-converter.ts +++ b/src/oas/oas-endpoint-component-converter.ts @@ -22,6 +22,16 @@ export class OasEndpointComponentConverter { }); } + const responseSanitizer = endpoint.response; + + if (responseSanitizer) { + this.addSchema({ + name: `${endpoint.name}Response`, + sanitizer: responseSanitizer, + example: endpoint.responseExample, + }); + } + return this.schemas; } diff --git a/src/oas/oas-endpoint-converter.ts b/src/oas/oas-endpoint-converter.ts index 62b2535..dc65e7a 100644 --- a/src/oas/oas-endpoint-converter.ts +++ b/src/oas/oas-endpoint-converter.ts @@ -3,6 +3,7 @@ import { PathItemObject, ParameterObject, RequestBodyObject, + ResponsesObject, } from 'auto-oas/oas/v3.1'; import { BaseApiEndpoint } from '../router'; @@ -60,9 +61,28 @@ export class OasEndpointConverter { // Use statusCode for response const status = endpoint.statusCode; - const responses = { - [status]: { description: 'Success' }, - }; + const responses: ResponsesObject = {}; + + // Generate success response with schema if available + const responseSanitizer = endpoint.response; + if (responseSanitizer && responseSanitizer.schema) { + responses[status] = { + description: 'Success', + content: { + 'application/json': { + schema: { + $ref: + '#/components/schemas/' + + endpoint.getName() + + 'Response', + }, + }, + }, + }; + } + else { + responses[status] = { description: 'Success' }; + } const errors = endpoint.getErrors(); for (const error in errors) { diff --git a/src/router/endpoint.ts b/src/router/endpoint.ts index 400925e..8e70318 100644 --- a/src/router/endpoint.ts +++ b/src/router/endpoint.ts @@ -49,6 +49,10 @@ export abstract class BaseApiEndpoint extends BaseApiRoute { // eslint-disable-next-line @typescript-eslint/no-explicit-any public headersExample?: any; + public response?: ObjectSanitizer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public responseExample?: any; + public getErrors(): { [key: string]: HTTPError } { return { parse: new BadRequestError(), diff --git a/src/router/endpoints/health-check-endpoint.ts b/src/router/endpoints/health-check-endpoint.ts index a761bb1..ea1cd6f 100644 --- a/src/router/endpoints/health-check-endpoint.ts +++ b/src/router/endpoints/health-check-endpoint.ts @@ -1,11 +1,29 @@ -import { ApiRequest, ApiResponse } from '../endpoint'; +import { + Iso8601TimestampValSan, + MinLengthValidator, + ObjectSanitizer, + StringToNumberValSan, +} from 'valsan'; 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) { + override responseExample = { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: 12345, + environment: 'development', + }; + + override response = new ObjectSanitizer({ + status: new MinLengthValidator(), + timestamp: new Iso8601TimestampValSan(), + uptime: new StringToNumberValSan(), + environment: new MinLengthValidator(), + }); + + async handle() { return { status: await this.getStatus(), timestamp: await this.getTimestamp(), diff --git a/src/router/router.ts b/src/router/router.ts index ef9bfdd..2feab59 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -50,7 +50,7 @@ export abstract class BaseApiRouter extends BaseApiRoute { (instance as BaseApiEndpoint).tag = tag; } - await instance.register(this.router, this.fullPath); + await this.registerInstance(instance); this.registeredRoutes.push(instance); @@ -83,6 +83,10 @@ export abstract class BaseApiRouter extends BaseApiRoute { }); }); } + + protected async registerInstance(instance: BaseApiRoute): Promise { + await instance.register(this.router, this.fullPath); + } } export type ApiRouter = { new (): BaseApiRouter }; diff --git a/test/spec/api/openapi-server.ts b/test/spec/api/openapi-server.ts index 26fa772..d393e89 100644 --- a/test/spec/api/openapi-server.ts +++ b/test/spec/api/openapi-server.ts @@ -47,6 +47,14 @@ class CreateUserEndpoint extends PostEndpoint { email: 'john.doe@example.com', }; + override response = new ObjectSanitizer({ + id: new LengthValidator({ minLength: 1, maxLength: 50 }), + }); + + override responseExample = { + id: 'abc123', + }; + async handle(request: ApiRequest) { const id = Math.random().toString(36).slice(2); usersDb[id] = { id, ...request.body }; @@ -80,6 +88,16 @@ class GetUserEndpoint extends GetEndpoint { 'x-request-id': 'req-12345', }; + override responseExample = { + id: 'abc123', + name: 'John Doe', + email: 'john.doe@example.com', + }; + + override response = new ObjectSanitizer({ + id: idValidator, + }); + async handle(request: ApiRequest) { const user = usersDb[request.params['id']]; @@ -107,6 +125,20 @@ class ListUsersEndpoint extends GetEndpoint { email: emailValidator.copy({ isOptional: true }), }); + override response = new ObjectSanitizer({ + limit: new RangeValidator({ min: 1, max: 100 }), + name: nameValidator, + email: emailValidator, + }); + + override responseExample = [ + { + id: 'abc123', + name: 'John Doe', + email: 'john.doe@example.com', + }, + ]; + async handle(request: ApiRequest) { const users = Object.values(usersDb); diff --git a/test/spec/openapi/oas-endpoint-component-converter-response.spec.ts b/test/spec/openapi/oas-endpoint-component-converter-response.spec.ts new file mode 100644 index 0000000..0fa71d0 --- /dev/null +++ b/test/spec/openapi/oas-endpoint-component-converter-response.spec.ts @@ -0,0 +1,192 @@ +import 'jasmine'; +// eslint-disable-next-line max-len +import { OasEndpointComponentConverter } from '../../../src/oas/oas-endpoint-component-converter'; +import { ObjectSanitizer, IntegerValidator, EmailValidator } from 'valsan'; +import { GetEndpoint } from '../../../src/router/endpoints/get-endpoint'; +import { PostEndpoint } from '../../../src/router/endpoints/post-endpoint'; +import { ApiRequest, ApiResponse, ApiNextFunction } from '../../../src/router'; + +describe('OasEndpointComponentConverter - Response Schemas', () => { + let converter: OasEndpointComponentConverter; + + beforeEach(() => { + converter = new OasEndpointComponentConverter(); + }); + + it('generates response schema from response sanitizer', () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + email: new EmailValidator(), + }); + + override responseExample = { + id: 1, + email: 'test@example.com', + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const schemas = converter.getSchemas(endpoint); + const schema = schemas['TestEndpointResponse']; + + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + expect(schema.properties?.['id']).toBeDefined(); + expect(schema.properties?.['email']).toBeDefined(); + }); + + it('includes response example in schema', () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 123, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const schemas = converter.getSchemas(endpoint); + const schema = schemas['TestEndpointResponse'] as { + example?: { id: number }; + }; + + expect(schema.example).toBeDefined(); + expect(schema.example?.id).toBe(123); + }); + + it('does not include response schema when no response ' + 'defined', () => { + class TestEndpoint extends GetEndpoint { + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const schemas = converter.getSchemas(endpoint); + + expect(schemas['TestEndpointResponse']).toBeUndefined(); + }); + + it('includes both body and response schemas', () => { + class TestEndpoint extends PostEndpoint { + override body = new ObjectSanitizer({ + name: new IntegerValidator(), + }); + + override bodyExample = { + name: 'test', + }; + + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const schemas = converter.getSchemas(endpoint); + + expect(schemas['TestEndpointBody']).toBeDefined(); + expect(schemas['TestEndpointResponse']).toBeDefined(); + expect(Object.keys(schemas).length).toBe(2); + }); + + it('generates correct schema structure for complex ' + 'response', () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + email: new EmailValidator(), + status: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + email: 'test@example.com', + status: 200, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const schemas = converter.getSchemas(endpoint); + const schema = schemas['TestEndpointResponse']; + const example = schema.example as { + id: number; + email: string; + status: number; + }; + + expect(schema.type).toBe('object'); + expect(Object.keys(schema.properties ?? {}).length).toBe(3); + expect(example?.id).toBe(1); + expect(example?.email).toBe('test@example.com'); + expect(example?.status).toBe(200); + }); +}); diff --git a/test/spec/openapi/oas-endpoint-converter-response-schema.spec.ts b/test/spec/openapi/oas-endpoint-converter-response-schema.spec.ts new file mode 100644 index 0000000..9e9ed25 --- /dev/null +++ b/test/spec/openapi/oas-endpoint-converter-response-schema.spec.ts @@ -0,0 +1,259 @@ +import 'jasmine'; +import { OasEndpointConverter } from '../../../src/oas/oas-endpoint-converter'; +import { ObjectSanitizer, IntegerValidator, EmailValidator } from 'valsan'; +import { GetEndpoint } from '../../../src/router/endpoints/get-endpoint'; +import { PostEndpoint } from '../../../src/router/endpoints/post-endpoint'; +import { ApiRequest, ApiResponse, ApiNextFunction } from '../../../src/router'; +import { ResponseObject } from 'auto-oas'; + +describe('OasEndpointConverter - Response Schemas', () => { + let converter: OasEndpointConverter; + + beforeEach(() => { + converter = new OasEndpointConverter(); + }); + + it( + 'generates response schema reference when ' + + 'response sanitizer is defined', + () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + email: new EmailValidator(), + }); + + override responseExample = { + id: 1, + email: 'test@example.com', + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const resp = path.get?.responses['200'] as ResponseObject; + + expect(resp?.description).toBe('Success'); + expect(resp?.content).toBeDefined(); + expect(resp?.content?.['application/json']).toBeDefined(); + expect(resp?.content?.['application/json']?.schema?.$ref).toBe( + '#/components/schemas/TestEndpointResponse' + ); + } + ); + + it( + 'uses generic description when no response ' + 'sanitizer defined', + () => { + class TestEndpoint extends GetEndpoint { + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const resp = path.get?.responses['200'] as ResponseObject; + + expect(resp.description).toBe('Success'); + expect(resp.content).toBeUndefined(); + } + ); + + it('respects custom status codes with response ' + 'schemas', () => { + class TestEndpoint extends PostEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const responses = path.post?.responses; + + // PostEndpoint defaults to 201 + expect(responses?.['201']).toBeDefined(); + expect( + (responses?.['201'] as ResponseObject)?.content?.[ + 'application/json' + ]?.schema?.$ref + ).toBe('#/components/schemas/TestEndpointResponse'); + }); + + it('generates both error and success responses', () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const responses = path.get?.responses; + + expect(responses?.['200']).toBeDefined(); + expect(responses?.['400']).toBeDefined(); + expect(responses?.['422']).toBeDefined(); + }); + + it( + 'only includes response schema name without response ' + 'sanitizer', + () => { + class TestEndpoint extends GetEndpoint { + override responseExample = { + id: 1, + email: 'test@example.com', + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const resp = path.get?.responses['200'] as ResponseObject; + + expect(resp.description).toBe('Success'); + expect(resp.content).toBeUndefined(); + } + ); + + it('handles endpoint names with special characters', () => { + class MySpecialEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new MySpecialEndpoint(); + endpoint.name = 'MySpecialEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const resp = path.get?.responses['200'] as ResponseObject; + + expect(resp.content?.['application/json']?.schema?.$ref).toBe( + '#/components/schemas/MySpecialEndpointResponse' + ); + }); + + it('preserves error responses when response schema ' + 'is defined', () => { + class TestEndpoint extends GetEndpoint { + override response = new ObjectSanitizer({ + id: new IntegerValidator(), + }); + + override responseExample = { + id: 1, + }; + + async handle( + request: ApiRequest, + response: ApiResponse, + next: ApiNextFunction + ) { + request; + response; + next; + return {}; + } + } + + const endpoint = new TestEndpoint(); + endpoint.name = 'TestEndpoint'; + + const path = converter.getOpenApiPath(endpoint); + const responses = path.get?.responses as Record; + + // Check that error responses are still present + const errorStatuses = Object.keys(responses).filter( + (status) => status !== '200' + ); + expect(errorStatuses.length).toBeGreaterThan(0); + + // Verify error response format + for (const status of errorStatuses) { + expect(responses[status].description).toBeDefined(); + } + }); +});