Skip to content

Commit

Permalink
feat: 1813 start using 415 code for invalid content-types instead con…
Browse files Browse the repository at this point in the history
…stantly inferring it
  • Loading branch information
Łukasz Kużyński committed Nov 3, 2021
1 parent c774510 commit df475fc
Show file tree
Hide file tree
Showing 11 changed files with 142 additions and 69 deletions.
8 changes: 8 additions & 0 deletions docs/guides/06-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
3 changes: 1 addition & 2 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/http/src/mocker/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ export const VIOLATIONS: Omit<ProblemJson, 'detail'> = {
title: 'Request/Response not valid',
status: 500,
};

export const INVALID_CONTENT_TYPE: Omit<ProblemJson, 'detail'> = {
type: 'INVALID_CONTENT_TYPE',
title: 'Invalid content type',
status: 415,
};
3 changes: 3 additions & 0 deletions packages/http/src/mocker/generator/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +30,7 @@ export function generate(bundle: unknown, source: JSONSchema): Either<Error, unk
stripWriteOnlyProperties(source),
E.fromOption(() => Error('Cannot strip writeOnly properties')),
E.chain(updatedSource =>
// @ts-ignore
tryCatch(() => jsf.generate({ ...cloneDeep(updatedSource), __bundled__: bundle }), toError)
)
);
Expand Down
124 changes: 78 additions & 46 deletions packages/http/src/mocker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,16 +69,17 @@ const mock: IPrismComponents<IHttpOperation, IHttpRequest, IHttpResponse, IHttpM
R.chain(mockConfig => 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)
)
)
);
};
Expand Down Expand Up @@ -153,7 +154,7 @@ export function createInvalidInputResponse(
): R.Reader<Logger, E.Either<ProblemJsonError, IHttpNegotiationResult>> {
const securityValidation = failedValidations.find(validation => validation.code === 401);

const expectedCodes: NonEmptyArray<number> = securityValidation ? [401] : [422, 400];
const expectedCodes = getExpectedCodesForViolations(failedValidations);
const isExampleKeyFromExpectedCodes = !!mockConfig.code && expectedCodes.includes(mockConfig.code);

return pipe(
Expand All @@ -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<IPrismDiagnostic>): NonEmptyArray<number> {
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<IPrismDiagnostic>) {
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,
Expand All @@ -199,6 +226,9 @@ export const createUnprocessableEntityResponse = (validations: NonEmptyArray<IPr
}
);

export const createInvalidContentTypeResponse = (validation: IPrismDiagnostic): ProblemJsonError =>
ProblemJsonError.fromTemplate(INVALID_CONTENT_TYPE, validation.message);

function negotiateResponse(
mockConfig: IHttpOperationConfig,
input: IPrismInput<IHttpRequest>,
Expand Down Expand Up @@ -244,39 +274,41 @@ function negotiateDeprecation(
return RE.fromEither(result);
}

const assembleResponse = (
result: E.Either<Error, IHttpNegotiationResult>,
payloadGenerator: PayloadGenerator
): R.Reader<Logger, E.Either<Error, IHttpResponse>> => 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<Error, IHttpNegotiationResult>,
payloadGenerator: PayloadGenerator
): R.Reader<Logger, E.Either<Error, IHttpResponse>> =>
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;
Expand Down
32 changes: 31 additions & 1 deletion packages/http/src/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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<NonEmptyArray<IPrismDiagnostic>>([
{
message: `Supported content types: ${supportedContentTypes.join(',')}`,
code: 415,
severity: DiagnosticSeverity.Error,
},
]);
}),
E.chain(() => validateInputIfBodySpecIsProvided(body, mediaType, requestBody.contents, bundle))
);

Expand Down
3 changes: 1 addition & 2 deletions packages/http/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
7 changes: 0 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit df475fc

Please sign in to comment.