diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index f6329f0..de3d0e0 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -30,11 +30,19 @@ } } }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MessageWithErrors" } + } + } + }, "500": { "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -69,7 +77,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -94,7 +102,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -119,7 +127,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -165,7 +173,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -197,7 +205,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -228,10 +236,21 @@ } }, "400": { - "description": "An unknown error occurred, trying again might help.", + "description": "Error response.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { + "oneOf": [ + { + "description": "Invalid request body.", + "$ref": "#/components/schemas/MessageWithErrors" + }, + { + "description": "An unknown error occurred, trying again might help.", + "$ref": "#/components/schemas/ErrorMessage" + } + ] + } } } } @@ -265,7 +284,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -299,7 +318,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -324,7 +343,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -349,7 +368,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -390,7 +409,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -425,7 +444,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -466,7 +485,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -505,11 +524,19 @@ } } }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/MessageWithErrors" } + } + } + }, "500": { "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -544,7 +571,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -569,7 +596,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -594,7 +621,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -640,7 +667,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -672,7 +699,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -703,10 +730,21 @@ } }, "400": { - "description": "An unknown error occurred, trying again might help.", + "description": "Error response.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { + "oneOf": [ + { + "description": "Invalid request body.", + "$ref": "#/components/schemas/MessageWithErrors" + }, + { + "description": "An unknown error occurred, trying again might help.", + "$ref": "#/components/schemas/ErrorMessage" + } + ] + } } } } @@ -740,7 +778,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -774,7 +812,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -799,7 +837,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -824,7 +862,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -865,7 +903,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -900,7 +938,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -941,7 +979,7 @@ "description": "An unknown error occurred, trying again might help.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UnexpectedError" } + "schema": { "$ref": "#/components/schemas/ErrorMessage" } } } } @@ -1001,6 +1039,11 @@ "additionalProperties": false, "description": "TODO deleted message." }, + "ErrorMessage": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "additionalProperties": false + }, "FormDataMultipartRequestBody": { "description": "Test form description.", "type": "object", @@ -1113,6 +1156,39 @@ }, "description": "List of TODOs." }, + "MessageWithErrors": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["code", "path", "message"], + "properties": { + "code": { + "type": "string", + "description": "Discriminator field for the Zod issue type." + }, + "path": { + "type": "array", + "items": { + "oneOf": [{ "type": "string" }, { "type": "number" }] + }, + "description": "Path to the error in the validated object, represented as an array of strings and/or numbers." + }, + "message": { + "type": "string", + "description": "Human-readable message describing the validation error." + } + }, + "additionalProperties": true + } + } + }, + "required": ["message"], + "additionalProperties": false + }, "MultipartFormData200ResponseBody": { "description": "File response.", "type": "string", @@ -1127,11 +1203,6 @@ } }, "RouteWithExternalDep200ResponseBody": { "type": "string" }, - "UnexpectedError": { - "type": "object", - "properties": { "message": { "type": "string" } }, - "additionalProperties": false - }, "UrlEncodedFormData200ResponseBody": { "type": "object", "properties": { "text": { "type": "string" } }, diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index 14f21fa..f4560c2 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -114,7 +114,7 @@ export const route = >( if (contentTypeSchema && contentType !== contentTypeSchema) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidMediaType }, - { status: 415 } + { status: 415, headers: { Allow: contentTypeSchema } } ); } diff --git a/packages/next-rest-framework/src/constants.ts b/packages/next-rest-framework/src/constants.ts index cb75f85..1a85230 100644 --- a/packages/next-rest-framework/src/constants.ts +++ b/packages/next-rest-framework/src/constants.ts @@ -1,3 +1,4 @@ +import { type OpenAPIV3_1 } from 'openapi-types'; import { type FormDataContentType } from './types'; export const DEFAULT_ERRORS = { @@ -8,7 +9,6 @@ export const DEFAULT_ERRORS = { invalidMediaType: 'Invalid media type.', operationNotAllowed: 'Operation not allowed.', invalidRequestBody: 'Invalid request body.', - missingRequestBody: 'Missing request body.', invalidQueryParameters: 'Invalid query parameters.', invalidPathParameters: 'Invalid path parameters.' }; @@ -45,3 +45,136 @@ export const DEFAULT_LOGO_URL = export const FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION: FormDataContentType[] = ['multipart/form-data', 'application/x-www-form-urlencoded']; + +export const MESSAGE_WITH_ERRORS_SCHEMA: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { + message: { type: 'string' }, + errors: { + type: 'array', + items: { + type: 'object', + required: ['code', 'path', 'message'], + properties: { + code: { + type: 'string', + description: 'Discriminator field for the Zod issue type.' + }, + path: { + type: 'array', + items: { + oneOf: [ + { + type: 'string' + }, + { + type: 'number' + } + ] + }, + description: + 'Path to the error in the validated object, represented as an array of strings and/or numbers.' + }, + message: { + type: 'string', + description: + 'Human-readable message describing the validation error.' + } + }, + additionalProperties: true + } + } + }, + required: ['message'], + additionalProperties: false +}; + +export const INVALID_REQUEST_BODY_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: DEFAULT_ERRORS.invalidRequestBody, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/MessageWithErrors` + } + } + } +}; + +export const ERROR_MESSAGE_SCHEMA: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { + message: { type: 'string' } + }, + additionalProperties: false +}; + +export const UNEXPECTED_ERROR_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: DEFAULT_ERRORS.unexpectedError, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/ErrorMessage` + } + } + } +}; + +export const INVALID_RPC_REQUEST_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: 'Error response.', + content: { + 'application/json': { + schema: { + oneOf: [ + { + description: DEFAULT_ERRORS.invalidRequestBody, + $ref: `#/components/schemas/MessageWithErrors` + }, + { + description: DEFAULT_ERRORS.unexpectedError, + $ref: `#/components/schemas/ErrorMessage` + } + ] + } + } + } +}; + +export const INVALID_MEDIA_TYPE_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: DEFAULT_ERRORS.invalidMediaType, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/ErrorMessage` + } + } + }, + headers: { + Allow: { + schema: { + type: 'string' + } + } + } +}; + +export const INVALID_QUERY_PARAMETERS_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: DEFAULT_ERRORS.invalidQueryParameters, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/MessageWithErrors` + } + } + } +}; + +export const INVALID_PATH_PARAMETERS_RESPONSE: OpenAPIV3_1.ResponseObject = { + description: DEFAULT_ERRORS.invalidPathParameters, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/MessageWithErrors` + } + } + } +}; diff --git a/packages/next-rest-framework/src/pages-router/api-route.ts b/packages/next-rest-framework/src/pages-router/api-route.ts index 695c934..59d6368 100644 --- a/packages/next-rest-framework/src/pages-router/api-route.ts +++ b/packages/next-rest-framework/src/pages-router/api-route.ts @@ -106,6 +106,7 @@ export const apiRoute = >( const contentType = req.headers['content-type']?.split(';')[0]; if (contentTypeSchema && contentType !== contentTypeSchema) { + res.setHeader('Allow', contentTypeSchema); res.status(415).json({ message: `${DEFAULT_ERRORS.invalidMediaType} Expected ${contentTypeSchema}.` }); diff --git a/packages/next-rest-framework/src/shared/paths.ts b/packages/next-rest-framework/src/shared/paths.ts index 5b10fac..232f03f 100644 --- a/packages/next-rest-framework/src/shared/paths.ts +++ b/packages/next-rest-framework/src/shared/paths.ts @@ -1,6 +1,14 @@ import { type OpenApiPathItem, type OpenApiOperation } from '../types'; import { type OpenAPIV3_1 } from 'openapi-types'; -import { DEFAULT_ERRORS } from '../constants'; +import { + ERROR_MESSAGE_SCHEMA, + INVALID_PATH_PARAMETERS_RESPONSE, + INVALID_QUERY_PARAMETERS_RESPONSE, + INVALID_REQUEST_BODY_RESPONSE, + INVALID_RPC_REQUEST_RESPONSE, + MESSAGE_WITH_ERRORS_SCHEMA, + UNEXPECTED_ERROR_RESPONSE +} from '../constants'; import { merge } from 'lodash'; import { getJsonSchema } from './schemas'; @@ -48,6 +56,13 @@ export const getPathsFromRoute = ({ Array<{ key: string; ref: string; schema: OpenAPIV3_1.SchemaObject }> > = {}; + const baseResponseBodySchemaMapping: Record< + string, + OpenAPIV3_1.SchemaObject + > = { + ErrorMessage: ERROR_MESSAGE_SCHEMA + }; + Object.entries(operations).forEach( ([operationId, { openApiOperation, method: _method, input, outputs }]: [ string, @@ -106,6 +121,31 @@ export const getPathsFromRoute = ({ const usedStatusCodes: number[] = []; + const baseOperationResponses: OpenAPIV3_1.ResponsesObject = { + 500: UNEXPECTED_ERROR_RESPONSE + }; + + if (input?.bodySchema) { + baseOperationResponses[400] = INVALID_REQUEST_BODY_RESPONSE; + + baseResponseBodySchemaMapping.MessageWithErrors = + MESSAGE_WITH_ERRORS_SCHEMA; + } + + if (input?.querySchema) { + baseOperationResponses[400] = INVALID_QUERY_PARAMETERS_RESPONSE; + + baseResponseBodySchemaMapping.InvalidQueryParameters = + MESSAGE_WITH_ERRORS_SCHEMA; + } + + if (input?.paramsSchema) { + baseOperationResponses[400] = INVALID_PATH_PARAMETERS_RESPONSE; + + baseResponseBodySchemaMapping.InvalidPathParameters = + MESSAGE_WITH_ERRORS_SCHEMA; + } + generatedOperationObject.responses = outputs?.reduce( (obj, { status, contentType, body, bodySchema, name }) => { const occurrenceOfStatusCode = usedStatusCodes.includes(status) @@ -161,18 +201,7 @@ export const getPathsFromRoute = ({ } }); }, - { - 500: { - description: DEFAULT_ERRORS.unexpectedError, - content: { - 'application/json': { - schema: { - $ref: `#/components/schemas/UnexpectedError` - } - } - } - } - } + baseOperationResponses ); let pathParameters: OpenAPIV3_1.ParameterObject[] = []; @@ -274,15 +303,7 @@ export const getPathsFromRoute = ({ acc[key] = schema; return acc; }, - { - UnexpectedError: { - type: 'object', - properties: { - message: { type: 'string' } - }, - additionalProperties: false - } - } + baseResponseBodySchemaMapping ); const schemas: Record = { @@ -326,6 +347,13 @@ export const getPathsFromRpcRoute = ({ }> > = {}; + const baseResponseBodySchemaMapping: Record< + string, + OpenAPIV3_1.SchemaObject + > = { + ErrorMessage: ERROR_MESSAGE_SCHEMA + }; + Object.entries(operations).forEach( ([ operationId, @@ -377,6 +405,16 @@ export const getPathsFromRpcRoute = ({ }; } + const baseOperationResponses: OpenAPIV3_1.ResponsesObject = {}; + + if (input?.bodySchema) { + baseOperationResponses[400] = INVALID_RPC_REQUEST_RESPONSE; + baseResponseBodySchemaMapping.MessageWithErrors = + MESSAGE_WITH_ERRORS_SCHEMA; + } else { + baseOperationResponses[400] = UNEXPECTED_ERROR_RESPONSE; + } + generatedOperationObject.responses = outputs?.reduce( (obj, { body, bodySchema, contentType, name }, i) => { const key = @@ -421,18 +459,7 @@ export const getPathsFromRpcRoute = ({ } }); }, - { - 400: { - description: DEFAULT_ERRORS.unexpectedError, - content: { - 'application/json': { - schema: { - $ref: `#/components/schemas/UnexpectedError` - } - } - } - } - } + baseOperationResponses ); paths[route] = { @@ -459,15 +486,7 @@ export const getPathsFromRpcRoute = ({ acc[key] = schema; return acc; }, - { - UnexpectedError: { - type: 'object', - properties: { - message: { type: 'string' } - }, - additionalProperties: false - } - } + baseResponseBodySchemaMapping ); const schemas: Record = { diff --git a/packages/next-rest-framework/tests/app-router/route.test.ts b/packages/next-rest-framework/tests/app-router/route.test.ts index 1ea4f59..bd91270 100644 --- a/packages/next-rest-framework/tests/app-router/route.test.ts +++ b/packages/next-rest-framework/tests/app-router/route.test.ts @@ -315,7 +315,9 @@ describe('route', () => { }).POST(req, context); const json = await res?.json(); + const headers = res?.headers; expect(res?.status).toEqual(415); + expect(headers?.get('Allow')).toEqual('application/json'); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidMediaType diff --git a/packages/next-rest-framework/tests/pages-router/api-route.test.ts b/packages/next-rest-framework/tests/pages-router/api-route.test.ts index 183352a..6a1a53d 100644 --- a/packages/next-rest-framework/tests/pages-router/api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/api-route.test.ts @@ -312,6 +312,7 @@ describe('apiRoute', () => { })(req, res); expect(res.statusCode).toEqual(415); + expect(res._getHeaders().allow).toEqual('application/json'); expect(res._getJSONData()).toEqual({ message: `${DEFAULT_ERRORS.invalidMediaType} Expected application/json.` diff --git a/packages/next-rest-framework/tests/utils.ts b/packages/next-rest-framework/tests/utils.ts index bf4bcc6..50eafa4 100644 --- a/packages/next-rest-framework/tests/utils.ts +++ b/packages/next-rest-framework/tests/utils.ts @@ -4,6 +4,8 @@ import { DEFAULT_DESCRIPTION, DEFAULT_ERRORS, DEFAULT_TITLE, + ERROR_MESSAGE_SCHEMA, + MESSAGE_WITH_ERRORS_SCHEMA, VERSION, ValidMethod } from '../src/constants'; @@ -170,15 +172,8 @@ export const getExpectedSpec = ({ const paths: OpenAPIV3_1.PathsObject = {}; const defaultSchemas: Record = { - UnexpectedError: { - type: 'object', - additionalProperties: false, - properties: { - message: { - type: 'string' - } - } - } + MessageWithErrors: MESSAGE_WITH_ERRORS_SCHEMA, + ErrorMessage: ERROR_MESSAGE_SCHEMA }; let schemas: Record = {};