Skip to content

Commit

Permalink
fix response type inference
Browse files Browse the repository at this point in the history
  • Loading branch information
cdimascio committed Sep 6, 2020
1 parent 079ae72 commit 10fd39b
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 86 deletions.
112 changes: 48 additions & 64 deletions src/middlewares/openapi.response.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
augmentAjvErrors,
ContentType,
ajvErrorsToValidatorError,
findResponseContent,
} from './util';
import {
OpenAPIV3,
OpenApiRequest,
OpenApiRequestMetadata,
InternalServerError,
ValidationError,
} from '../framework/types';
import * as mediaTypeParser from 'media-typer';
import * as contentTypeParser from 'content-type';
Expand All @@ -22,7 +22,7 @@ interface ValidateResult {
body: object;
statusCode: number;
path: string;
contentType?: string;
accepts: string[];
}
export class ResponseValidator {
private ajv: ajv.Ajv;
Expand All @@ -45,24 +45,24 @@ export class ResponseValidator {
const openapi = <OpenApiRequestMetadata>req.openapi;
const responses = openapi.schema?.responses;

const contentTypeMeta = ContentType.from(req);
const contentType =
(contentTypeMeta.contentType?.indexOf('multipart') > -1
? contentTypeMeta.equivalents()[0]
: contentTypeMeta.contentType) ?? 'not_provided';
const validators = this._getOrBuildValidator(
req,
responses,
contentType,
);
const statusCode = res.statusCode;
const validators = this._getOrBuildValidator(req, responses);
const path = req.originalUrl;
const statusCode = res.statusCode;
const contentType = res.getHeaders()['content-type'];
const accept = req.headers['accept'];
// ir response has a content type use it, else use accept headers
const accepts: [string] = contentType
? [contentType]
: accept
? accept.split(',').map((h) => h.trim())
: [];

return this._validate({
validators,
body,
statusCode,
path,
contentType,
accepts, // return 406 if not acceptable
});
}
return body;
Expand All @@ -74,12 +74,13 @@ export class ResponseValidator {
public _getOrBuildValidator(
req: OpenApiRequest,
responses: OpenAPIV3.ResponsesObject,
contentType: string,
): { [key: string]: ajv.ValidateFunction } {
if (!req) {
// use !req is only possible in unit tests
return this.buildValidators(responses);
}
// get the request content type - used only to build the cache key
const contentTypeMeta = ContentType.from(req);
const contentType =
(contentTypeMeta.contentType?.indexOf('multipart') > -1
? contentTypeMeta.equivalents()[0]
: contentTypeMeta.contentType) ?? 'not_provided';

const openapi = <OpenApiRequestMetadata>req.openapi;
const key = `${req.method}-${openapi.expressRoute}-${contentType}`;
Expand All @@ -98,57 +99,40 @@ export class ResponseValidator {
body,
statusCode,
path,
contentType,
accepts, // optional
}: ValidateResult): void {
// find the validator for the 'status code' e.g 200, 2XX or 'default'
let validator;
const status = statusCode;
if (status) {
const statusXX = status.toString()[0] + 'XX';
let svalidator;
if (status in validators) {
svalidator = validators[status];
} else if (statusXX in validators) {
svalidator = validators[statusXX];
} else if (validators.default) {
svalidator = validators.default;
} else {
throw new InternalServerError({
path: path,
message: `no schema defined for status code '${status}' in the openapi spec`,
});
}

validator = svalidator[contentType];

if (!validator) {
// wildcard support
for (const validatorContentType of Object.keys(svalidator)
.sort()
.reverse()) {
if (validatorContentType === '*/*') {
validator = svalidator[validatorContentType];
break;
}

if (RegExp(/^[a-z]+\/\*$/).test(validatorContentType)) {
// wildcard of type application/*
const [type] = validatorContentType.split('/', 1);
const status = statusCode ?? 'default';
const statusXX = status.toString()[0] + 'XX';
let svalidator;
if (status in validators) {
svalidator = validators[status];
} else if (statusXX in validators) {
svalidator = validators[statusXX];
} else if (validators.default) {
svalidator = validators.default;
} else {
throw new InternalServerError({
path: path,
message: `no schema defined for status code '${status}' in the openapi spec`,
});
}

if (new RegExp(`^${type}\/.+$`).test(contentType)) {
validator = svalidator[validatorContentType];
break;
}
}
}
}
const validatorContentTypes = Object.keys(svalidator);
const contentType =
findResponseContent(accepts, validatorContentTypes) ||
validatorContentTypes[0]; // take first contentType, if none found

if (!validator) validator = svalidator[Object.keys(svalidator)[0]]; // take first for backwards compatibility
if (!contentType) {
// not contentType inferred, assume valid
console.warn('no contentType found');
return;
}

const validator = svalidator[contentType];

if (!validator) {
// no validator found, assume valid
console.warn('no validator found');
// assume valid
return;
}

Expand Down Expand Up @@ -296,4 +280,4 @@ export class ResponseValidator {
mediaTypeParsed.subtype === 'json' || mediaTypeParsed.suffix === 'json'
);
}
}
}
55 changes: 51 additions & 4 deletions src/middlewares/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import { Request } from 'express';
import { ValidationError } from '../framework/types';

export class ContentType {
public contentType: string = null;
public mediaType: string = null;
public charSet: string = null;
public withoutBoundary: string = null;
public readonly contentType: string = null;
public readonly mediaType: string = null;
public readonly charSet: string = null;
public readonly withoutBoundary: string = null;
public readonly isWildCard: boolean;
private constructor(contentType: string | null) {
this.contentType = contentType;
if (contentType) {
this.withoutBoundary = contentType.replace(/;\s{0,}boundary.*/, '');
this.mediaType = this.withoutBoundary.split(';')[0].trim();
this.charSet = this.withoutBoundary.split(';')[1];
this.isWildCard = RegExp(/^[a-z]+\/\*$/).test(this.contentType);
if (this.charSet) {
this.charSet = this.charSet.trim();
}
Expand All @@ -22,6 +24,10 @@ export class ContentType {
return new ContentType(req.headers['content-type']);
}

public static fromString(type: string): ContentType {
return new ContentType(type);
}

public equivalents(): string[] {
if (!this.withoutBoundary) return [];
if (this.charSet) {
Expand Down Expand Up @@ -75,3 +81,44 @@ export function ajvErrorsToValidatorError(

export const deprecationWarning =
process.env.NODE_ENV !== 'production' ? console.warn : () => {};

/**
*
* @param accepts the list of accepted media types
* @param expectedTypes - expected media types defined in the response schema
* @returns the content-type
*/
export const findResponseContent = function (
accepts: string[],
expectedTypes: string[],
): string {
const expectedTypesSet = new Set(expectedTypes);
// if accepts are supplied, try to find a match, and use its validator
for (const accept of accepts) {
const act = ContentType.fromString(accept);
if (act.contentType === '*/*') {
return expectedTypes[0];
} else if (expectedTypesSet.has(act.contentType)) {
return act.contentType;
} else if (expectedTypesSet.has(act.mediaType)) {
return act.mediaType;
} else if (act.isWildCard) {
// wildcard of type application/*
const [type] = act.contentType.split('/', 1);

for (const expectedType of expectedTypesSet) {
if (new RegExp(`^${type}\/.+$`).test(expectedType)) {
return expectedType;
}
}
} else {
for (const expectedType of expectedTypes) {
const ect = ContentType.fromString(expectedType);
if (ect.mediaType === act.mediaType) {
return expectedType;
}
}
}
}
return null;
};
50 changes: 50 additions & 0 deletions test/content.type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { findResponseContent, ContentType } from '../src/middlewares/util';
import { expect } from 'chai';

describe('contentType', () => {
it('should match wildcard type */*', async () => {
const expectedTypes = ['application/json', 'application/xml'];
const accepts = ['*/*'];

const contentType = findResponseContent(accepts, expectedTypes);
expect(contentType).to.equal(expectedTypes[0]);
});

it('should match wildcard type application/*', async () => {
const expectedTypes = ['application/json', 'application/xml'];
const accepts = ['application/*'];

const contentType = findResponseContent(accepts, expectedTypes);
expect(contentType).to.equal(expectedTypes[0]);
});

it('should null if no accept specified', async () => {
const expectedTypes = ['application/json', 'application/xml'];
const accepts = [];

const contentType = findResponseContent(accepts, expectedTypes);
expect(contentType).to.equal(null);
});

it('should match media type if charset is not specified in accepts', async () => {
const expectedTypes = [
'application/json; charset=utf-8',
'application/xml',
];
const accepts = ['application/json'];

const contentType = findResponseContent(accepts, expectedTypes);
expect(contentType).to.equal(expectedTypes[0]);
});

it('should match media type if charset is specified in accepts, but charset not defined in schema', async () => {
const expectedTypes = [
'application/json',
'application/xml',
];
const accepts = ['application/json; charset=utf-8'];

const contentType = findResponseContent(accepts, expectedTypes);
expect(contentType).to.equal(expectedTypes[0]);
});
});
Loading

0 comments on commit 10fd39b

Please sign in to comment.