diff --git a/packages/openapi-generator/README.md b/packages/openapi-generator/README.md index 4c74ef6f..df0c5602 100644 --- a/packages/openapi-generator/README.md +++ b/packages/openapi-generator/README.md @@ -472,9 +472,13 @@ These are some tags that you can use in your schema JSDocs are custom to this ge will have `x-internal: true` for schemas with the `@private` tag. - `@deprecated` allows to mark any field in any schema as deprecated. The final spec will include `deprecated: true` in the final specificaiton. +- `@contentType` allows you to override the default `application/json` content type for + requests and responses. Can be applied at route level (affects both request and + response), request body level, or individual response status code level. ```typescript import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; const Schema = t.type({ /** @private */ @@ -483,4 +487,25 @@ const Schema = t.type({ deprecatedField: t.string, publicNonDeprecatedField: t.string, }); + +/** + * Route-level content type + * @contentType multipart/form-data + */ +export const uploadRoute = h.httpRoute({ + request: h.httpRequest({ + /** + * Request-specific content type + * @contentType application/xml + */ + body: t.type({ data: t.string }), + }), + response: { + /** + * Response-specific content type + * @contentType text/plain + */ + 200: t.string, + }, +}); ``` diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index 15d39626..5ff97bc6 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -318,6 +318,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec const isInternal = jsdoc.tags?.private !== undefined; const isUnstable = jsdoc.tags?.unstable !== undefined; const example = jsdoc.tags?.example; + const contentType = jsdoc.tags?.contentType ?? 'application/json'; const knownTags = new Set([ 'operationId', @@ -328,6 +329,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec 'tag', 'description', 'url', + 'contentType', ]); const unknownTagsObject = Object.entries(jsdoc.tags ?? {}).reduce( (acc, [key, value]) => { @@ -344,9 +346,20 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec ? {} : { requestBody: { - content: { - 'application/json': { schema: schemaToOpenAPI(route.body) }, - }, + content: (() => { + const emptyBlock: Block = { + description: '', + tags: [], + source: [], + problems: [], + }; + const bodyJsdoc = parseCommentBlock(route.body.comment ?? emptyBlock); + const requestContentType = bodyJsdoc.tags?.contentType ?? contentType; + + return { + [requestContentType]: { schema: schemaToOpenAPI(route.body) }, + }; + })(), }, }; @@ -392,12 +405,21 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec responses: Object.entries(route.response).reduce((acc, [code, response]) => { const description = STATUS_CODES[code] ?? ''; + const emptyBlock: Block = { + description: '', + tags: [], + source: [], + problems: [], + }; + const responseJsdoc = parseCommentBlock(response.comment ?? emptyBlock); + const responseContentType = responseJsdoc.tags?.contentType ?? contentType; + return { ...acc, [Number(code)]: { description, content: { - 'application/json': { + [responseContentType]: { schema: schemaToOpenAPI(response), ...(example !== undefined ? { example } : undefined), }, diff --git a/packages/openapi-generator/test/openapi/misc.test.ts b/packages/openapi-generator/test/openapi/misc.test.ts index 801e5190..494d5778 100644 --- a/packages/openapi-generator/test/openapi/misc.test.ts +++ b/packages/openapi-generator/test/openapi/misc.test.ts @@ -287,3 +287,541 @@ testCase('route with record types', ROUTE_WITH_RECORD_TYPES, { }, }, }); + +const CONTENT_TYPE_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Route with per-schema content types + * + * @operationId api.v1.uploadDocument + * @tag Document Upload + */ +export const uploadRoute = h.httpRoute({ + path: '/upload', + method: 'POST', + request: h.httpRequest({ + /** + * File upload data + * @contentType multipart/form-data + */ + body: t.type({ + file: t.unknown, + documentType: t.string, + }), + }), + response: { + /** + * Upload success response + * @contentType application/xml + */ + 201: t.type({ + id: t.string, + success: t.boolean, + }), + }, +}); + +/** + * Route with default application/json content type + * + * @operationId api.v1.createUser + * @tag User Management + */ +export const createUserRoute = h.httpRoute({ + path: '/users', + method: 'POST', + request: h.httpRequest({ + body: t.type({ + name: t.string, + email: t.string, + }), + }), + response: { + 201: t.type({ + id: t.string, + name: t.string, + }), + }, +}); +`; + +testCase('route with per-schema contentType tags', CONTENT_TYPE_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/upload': { + post: { + summary: 'Route with per-schema content types', + operationId: 'api.v1.uploadDocument', + tags: ['Document Upload'], + parameters: [], + requestBody: { + content: { + 'multipart/form-data': { + schema: { + description: 'File upload data', + type: 'object', + properties: { + file: {}, + documentType: { type: 'string' }, + }, + required: ['file', 'documentType'], + }, + }, + }, + }, + responses: { + 201: { + description: 'Created', + content: { + 'application/xml': { + schema: { + description: 'Upload success response', + type: 'object', + properties: { + id: { type: 'string' }, + success: { type: 'boolean' }, + }, + required: ['id', 'success'], + }, + }, + }, + }, + }, + }, + }, + '/users': { + post: { + summary: 'Route with default application/json content type', + operationId: 'api.v1.createUser', + tags: ['User Management'], + parameters: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['name', 'email'], + }, + }, + }, + }, + responses: { + 201: { + description: 'Created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, +}); + +const ROUTE_LEVEL_CONTENT_TYPE_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Route with default XML content type + * @operationId api.v1.sendNotification + * @tag Notifications + * @contentType application/xml + */ +export const sendNotificationRoute = h.httpRoute({ + path: '/notifications', + method: 'POST', + request: h.httpRequest({ + body: t.type({ + title: t.string, + message: t.string, + }), + }), + response: { + 200: t.type({ + id: t.string, + status: t.string, + }), + }, +}); +`; + +testCase('route with route-level contentType override', ROUTE_LEVEL_CONTENT_TYPE_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/notifications': { + post: { + summary: 'Route with default XML content type', + operationId: 'api.v1.sendNotification', + tags: ['Notifications'], + parameters: [], + requestBody: { + content: { + 'application/xml': { + schema: { + type: 'object', + properties: { + title: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['title', 'message'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/xml': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + status: { type: 'string' }, + }, + required: ['id', 'status'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, +}); + +const MIXED_CONTENT_TYPE_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Route with mixed content types - route default with schema overrides + * @operationId api.v1.processDocument + * @tag Document Processing + * @contentType application/xml + */ +export const processDocumentRoute = h.httpRoute({ + path: '/process', + method: 'POST', + request: h.httpRequest({ + /** + * Request overrides route default to JSON + * @contentType application/json + */ + body: t.type({ + document: t.string, + options: t.type({ + validate: t.boolean, + }), + }), + }), + response: { + 200: t.type({ + result: t.string, + processed: t.boolean, + }), + /** + * Error response uses custom content type + * @contentType text/plain + */ + 400: t.type({ + error: t.string, + }), + }, +}); +`; + +testCase('route with mixed content type overrides', MIXED_CONTENT_TYPE_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/process': { + post: { + summary: 'Route with mixed content types - route default with schema overrides', + operationId: 'api.v1.processDocument', + tags: ['Document Processing'], + parameters: [], + requestBody: { + content: { + 'application/json': { + schema: { + description: 'Request overrides route default to JSON', + type: 'object', + properties: { + document: { type: 'string' }, + options: { + type: 'object', + properties: { + validate: { type: 'boolean' }, + }, + required: ['validate'], + }, + }, + required: ['document', 'options'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/xml': { + schema: { + type: 'object', + properties: { + result: { type: 'string' }, + processed: { type: 'boolean' }, + }, + required: ['result', 'processed'], + }, + }, + }, + }, + 400: { + description: 'Bad Request', + content: { + 'text/plain': { + schema: { + description: 'Error response uses custom content type', + type: 'object', + properties: { + error: { type: 'string' }, + }, + required: ['error'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, +}); + +const PER_RESPONSE_CONTENT_TYPE_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +/** + * Upload document with different response content types per status code + * @operationId api.v1.uploadDocumentAdvanced + * @tag Document Upload + */ +export const uploadAdvancedRoute = h.httpRoute({ + path: '/upload-advanced', + method: 'POST', + request: h.httpRequest({ + /** + * Multipart form data for file upload + * @contentType multipart/form-data + */ + body: t.type({ + file: t.unknown, + documentType: t.string, + }), + }), + response: { + /** + * Success response with JSON data + * @contentType application/json + */ + 200: t.type({ + id: t.string, + success: t.boolean, + }), + + /** + * Plain text error message + * @contentType text/plain + */ + 400: t.string, + + /** + * File download response + * @contentType application/octet-stream + */ + 201: t.unknown, + }, +}); + +/** + * Standard route with default content types + * @operationId api.v1.createUserStandard + * @tag User Management + */ +export const createUserStandardRoute = h.httpRoute({ + path: '/users-standard', + method: 'POST', + request: h.httpRequest({ + body: t.type({ + name: t.string, + email: t.string, + }), + }), + response: { + 200: t.type({ + id: t.string, + name: t.string, + }), + 400: t.type({ + error: t.string, + }), + }, +}); +`; + +testCase('routes with per-response content types', PER_RESPONSE_CONTENT_TYPE_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/upload-advanced': { + post: { + summary: + 'Upload document with different response content types per status code', + operationId: 'api.v1.uploadDocumentAdvanced', + tags: ['Document Upload'], + parameters: [], + requestBody: { + content: { + 'multipart/form-data': { + schema: { + description: 'Multipart form data for file upload', + type: 'object', + properties: { + file: {}, + documentType: { type: 'string' }, + }, + required: ['file', 'documentType'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + description: 'Success response with JSON data', + type: 'object', + properties: { + id: { type: 'string' }, + success: { type: 'boolean' }, + }, + required: ['id', 'success'], + }, + }, + }, + }, + 201: { + description: 'Created', + content: { + 'application/octet-stream': { + schema: {}, + }, + }, + }, + 400: { + description: 'Bad Request', + content: { + 'text/plain': { + schema: { + description: 'Plain text error message', + type: 'string', + }, + }, + }, + }, + }, + }, + }, + '/users-standard': { + post: { + summary: 'Standard route with default content types', + operationId: 'api.v1.createUserStandard', + tags: ['User Management'], + parameters: [], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['name', 'email'], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }, + }, + }, + }, + 400: { + description: 'Bad Request', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + required: ['error'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, +});