diff --git a/docs/guides/06-errors.md b/docs/guides/06-errors.md index 0afb0885a..84371dca6 100644 --- a/docs/guides/06-errors.md +++ b/docs/guides/06-errors.md @@ -256,6 +256,14 @@ paths: **Explanation:** This error occurs when the current request has matched a corresponding HTTP Operation and has passed all the validations, but there's no response that could be returned. +### INVALID_CONTENT_TYPE + +**Message: Supported content types: _list_ ** + +**Returned Status Code: `415`** + +**Explanation:** This error occurs when the current request uses content-type that is not supported by corresponding HTTP Operation. + ##### Example ```yaml diff --git a/package.json b/package.json index 59467824f..272b755c0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@types/faker": "^5.5.8", "@types/jest": "^27.0.2", "@types/json-schema": "^7.0.9", - "@types/json-schema-faker": "^0.5.1", "@types/lodash": "^4.14.175", "@types/node": "^13.1.1", "@types/node-fetch": "2.5.10", diff --git a/packages/cli/src/extensions.ts b/packages/cli/src/extensions.ts index 4ae0c3734..fefc79d1a 100644 --- a/packages/cli/src/extensions.ts +++ b/packages/cli/src/extensions.ts @@ -7,8 +7,12 @@ export async function configureExtensionsFromSpec(specFilePathOrObject: string | const result = decycle(await dereference(specFilePathOrObject)); forOwn(get(result, 'x-json-schema-faker', {}), (value: any, option: string) => { - if (option === 'locale') return jsf.locate('faker').setLocale(value); + if (option === 'locale') { + // @ts-ignore + return jsf.locate('faker').setLocale(value); + } + // @ts-ignore jsf.option(camelCase(option), value); }); } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d6e0b81a5..d0e7ae8a9 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,8 +4,7 @@ "paths": { "@stoplight/prism-core": ["./core/src"], "@stoplight/prism-http": ["./http/src"], - "@stoplight/prism-http-server": ["./http-server/src"], - "json-schema-faker": ["../node_modules/@types/json-schema-faker/index.d.ts"] + "@stoplight/prism-http-server": ["./http-server/src"] } } } diff --git a/packages/http-server/src/__tests__/body-params-validation.spec.ts b/packages/http-server/src/__tests__/body-params-validation.spec.ts index da47f49a8..2af5ff10e 100644 --- a/packages/http-server/src/__tests__/body-params-validation.spec.ts +++ b/packages/http-server/src/__tests__/body-params-validation.spec.ts @@ -530,8 +530,8 @@ describe('body params validation', () => { test('returns 422', async () => { const response = await makeRequest('/path', { method: 'POST', - body: '{}', - headers: { 'content-type': 'application/json' }, + body: '', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, }); expect(response.status).toBe(422); @@ -559,11 +559,11 @@ describe('body params validation', () => { test('returns 422 & proper validation message', async () => { const response = await makeRequest('/path', { method: 'POST', - body: JSON.stringify({ + body: new URLSearchParams({ id: 'not integer', status: 'somerundomestuff', - }), - headers: { 'content-type': 'application/json' }, + }).toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, }); expect(response.status).toBe(422); @@ -591,11 +591,11 @@ describe('body params validation', () => { test('returns 200', async () => { const response = await makeRequest('/path', { method: 'POST', - body: JSON.stringify({ - id: 123, + body: new URLSearchParams({ + id: '123', status: 'open', - }), - headers: { 'content-type': 'application/json' }, + }).toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, }); expect(response.status).toBe(200); diff --git a/packages/http/src/mocker/errors.ts b/packages/http/src/mocker/errors.ts index ea745f6e5..fe0e5b2d6 100644 --- a/packages/http/src/mocker/errors.ts +++ b/packages/http/src/mocker/errors.ts @@ -35,3 +35,9 @@ export const VIOLATIONS: Omit = { title: 'Request/Response not valid', status: 500, }; + +export const INVALID_CONTENT_TYPE: Omit = { + type: 'INVALID_CONTENT_TYPE', + title: 'Invalid content type', + status: 415, +}; diff --git a/packages/http/src/mocker/generator/JSONSchema.ts b/packages/http/src/mocker/generator/JSONSchema.ts index 2dc37b43a..29003ab25 100644 --- a/packages/http/src/mocker/generator/JSONSchema.ts +++ b/packages/http/src/mocker/generator/JSONSchema.ts @@ -10,8 +10,10 @@ import { pipe } from 'fp-ts/function'; import * as E from 'fp-ts/lib/Either'; import { stripWriteOnlyProperties } from '../../utils/filterRequiredProperties'; +// @ts-ignore jsf.extend('faker', () => faker); +// @ts-ignore jsf.option({ failOnInvalidTypes: false, failOnInvalidFormat: false, @@ -28,6 +30,7 @@ export function generate(bundle: unknown, source: JSONSchema): Either Error('Cannot strip writeOnly properties')), E.chain(updatedSource => + // @ts-ignore tryCatch(() => jsf.generate({ ...cloneDeep(updatedSource), __bundled__: bundle }), toError) ) ); diff --git a/packages/http/src/mocker/index.ts b/packages/http/src/mocker/index.ts index 1d3909044..2b0ed6949 100644 --- a/packages/http/src/mocker/index.ts +++ b/packages/http/src/mocker/index.ts @@ -30,7 +30,7 @@ import { ProblemJsonError, } from '../types'; import withLogger from '../withLogger'; -import { UNAUTHORIZED, UNPROCESSABLE_ENTITY } from './errors'; +import { UNAUTHORIZED, UNPROCESSABLE_ENTITY, INVALID_CONTENT_TYPE } from './errors'; import { generate, generateStatic } from './generator/JSONSchema'; import helpers from './negotiator/NegotiatorHelpers'; import { IHttpNegotiationResult } from './negotiator/types'; @@ -69,16 +69,17 @@ const mock: IPrismComponents negotiateResponse(mockConfig, input, resource)), R.chain(result => negotiateDeprecation(result, resource)), R.chain(result => assembleResponse(result, payloadGenerator)), - R.chain(response => - /* Note: This is now just logging the errors without propagating them back. This might be moved as a first + R.chain( + response => + /* Note: This is now just logging the errors without propagating them back. This might be moved as a first level concept in Prism. */ - logger => - pipe( - response, - E.map(response => runCallbacks({ resource, request: input.data, response })(logger)), - E.chain(() => response) - ) + logger => + pipe( + response, + E.map(response => runCallbacks({ resource, request: input.data, response })(logger)), + E.chain(() => response) + ) ) ); }; @@ -153,7 +154,7 @@ export function createInvalidInputResponse( ): R.Reader> { const securityValidation = failedValidations.find(validation => validation.code === 401); - const expectedCodes: NonEmptyArray = securityValidation ? [401] : [422, 400]; + const expectedCodes = getExpectedCodesForViolations(failedValidations); const isExampleKeyFromExpectedCodes = !!mockConfig.code && expectedCodes.includes(mockConfig.code); return pipe( @@ -169,15 +170,41 @@ export function createInvalidInputResponse( if (error instanceof ProblemJsonError && error.status === 404) { return error; } - return securityValidation - ? createUnauthorisedResponse(securityValidation.tags) - : createUnprocessableEntityResponse(failedValidations); + return createResponseForViolations(failedValidations); }) ) ) ); } +function getExpectedCodesForViolations(failedValidations: NonEmptyArray): NonEmptyArray { + const hasSecurityViolations = failedValidations.find(validation => validation.code === 401); + if (hasSecurityViolations) { + return [401]; + } + + const hasInvalidContentTypeViolations = failedValidations.find(validation => validation.code === 415); + if (hasInvalidContentTypeViolations) { + return [415, 422, 400]; + } + + return [422, 400]; +} + +function createResponseForViolations(failedValidations: NonEmptyArray) { + const securityViolation = failedValidations.find(validation => validation.code === 401); + if (securityViolation) { + return createUnauthorisedResponse(securityViolation.tags); + } + + const invalidContentViolation = failedValidations.find(validation => validation.code === 415); + if (invalidContentViolation) { + return createInvalidContentTypeResponse(invalidContentViolation); + } + + return createUnprocessableEntityResponse(failedValidations); +} + export const createUnauthorisedResponse = (tags?: string[]): ProblemJsonError => ProblemJsonError.fromTemplate( UNAUTHORIZED, @@ -199,6 +226,9 @@ export const createUnprocessableEntityResponse = (validations: NonEmptyArray + ProblemJsonError.fromTemplate(INVALID_CONTENT_TYPE, validation.message); + function negotiateResponse( mockConfig: IHttpOperationConfig, input: IPrismInput, @@ -244,39 +274,41 @@ function negotiateDeprecation( return RE.fromEither(result); } -const assembleResponse = ( - result: E.Either, - payloadGenerator: PayloadGenerator -): R.Reader> => logger => - pipe( - E.Do, - E.bind('negotiationResult', () => result), - E.bind('mockedData', ({ negotiationResult }) => - eitherSequence( - computeBody(negotiationResult, payloadGenerator), - computeMockedHeaders(negotiationResult.headers || [], payloadGenerator) - ) - ), - E.map(({ mockedData: [mockedBody, mockedHeaders], negotiationResult }) => { - const response: IHttpResponse = { - statusCode: parseInt(negotiationResult.code), - headers: { - ...mockedHeaders, - ...(negotiationResult.mediaType && { - 'Content-type': negotiationResult.mediaType, - }), - ...(negotiationResult.deprecated && { - deprecation: 'true', - }), - }, - body: mockedBody, - }; - - logger.success(`Responding with the requested status code ${response.statusCode}`); - - return response; - }) - ); +const assembleResponse = + ( + result: E.Either, + payloadGenerator: PayloadGenerator + ): R.Reader> => + logger => + pipe( + E.Do, + E.bind('negotiationResult', () => result), + E.bind('mockedData', ({ negotiationResult }) => + eitherSequence( + computeBody(negotiationResult, payloadGenerator), + computeMockedHeaders(negotiationResult.headers || [], payloadGenerator) + ) + ), + E.map(({ mockedData: [mockedBody, mockedHeaders], negotiationResult }) => { + const response: IHttpResponse = { + statusCode: parseInt(negotiationResult.code), + headers: { + ...mockedHeaders, + ...(negotiationResult.mediaType && { + 'Content-type': negotiationResult.mediaType, + }), + ...(negotiationResult.deprecated && { + deprecation: 'true', + }), + }, + body: mockedBody, + }; + + logger.success(`Responding with the requested status code ${response.statusCode}`); + + return response; + }) + ); function isINodeExample(nodeExample: ContentExample | undefined): nodeExample is INodeExample { return !!nodeExample && 'value' in nodeExample; diff --git a/packages/http/src/validator/index.ts b/packages/http/src/validator/index.ts index 3f296c0c8..bd477d2e4 100644 --- a/packages/http/src/validator/index.ts +++ b/packages/http/src/validator/index.ts @@ -14,7 +14,7 @@ import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import { sequenceOption, sequenceValidation } from '../combinators'; import { is as typeIs } from 'type-is'; -import { pipe } from 'fp-ts/function'; +import { pipe, flow } from 'fp-ts/function'; import { inRange, isMatch } from 'lodash'; import { URI } from 'uri-template-lite'; import { IHttpRequest, IHttpResponse } from '../types'; @@ -34,6 +34,22 @@ const checkBodyIsProvided = (requestBody: IHttpOperationRequestBody, body: unkno ) ); +const isMediaTypeValid = (mediaType?: string, contents?: IMediaTypeContent[]): boolean => + pipe( + O.fromNullable(mediaType), + O.fold( + () => true, + mediaType => + pipe( + O.fromNullable(contents), + O.fold( + () => true, + contents => !!contents.find(x => !!typeIs(mediaType, x.mediaType)) + ) + ) + ) + ); + const validateInputIfBodySpecIsProvided = ( body: unknown, mediaType: string, @@ -56,6 +72,20 @@ const tryValidateInputBody = ( ) => pipe( checkBodyIsProvided(requestBody, body), + E.chain(() => { + if (isMediaTypeValid(mediaType, requestBody.contents)) { + return E.right(body); + } + + const supportedContentTypes = (requestBody.contents || []).map(x => x.mediaType); + return E.left>([ + { + message: `Supported content types: ${supportedContentTypes.join(',')}`, + code: 415, + severity: DiagnosticSeverity.Error, + }, + ]); + }), E.chain(() => validateInputIfBodySpecIsProvided(body, mediaType, requestBody.contents, bundle)) ); diff --git a/packages/http/tsconfig.json b/packages/http/tsconfig.json index ee85d8df4..db5b2ad76 100644 --- a/packages/http/tsconfig.json +++ b/packages/http/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "resolveJsonModule": true, "paths": { - "@stoplight/prism-core": ["./core/src"], - "json-schema-faker": ["../node_modules/@types/json-schema-faker/index.d.ts"] + "@stoplight/prism-core": ["./core/src"] } } } diff --git a/yarn.lock b/yarn.lock index 09c4d419f..9b91c47cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1729,13 +1729,6 @@ jest-diff "^27.0.0" pretty-format "^27.0.0" -"@types/json-schema-faker@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@types/json-schema-faker/-/json-schema-faker-0.5.1.tgz#6558d9704ab8b08c982846a7bdb73a41576ae8e1" - integrity sha512-gXkZKNeQEMLFH2aYVG+ZSxdrLN2MCi0V6CoB3RAcUSz1BTfXntCOpTDdrfx+rTQ3x2sctFjM3gGauqDVxDXI7g== - dependencies: - "@types/json-schema" "*" - "@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"