From 1530d9ae1ca16ed59643e9ddcd224c14de9a5045 Mon Sep 17 00:00:00 2001 From: Chris Ekstedt <9835700+cekstedt@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:02:17 -0800 Subject: [PATCH 1/3] fix: send first success response, not just 200 The current behavior of generated handlers is to send a hard-coded `504 Not Implemented` response if a `200` or `default` response is not defined, per [docs][1]. However, this behavior can be counter-intuitive, especially when other success (2XX) status codes are defined, as these will fail and fall back to the generic `504 Not Implemented`. This proposal would instead respond with the first "success" (2XX) response in the spec, rather than looking for only `200`. This will allow methods like POST and DELETE to specify other status codes like `201 Created` or `204 No Content`, aligning better with REST best practice. If neither "Success" nor `default` response is defined, then the fall-back `504 Not Implemented` response will be sent. File Changes: ============= modified: src/open-api/utils/open-api-utils.ts - function `createResponseResolver()` edited to return the first-defined "Success" (2XX) response, or `default` if defined. If neither "Success" nor `default` response is defined, then the fallback `504 Not Implemented` response will be sent. modified: test/oas/oas-json-schema.test.ts - Tests added to reflect new expectations. - Returns `201` for POST - Returns `204` for DELETE - Returns `201` even out of order - Returns `201` even with default [1]: https://source.mswjs.io/docs/integrations/open-api#response-definition --- src/open-api/utils/open-api-utils.ts | 27 +++++-- test/oas/oas-json-schema.test.ts | 110 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/open-api/utils/open-api-utils.ts b/src/open-api/utils/open-api-utils.ts index ffe07e9..122c737 100644 --- a/src/open-api/utils/open-api-utils.ts +++ b/src/open-api/utils/open-api-utils.ts @@ -26,6 +26,7 @@ export function createResponseResolver( } let responseObject: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject + let status: number = NaN const url = new URL(request.url) const explicitResponseStatus = url.searchParams.get('response') @@ -43,14 +44,26 @@ export function createResponseResolver( } responseObject = responseByStatus + status = Number(explicitResponseStatus) } else { - const fallbackResponse = - (responses['200'] as - | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject) || - (responses.default as + let fallbackResponse + + for (const [key, _] of Object.entries(STATUS_CODES)) { + if (key.startsWith('2') && responses[key]) { + fallbackResponse = responses[key] as + | OpenAPIV3.ResponseObject + | OpenAPIV3_1.ResponseObject + status = Number(key) + break + } + } + + if (!fallbackResponse && responses.default) { + fallbackResponse = responses.default as | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject) + | OpenAPIV3_1.ResponseObject + status = 200 + } if (!fallbackResponse) { return new Response('Not Implemented', { @@ -62,8 +75,6 @@ export function createResponseResolver( responseObject = fallbackResponse } - const status = Number(explicitResponseStatus || '200') - return new Response(toBody(request, responseObject), { status, statusText: STATUS_CODES[status], diff --git a/test/oas/oas-json-schema.test.ts b/test/oas/oas-json-schema.test.ts index 2d45516..beec2aa 100644 --- a/test/oas/oas-json-schema.test.ts +++ b/test/oas/oas-json-schema.test.ts @@ -292,3 +292,113 @@ it('respects the "Accept" request header', async () => { }), ) }) + +it('responds with 201 to a POST request with a 201 response defined', async () => { + const handlers = await fromOpenApi( + createOpenApiSpec({ + paths: { + '/resource': { + post: { + responses: { + 201: { description: 'Created' }, + }, + }, + }, + }, + }), + ) + + const response = await withHandlers(handlers, () => { + return fetch('http://localhost/resource', { + method: 'POST', + body: JSON.stringify({ username: 'example' }), + headers: { + 'Content-Type': 'application/json', + }, + }) + }) + + expect(response.status).toEqual(201) +}) + +it('responds with 204 to a DELETE request with a 204 response defined', async () => { + const handlers = await fromOpenApi( + createOpenApiSpec({ + paths: { + '/resource': { + delete: { + responses: { + 204: { description: 'No Content' }, + }, + }, + }, + }, + }), + ) + + const response = await withHandlers(handlers, () => { + return fetch('http://localhost/resource', { + method: 'DELETE', + }) + }) + + expect(response.status).toEqual(204) +}) + +it('responds with 201 to a POST request even if defined out of order', async () => { + const handlers = await fromOpenApi( + createOpenApiSpec({ + paths: { + '/resource': { + post: { + responses: { + 404: { description: 'Not Found' }, + 201: { description: 'Created' }, + }, + }, + }, + }, + }), + ) + + const response = await withHandlers(handlers, () => { + return fetch('http://localhost/resource', { + method: 'POST', + body: JSON.stringify({ username: 'example' }), + headers: { + 'Content-Type': 'application/json', + }, + }) + }) + + expect(response.status).toEqual(201) +}) + +it('responds with 201 to a POST request even if default is defined', async () => { + const handlers = await fromOpenApi( + createOpenApiSpec({ + paths: { + '/resource': { + post: { + responses: { + 201: { description: 'Created' }, + default: { description: 'An unexpected error occurred' }, + }, + }, + }, + }, + }), + ) + + const response = await withHandlers(handlers, () => { + return fetch('http://localhost/resource', { + method: 'POST', + body: JSON.stringify({ username: 'example' }), + headers: { + 'Content-Type': 'application/json', + }, + }) + }) + + expect(response.status).toEqual(201) +}) From c22c4184b9af18e5febd51f1f5b9fe15a2e9511c Mon Sep 17 00:00:00 2001 From: Chris Ekstedt <9835700+cekstedt@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:58:36 -0800 Subject: [PATCH 2/3] fix: refactor status code logic into own function This commit extracts the status-selecting logic from `createResponseResolver()` and into its own function, `getResponseStatusCode()`. This creates better readability in the affected functions, and allows for unit testing the logic in isolation, which has been added to `open-api-utils.test.ts`. File Changes: ============= modified: src/open-api/utils/open-api-utils.ts - function `getResponseStatusCode()` added to return extract the logic for deciding which response to return for a given operation and request. - function `createResponseResolver()` adapted to utilize `getResponseStatusCode()`. modified: src/open-api/utils/open-api-utils.test.ts - Added unit tests based on current status- selecting logic. --- src/open-api/utils/open-api-utils.test.ts | 76 +++++++++++- src/open-api/utils/open-api-utils.ts | 143 ++++++++++++++-------- 2 files changed, 165 insertions(+), 54 deletions(-) diff --git a/src/open-api/utils/open-api-utils.test.ts b/src/open-api/utils/open-api-utils.test.ts index 05132ec..83b4167 100644 --- a/src/open-api/utils/open-api-utils.test.ts +++ b/src/open-api/utils/open-api-utils.test.ts @@ -1,4 +1,9 @@ -import { getAcceptedContentTypes } from './open-api-utils.js' +import { + getAcceptedContentTypes, + getResponseStatusCode, +} from './open-api-utils.js' + +// Tests for `getAcceptedContentTypes()`. it('returns a single content type as-is', () => { expect( @@ -46,3 +51,72 @@ describe.skip('complex "accept" headers', () => { ).toEqual(['text/plain;format=flowed', 'text/plain', 'text/*', '*/*']) }) }) + +// Tests for `getResponseStatusCode()`. + +it('returns status code specified in url query, if defined', () => { + const responses = { + '204': { description: 'No Content' }, + } + + expect( + getResponseStatusCode(responses, { + url: 'http://localhost/resource?response=204', + }), + ).toEqual(204) +}) + +it('returns `501` if status code is specified in url query, but not defined, even if others are defined', () => { + const responses = { + '204': { description: 'No Content' }, + } + + expect( + getResponseStatusCode(responses, { + url: 'http://localhost/resource?response=201', + }), + ).toEqual(501) +}) + +it('returns `200` if a 200 code response is defined', () => { + const responses = { + '200': { description: 'Success' }, + } + + expect(getResponseStatusCode(responses, {})).toEqual(200) +}) + +it('returns the first defined success (2XX) status code', () => { + const responses = { + '404': { description: 'Not Found' }, + '201': { description: 'Success' }, + '204': { description: 'No Content' }, + } + + expect(getResponseStatusCode(responses, {})).toEqual(201) +}) + +it('returns `default` if a default status code is defined with no success codes', () => { + const responses = { + '404': { description: 'Not Found' }, + default: { description: 'Success' }, + } + + expect(getResponseStatusCode(responses, {})).toEqual('default') +}) + +it('returns `501` as a fallback', () => { + const responses = { + '404': { description: 'Not Found' }, + } + + expect(getResponseStatusCode(responses, {})).toEqual(501) +}) + +it('returns `501` if `responses` is empty', () => { + expect(getResponseStatusCode({}, {})).toEqual(501) +}) + +it('returns `501` if `responses` is undefined', () => { + expect(getResponseStatusCode(undefined, {})).toEqual(501) +}) diff --git a/src/open-api/utils/open-api-utils.ts b/src/open-api/utils/open-api-utils.ts index 122c737..b89a127 100644 --- a/src/open-api/utils/open-api-utils.ts +++ b/src/open-api/utils/open-api-utils.ts @@ -1,86 +1,123 @@ -import type { ResponseResolver } from 'msw' -import { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' +import type { ResponseResolver, StrictRequest, DefaultBodyType } from 'msw' +import { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' import { seedSchema } from '@yellow-ticket/seed-json-schema' import { toString } from './to-string.js' import { STATUS_CODES } from './status-codes.js' +/** + * Create a resolver function based on the responses defined for a given operation. + */ export function createResponseResolver( operation: OpenAPI.Operation, ): ResponseResolver { return ({ request }) => { const { responses } = operation - // Treat operations that describe no responses as not implemented. - if (responses == null) { + // Get the status code that we will return for this request. + const responseStatus = getResponseStatusCode(responses, request) + + // Handle default `Not Implemented` response. + + if (responseStatus === 501) { return new Response('Not Implemented', { status: 501, statusText: 'Not Implemented', }) } - if (Object.keys(responses).length === 0) { - return new Response('Not Implemented', { - status: 501, - statusText: 'Not Implemented', + // After this point we know that `responses` is not `null` or `undefined` + // since, if it were, `responseStatus` would have been `501`. + + if (responseStatus === 'default') { + const responseObject = responses!.default as + | OpenAPIV3.ResponseObject + | OpenAPIV3_1.ResponseObject + + return new Response(toBody(request, responseObject), { + status: 200, + statusText: STATUS_CODES[200], + headers: toHeaders(request, responseObject), }) } - let responseObject: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject - let status: number = NaN + // After this point we know that `responseStatus` is a number + // and that `responses[responseStatus]` is defined. + const responseObject = responses![responseStatus.toString()] as + | OpenAPIV3.ResponseObject + | OpenAPIV3_1.ResponseObject + + return new Response(toBody(request, responseObject), { + status: responseStatus, + statusText: STATUS_CODES[responseStatus], + headers: toHeaders(request, responseObject), + }) + } +} + +/** + * Returns the status code (as a string) that a given handler will return, + * based on defined responses to the given operation and the captured url. + * + * The following logic path is used to determine the status to return: + * + * - Explicit response status if provided by request query string, + * - 501 Not Implemented if explicit response is provided but not defined in spec, + * - 200, + * - The first matching 2xx, + * - responses.default if defined, + * - 501 Not Implemented otherwise. + * + * @param {ResponseObject} responses - The object mapping defined status codes to response objects. + * @param {StrictRequest} request - The request that the handler will be responding to. + */ +export function getResponseStatusCode( + responses: + | OpenAPIV2.ResponsesObject + | OpenAPIV3.ResponsesObject + | OpenAPIV3_1.ResponsesObject + | undefined, + request: StrictRequest | undefined, +): number | 'default' { + // First, if operation has no responses described, always return `Not Implemented`. + if (responses == null || Object.keys(responses).length === 0) { + return 501 + } + + // Next, check if client has specified a "response" query in url. + // (Wrapped to allow unit testing with blank or incomplete `request` objects.) + if (request?.url) { const url = new URL(request.url) const explicitResponseStatus = url.searchParams.get('response') - if (explicitResponseStatus) { - const responseByStatus = responses[ + // If so, send that response, or `Not Implemented` if specified but not defined. + if (responses[explicitResponseStatus]) { explicitResponseStatus - ] as OpenAPIV3.ResponseObject - - if (!responseByStatus) { - return new Response('Not Implemented', { - status: 501, - statusText: 'Not Implemented', - }) - } - - responseObject = responseByStatus - status = Number(explicitResponseStatus) - } else { - let fallbackResponse - - for (const [key, _] of Object.entries(STATUS_CODES)) { - if (key.startsWith('2') && responses[key]) { - fallbackResponse = responses[key] as - | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject - status = Number(key) - break - } - } - - if (!fallbackResponse && responses.default) { - fallbackResponse = responses.default as - | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject - status = 200 + } else { + return 501 } + } + } - if (!fallbackResponse) { - return new Response('Not Implemented', { - status: 501, - statusText: 'Not Implemented', - }) - } + // Next, check for a 200 code response explicitly. + if (responses[200]) { + return 200 + } - responseObject = fallbackResponse + // Next, check for success (2XX) status code responses. + for (const key of Object.keys(STATUS_CODES)) { + if (key.startsWith('2') && responses[key]) { + return Number(key) } + } - return new Response(toBody(request, responseObject), { - status, - statusText: STATUS_CODES[status], - headers: toHeaders(request, responseObject), - }) + // Next, check for a `default` response. + if (responses.default) { + return 'default' } + + // As a last resort, send `Not Implemented`. + return 501 } /** From ed067e053e67f05bc1dc568c610f5e92f325c927 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 2 Feb 2026 18:05:02 +0100 Subject: [PATCH 3/3] chore: polish --- src/open-api/utils/open-api-utils.test.ts | 135 +++++++--------------- src/open-api/utils/open-api-utils.ts | 130 +++++++-------------- 2 files changed, 85 insertions(+), 180 deletions(-) diff --git a/src/open-api/utils/open-api-utils.test.ts b/src/open-api/utils/open-api-utils.test.ts index 83b4167..ce906d5 100644 --- a/src/open-api/utils/open-api-utils.test.ts +++ b/src/open-api/utils/open-api-utils.test.ts @@ -1,34 +1,31 @@ -import { - getAcceptedContentTypes, - getResponseStatusCode, -} from './open-api-utils.js' +import { getAcceptedContentTypes, getResponseStatus } from './open-api-utils.js' -// Tests for `getAcceptedContentTypes()`. - -it('returns a single content type as-is', () => { - expect( - getAcceptedContentTypes(new Headers([['accept', 'text/html']])), - ).toEqual(['text/html']) -}) +describe(getAcceptedContentTypes, () => { + it('returns a single content type as-is', () => { + expect( + getAcceptedContentTypes(new Headers([['accept', 'text/html']])), + ).toEqual(['text/html']) + }) -it('ignores whitespace separating multiple content types', () => { - expect( - getAcceptedContentTypes( - new Headers([['accept', 'text/html, application/xhtml+xml, */*']]), - ), - ).toEqual(['text/html', 'application/xhtml+xml', '*/*']) -}) + it('ignores whitespace separating multiple content types', () => { + expect( + getAcceptedContentTypes( + new Headers([['accept', 'text/html, application/xhtml+xml, */*']]), + ), + ).toEqual(['text/html', 'application/xhtml+xml', '*/*']) + }) -it('removes an empty content type', () => { - expect(getAcceptedContentTypes(new Headers([['accept', ', ,']]))).toEqual([]) + it('removes an empty content type', () => { + expect(getAcceptedContentTypes(new Headers([['accept', ', ,']]))).toEqual( + [], + ) - expect( - getAcceptedContentTypes(new Headers([['accept', 'text/html, , */*']])), - ).toEqual(['text/html', '*/*']) -}) + expect( + getAcceptedContentTypes(new Headers([['accept', 'text/html, , */*']])), + ).toEqual(['text/html', '*/*']) + }) -describe.skip('complex "accept" headers', () => { - it('supports weight reordering', () => { + it.skip('supports weight reordering', () => { expect( getAcceptedContentTypes( new Headers([ @@ -41,7 +38,7 @@ describe.skip('complex "accept" headers', () => { ).toEqual(['text/html', 'text/x-c', 'text/x-dvi', 'text/plain']) }) - it('supports specificity reordering', () => { + it.skip('supports specificity reordering', () => { expect( getAcceptedContentTypes( new Headers([ @@ -52,71 +49,27 @@ describe.skip('complex "accept" headers', () => { }) }) -// Tests for `getResponseStatusCode()`. - -it('returns status code specified in url query, if defined', () => { - const responses = { - '204': { description: 'No Content' }, - } - - expect( - getResponseStatusCode(responses, { - url: 'http://localhost/resource?response=204', - }), - ).toEqual(204) -}) - -it('returns `501` if status code is specified in url query, but not defined, even if others are defined', () => { - const responses = { - '204': { description: 'No Content' }, - } - - expect( - getResponseStatusCode(responses, { - url: 'http://localhost/resource?response=201', - }), - ).toEqual(501) -}) - -it('returns `200` if a 200 code response is defined', () => { - const responses = { - '200': { description: 'Success' }, - } - - expect(getResponseStatusCode(responses, {})).toEqual(200) -}) - -it('returns the first defined success (2XX) status code', () => { - const responses = { - '404': { description: 'Not Found' }, - '201': { description: 'Success' }, - '204': { description: 'No Content' }, - } - - expect(getResponseStatusCode(responses, {})).toEqual(201) -}) - -it('returns `default` if a default status code is defined with no success codes', () => { - const responses = { - '404': { description: 'Not Found' }, - default: { description: 'Success' }, - } - - expect(getResponseStatusCode(responses, {})).toEqual('default') -}) - -it('returns `501` as a fallback', () => { - const responses = { - '404': { description: 'Not Found' }, - } +describe(getResponseStatus, () => { + it('returns 200 if 200 response is defined', () => { + expect(getResponseStatus({ 200: { description: '' } })).toBe('200') + }) - expect(getResponseStatusCode(responses, {})).toEqual(501) -}) + it('returns the first 2xx code if 200 response is not defined', () => { + expect( + getResponseStatus({ + 201: { description: '' }, + }), + ).toBe('201') -it('returns `501` if `responses` is empty', () => { - expect(getResponseStatusCode({}, {})).toEqual(501) -}) + expect( + getResponseStatus({ + 201: { description: '' }, + 202: { description: '' }, + }), + ).toBe('201') + }) -it('returns `501` if `responses` is undefined', () => { - expect(getResponseStatusCode(undefined, {})).toEqual(501) + it('returns undefined as the fallback', () => { + expect(getResponseStatus({})).toBeUndefined() + }) }) diff --git a/src/open-api/utils/open-api-utils.ts b/src/open-api/utils/open-api-utils.ts index b89a127..7c1446c 100644 --- a/src/open-api/utils/open-api-utils.ts +++ b/src/open-api/utils/open-api-utils.ts @@ -1,9 +1,26 @@ -import type { ResponseResolver, StrictRequest, DefaultBodyType } from 'msw' +import type { ResponseResolver } from 'msw' import { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' import { seedSchema } from '@yellow-ticket/seed-json-schema' import { toString } from './to-string.js' import { STATUS_CODES } from './status-codes.js' +/** + * @note Manually type the `responses` object to be dereferenced. + */ +type ResponsesObject = + | { + [index: string]: OpenAPIV2.ResponseObject | undefined + default?: OpenAPIV2.ResponseObject + } + | { + [code: string]: OpenAPIV3.ResponseObject + } + | { + [code: string]: OpenAPIV3_1.ResponseObject + } + +type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject + /** * Create a resolver function based on the responses defined for a given operation. */ @@ -11,113 +28,48 @@ export function createResponseResolver( operation: OpenAPI.Operation, ): ResponseResolver { return ({ request }) => { - const { responses } = operation + const responses = operation.responses as ResponsesObject - // Get the status code that we will return for this request. - const responseStatus = getResponseStatusCode(responses, request) + const explicitResponseStatus = new URL(request.url).searchParams.get( + 'response', + ) - // Handle default `Not Implemented` response. + const responseStatus = + explicitResponseStatus || getResponseStatus(responses) - if (responseStatus === 501) { + const responseObject = responseStatus + ? responses[responseStatus] + : responses.default + + if (responseObject == null) { return new Response('Not Implemented', { status: 501, statusText: 'Not Implemented', }) } - // After this point we know that `responses` is not `null` or `undefined` - // since, if it were, `responseStatus` would have been `501`. - - if (responseStatus === 'default') { - const responseObject = responses!.default as - | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject - - return new Response(toBody(request, responseObject), { - status: 200, - statusText: STATUS_CODES[200], - headers: toHeaders(request, responseObject), - }) - } - - // After this point we know that `responseStatus` is a number - // and that `responses[responseStatus]` is defined. - - const responseObject = responses![responseStatus.toString()] as - | OpenAPIV3.ResponseObject - | OpenAPIV3_1.ResponseObject + const normalizedStatus = Number(responseStatus || '200') return new Response(toBody(request, responseObject), { - status: responseStatus, - statusText: STATUS_CODES[responseStatus], + status: normalizedStatus, + statusText: STATUS_CODES[normalizedStatus], headers: toHeaders(request, responseObject), }) } } -/** - * Returns the status code (as a string) that a given handler will return, - * based on defined responses to the given operation and the captured url. - * - * The following logic path is used to determine the status to return: - * - * - Explicit response status if provided by request query string, - * - 501 Not Implemented if explicit response is provided but not defined in spec, - * - 200, - * - The first matching 2xx, - * - responses.default if defined, - * - 501 Not Implemented otherwise. - * - * @param {ResponseObject} responses - The object mapping defined status codes to response objects. - * @param {StrictRequest} request - The request that the handler will be responding to. - */ -export function getResponseStatusCode( - responses: - | OpenAPIV2.ResponsesObject - | OpenAPIV3.ResponsesObject - | OpenAPIV3_1.ResponsesObject - | undefined, - request: StrictRequest | undefined, -): number | 'default' { - // First, if operation has no responses described, always return `Not Implemented`. - if (responses == null || Object.keys(responses).length === 0) { - return 501 +export function getResponseStatus( + responses: ResponsesObject, +): string | undefined { + if (responses['200']) { + return '200' } - // Next, check if client has specified a "response" query in url. - // (Wrapped to allow unit testing with blank or incomplete `request` objects.) - if (request?.url) { - const url = new URL(request.url) - const explicitResponseStatus = url.searchParams.get('response') - if (explicitResponseStatus) { - // If so, send that response, or `Not Implemented` if specified but not defined. - if (responses[explicitResponseStatus]) { - explicitResponseStatus - } else { - return 501 - } + for (const status in responses) { + if (status.startsWith('2')) { + return status } } - - // Next, check for a 200 code response explicitly. - if (responses[200]) { - return 200 - } - - // Next, check for success (2XX) status code responses. - for (const key of Object.keys(STATUS_CODES)) { - if (key.startsWith('2') && responses[key]) { - return Number(key) - } - } - - // Next, check for a `default` response. - if (responses.default) { - return 'default' - } - - // As a last resort, send `Not Implemented`. - return 501 } /** @@ -204,7 +156,7 @@ export function toHeaders( */ export function toBody( request: Request, - responseObject: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject, + responseObject: ResponseObject, ): RequestInit['body'] { const { content } = responseObject