Skip to content

Commit

Permalink
Normalize request body ContentTypes (#863)
Browse files Browse the repository at this point in the history
Co-authored-by: Ray Vincent <[email protected]>
  • Loading branch information
rayvincent2 and rayvincent2 authored Dec 2, 2023
1 parent 807e09c commit 0099b0d
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 38 deletions.
2 changes: 1 addition & 1 deletion src/middlewares/openapi.request.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class RequestValidator {
const reqSchema = openapi.schema;
// cache middleware by combining method, path, and contentType
const contentType = ContentType.from(req);
const contentTypeKey = contentType.equivalents()[0] ?? 'not_provided';
const contentTypeKey = contentType.normalize() ?? 'not_provided';
// use openapi.expressRoute as path portion of key
const key = `${req.method}-${path}-${contentTypeKey}`;

Expand Down
5 changes: 1 addition & 4 deletions src/middlewares/openapi.response.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,7 @@ export class ResponseValidator {
): { [key: string]: ValidateFunction } {
// 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 contentType = contentTypeMeta.normalize() ?? 'not_provided';

const openapi = <OpenApiRequestMetadata>req.openapi;
const key = `${req.method}-${openapi.expressRoute}-${contentType}`;
Expand Down
29 changes: 22 additions & 7 deletions src/middlewares/parsers/body.parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,24 @@ export class BodySchemaParser {
if (!requestBody?.content) return {};

let content = null;
for (const type of contentType.equivalents()) {
content = requestBody.content[type];
if (content) break;
let requestBodyTypes = Object.keys(requestBody.content);
for (const type of requestBodyTypes) {
let openApiContentType = ContentType.fromString(type);
if (contentType.normalize() == openApiContentType.normalize()) {
content = requestBody.content[type];
break;
}
}

if (!content) {
const equivalentContentTypes = contentType.equivalents();
for (const type of requestBodyTypes) {
let openApiContentType = ContentType.fromString(type);
if (equivalentContentTypes.find((type2) => openApiContentType.normalize() === type2.normalize())) {
content = requestBody.content[type];
break;
}
}
}

if (!content) {
Expand All @@ -49,7 +64,7 @@ export class BodySchemaParser {

const [type] = requestContentType.split('/', 1);

if (new RegExp(`^${type}\/.+$`).test(contentType.contentType)) {
if (new RegExp(`^${type}\/.+$`).test(contentType.normalize())) {
content = requestBody.content[requestContentType];
break;
}
Expand All @@ -58,14 +73,14 @@ export class BodySchemaParser {

if (!content) {
// check if required is false, if so allow request when no content type is supplied
const contentNotProvided = contentType.contentType === 'not_provided';
if ((contentType.contentType === undefined || contentNotProvided) && requestBody.required === false) {
const contentNotProvided = contentType.normalize() === 'not_provided';
if ((contentType.normalize() === undefined || contentNotProvided) && requestBody.required === false) {
return {};
}
const msg =
contentNotProvided
? 'media type not specified'
: `unsupported media type ${contentType.contentType}`;
: `unsupported media type ${contentType.normalize()}`;
throw new UnsupportedMediaType({ path: path, message: msg });
}
return content.schema ?? {};
Expand Down
81 changes: 55 additions & 26 deletions src/middlewares/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@ import { Request } from 'express';
import { ValidationError } from '../framework/types';

export class ContentType {
public readonly contentType: string = null;
public readonly mediaType: string = null;
public readonly charSet: string = null;
public readonly withoutBoundary: string = null;
public readonly isWildCard: boolean;
public readonly parameters: { charset?: string, boundary?: string } & Record<string, string> = {};
private constructor(contentType: string | null) {
this.contentType = contentType;
if (contentType) {
this.withoutBoundary = contentType
.replace(/;\s{0,}boundary.*/, '')
.toLowerCase();
this.mediaType = this.withoutBoundary.split(';')[0].toLowerCase().trim();
this.charSet = this.withoutBoundary.split(';')[1]?.toLowerCase();
this.isWildCard = RegExp(/^[a-z]+\/\*$/).test(this.contentType);
if (this.charSet) {
this.charSet = this.charSet.toLowerCase().trim();
const parameterRegExp = /;\s*([^=]+)=([^;]+)/g;
const paramMatches = contentType.matchAll(parameterRegExp)
if (paramMatches) {
this.parameters = {};
for (let match of paramMatches) {
const key = match[1].toLowerCase();
let value = match[2];

if (key === 'charset') {
// charset parameter is case insensitive
// @see [rfc2046, Section 4.1.2](https://www.rfc-editor.org/rfc/rfc2046#section-4.1.2)
value = value.toLowerCase();
}
this.parameters[key] = value;
};
}
this.mediaType = contentType.split(';')[0].toLowerCase().trim();
this.isWildCard = RegExp(/^[a-z]+\/\*$/).test(contentType);
}
}
public static from(req: Request): ContentType {
Expand All @@ -30,12 +36,30 @@ export class ContentType {
return new ContentType(type);
}

public equivalents(): string[] {
if (!this.withoutBoundary) return [];
if (this.charSet) {
return [this.mediaType, `${this.mediaType}; ${this.charSet}`];
public equivalents(): ContentType[] {
const types: ContentType[] = [];
if (!this.mediaType) {
return types;
}
types.push(new ContentType(this.mediaType));

if (!this.parameters['charset']) {
types.push(new ContentType(`${this.normalize(['charset'])}; charset=utf-8`));
}
return [this.withoutBoundary, `${this.mediaType}; charset=utf-8`];
return types;
}

public normalize(excludeParams: string[] = ['boundary']): string {
let parameters = '';
Object.keys(this.parameters)
.sort()
.forEach((key) => {
if (!excludeParams.includes(key)) {
parameters += `; ${key}=${this.parameters[key]}`
}
});
if (this.mediaType)
return this.mediaType + parameters;
}
}

Expand Down Expand Up @@ -105,23 +129,28 @@ export const findResponseContent = function (
accepts: string[],
expectedTypes: string[],
): string {
const expectedTypesSet = new Set(expectedTypes);
const expectedTypesMap = new Map();
for(let type of expectedTypes) {
expectedTypesMap.set(ContentType.fromString(type).normalize(), type);
}

// 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 === '*/*') {
const normalizedCT = act.normalize();
if (normalizedCT === '*/*') {
return expectedTypes[0];
} else if (expectedTypesSet.has(act.contentType)) {
return act.contentType;
} else if (expectedTypesSet.has(act.mediaType)) {
} else if (expectedTypesMap.has(normalizedCT)) {
return normalizedCT;
} else if (expectedTypesMap.has(act.mediaType)) {
return act.mediaType;
} else if (act.isWildCard) {
// wildcard of type application/*
const [type] = act.contentType.split('/', 1);
const [type] = normalizedCT.split('/', 1);

for (const expectedType of expectedTypesSet) {
if (new RegExp(`^${type}\/.+$`).test(expectedType)) {
return expectedType;
for (const expectedType of expectedTypesMap) {
if (new RegExp(`^${type}\/.+$`).test(expectedType[0])) {
return expectedType[1];
}
}
} else {
Expand Down
10 changes: 10 additions & 0 deletions test/common/app.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,14 @@ export function routes(app) {
id: 'new-id',
});
});

app.post('/v1/pets_content_types', function(req: Request, res: Response): void {
// req.file is the `avatar` file
// req.body will hold the text fields, if there were any
res.json({
...req.body,
contentType: req.headers['content-type'],
id: 'new-id',
});
});
}
64 changes: 64 additions & 0 deletions test/headers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,69 @@ describe(packageJson.name, () => {
tag: 'cat',
})
.expect(200));

it('should succeed in sending a content-type: "application/json; version=1" in multiple ways', async () =>{
await request(app)
.post(`${app.basePath}/pets_content_types`)
.set('Content-Type', 'application/json; version=1')
.set('Accept', 'application/json')
.send({
name: 'myPet',
tag: 'cat',
})
.expect(200)
.expect((res) => {
expect(res.body.contentType).to.equal('application/json; version=1')
});

await request(app)
.post(`${app.basePath}/pets_content_types`)
.set('Content-Type', 'application/json;version=1')
.set('Accept', 'application/json')
.send({
name: 'myPet',
tag: 'cat',
})
.expect(200)
.expect((res) => {
expect(res.body.contentType).to.equal('application/json;version=1')
});
});

it('should throw a 415 error for unsupported "application/json; version=2" content type', async () =>
request(app)
.post(`${app.basePath}/pets_content_types`)
.set('Content-Type', 'application/json; version=2')
.set('Accept', 'application/json')
.send({
name: 'myPet',
tag: 'cat',
})
.expect(415));

it('should throw a 415 error for a path/method which has previously succeeded using different request body content types', async () => {
await request(app)
.post(`${app.basePath}/pets_content_types`)
.set('Content-Type', 'application/json;version=1')
.set('Accept', 'application/json')
.send({
name: 'myPet',
tag: 'cat',
})
.expect(200)
.expect((res) => {
expect(res.body.contentType).to.equal('application/json;version=1')
});

await request(app)
.post(`${app.basePath}/pets_content_types`)
.set('Content-Type', 'application/json; version=3')
.set('Accept', 'application/json')
.send({
name: 'myPet',
tag: 'cat',
})
.expect(415);
});
});
});
24 changes: 24 additions & 0 deletions test/resources/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,30 @@ paths:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
/pets_content_types:
post:
description: Creates a new pet in the store. Duplicates are allowed
operationId: addPet
requestBody:
description: Pet to add to the store
required: true
content:
application/json; version=1:
schema:
$ref: '#/components/schemas/NewPet'
responses:
'200':
description: pet response
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
default:
description: unexpected error
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'

components:
parameters:
Expand Down

0 comments on commit 0099b0d

Please sign in to comment.