Skip to content

Commit 7ff2fee

Browse files
authored
fixes 1064: type: object and explode: false in query parameter is not working as expected (#1066)
1 parent d47f1f6 commit 7ff2fee

File tree

4 files changed

+152
-26
lines changed

4 files changed

+152
-26
lines changed

src/middlewares/openapi.request.validator.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Ajv, { ValidateFunction } from 'ajv';
2-
import { parse } from 'qs';
32
import { NextFunction, RequestHandler, Response } from 'express';
43
import { createRequestAjv } from '../framework/ajv';
54
import {
@@ -147,8 +146,6 @@ export class RequestValidator {
147146
writable: true,
148147
value: req.query,
149148
});
150-
// TODO should be in RequestParameterMutator?
151-
req.query = this.normalizeQueryFields(req.query);
152149
const schemaProperties = validator.allSchemaProperties;
153150
const mutator = new RequestParameterMutator(
154151
this.ajv,
@@ -291,28 +288,6 @@ export class RequestValidator {
291288
}
292289
}
293290
}
294-
295-
/**
296-
* Mutates and normalizes the req.query object by parsing braket notation query string key values pairs
297-
* into its corresponding key=<json-object> and update req.query with the parsed value
298-
* for instance, req.query that equals { filter[name]: test} is translated into { filter: { name: 'test' }, where
299-
* the query string field is set as filter and its value is the full javascript object (translated from bracket notation)
300-
* @param keys
301-
* @returns
302-
*/
303-
private normalizeQueryFields(query: { [key: string]: any }): {
304-
[key: string]: any;
305-
} {
306-
Object.keys(query).forEach((key) => {
307-
const bracketNotation = key.includes('[');
308-
if (bracketNotation) {
309-
const normalizedKey = key.split('[')[0];
310-
query[normalizedKey] = parse(`${key}=${query[key]}`)[normalizedKey];
311-
delete query[key];
312-
}
313-
});
314-
return query;
315-
}
316291
}
317292

318293
class Validator {

src/middlewares/parsers/req.parameter.mutator.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as url from 'url';
1111
import { dereferenceParameter, normalizeParameter } from './util';
1212
import * as mediaTypeParser from 'media-typer';
1313
import * as contentTypeParser from 'content-type';
14+
import { parse } from 'qs';
1415

1516
type SchemaObject = OpenAPIV3.SchemaObject;
1617
type ReferenceObject = OpenAPIV3.ReferenceObject;
@@ -67,6 +68,8 @@ export class RequestParameterMutator {
6768
url.parse(req.originalUrl).query,
6869
);
6970

71+
req.query = this.handleBracketNotationQueryFields(req.query);
72+
7073
(parameters || []).forEach((p) => {
7174
const parameter = dereferenceParameter(this._apiDocs, p);
7275
const { name, schema } = normalizeParameter(this.ajv, parameter);
@@ -96,14 +99,27 @@ export class RequestParameterMutator {
9699
if (parameter.content) {
97100
this.handleContent(req, name, parameter);
98101
} else if (parameter.in === 'query' && this.isObjectOrXOf(schema)) {
102+
// handle bracket notation and mutates query param
103+
104+
99105
if (style === 'form' && explode) {
100106
this.parseJsonAndMutateRequest(req, parameter.in, name);
101107
this.handleFormExplode(req, name, <SchemaObject>schema, parameter);
102108
} else if (style === 'deepObject') {
103109
this.handleDeepObject(req, queryString, name, schema);
110+
} else if (style === 'form' && !explode && schema.type === 'object') {
111+
const value = req.query[name];
112+
if (typeof value === 'string') {
113+
const kvPairs = this.csvToKeyValuePairs(value);
114+
if (kvPairs) {
115+
req.query[name] = kvPairs;
116+
return;
117+
}
118+
}
119+
this.parseJsonAndMutateRequest(req, parameter.in, name);
104120
} else {
105121
this.parseJsonAndMutateRequest(req, parameter.in, name);
106-
}
122+
}
107123
} else if (type === 'array' && !explode) {
108124
const delimiter = ARRAY_DELIMITER[parameter.style];
109125
this.validateArrayDelimiter(delimiter, parameter);
@@ -411,4 +427,52 @@ export class RequestParameterMutator {
411427
return m;
412428
}, new Map<string, string[]>());
413429
}
430+
431+
private csvToKeyValuePairs(csvString: string): Record<string, string> | undefined {
432+
const hasBrace = csvString.split('{').length > 1;
433+
const items = csvString.split(',');
434+
435+
if (hasBrace) {
436+
// if it has a brace, we assume its JSON and skip creating k v pairs
437+
// TODO improve json check, but ensure its cheap
438+
return;
439+
}
440+
441+
if (items.length % 2 !== 0) {
442+
// if the number of elements is not event,
443+
// then we do not have k v pairs, so return undefined
444+
return;
445+
}
446+
447+
const result = {};
448+
449+
for (let i = 0; i < items.length - 1; i += 2) {
450+
result[items[i]] = items[i + 1];
451+
}
452+
453+
return result;
454+
}
455+
456+
/**
457+
* Mutates and normalizes the req.query object by parsing braket notation query string key values pairs
458+
* into its corresponding key=<json-object> and update req.query with the parsed value
459+
* for instance, req.query that equals { filter[name]: test} is translated into { filter: { name: 'test' }, where
460+
* the query string field is set as filter and its value is the full javascript object (translated from bracket notation)
461+
* @param keys
462+
* @returns
463+
*/
464+
private handleBracketNotationQueryFields(query: { [key: string]: any }): {
465+
[key: string]: any;
466+
} {
467+
Object.keys(query).forEach((key) => {
468+
const bracketNotation = key.includes('[');
469+
if (bracketNotation) {
470+
const normalizedKey = key.split('[')[0];
471+
query[normalizedKey] = parse(`${key}=${query[key]}`)[normalizedKey];
472+
delete query[key];
473+
}
474+
});
475+
return query;
476+
}
477+
414478
}

test/query.object.explode.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect } from 'chai';
2+
import * as path from 'path';
3+
import * as request from 'supertest';
4+
import { createApp } from './common/app';
5+
import { AppWithServer } from './common/app.common';
6+
7+
describe('query object with explode:false', () => {
8+
let app: AppWithServer;
9+
10+
before(async () => {
11+
const apiSpec = path.join('test', 'resources', 'query.object.explode.yaml');
12+
app = await createApp(
13+
{
14+
apiSpec,
15+
validateRequests: true,
16+
validateResponses: false,
17+
},
18+
3005,
19+
(app) => {
20+
app.get(`${app.basePath}/users`, (req, res) => {
21+
res.json(req.query);
22+
});
23+
},
24+
);
25+
});
26+
27+
after(() => {
28+
app.server.close();
29+
});
30+
31+
it('should correctly parse query parameter with style:form and explode:false', async () => {
32+
return request(app)
33+
.get(`${app.basePath}/users`)
34+
.query('id=role,admin,firstName,Alex')
35+
.expect(200)
36+
.then((r) => {
37+
console.log(r.body);
38+
expect(r.body.id).to.deep.equal({
39+
role: 'admin',
40+
firstName: 'Alex',
41+
});
42+
});
43+
});
44+
45+
it('should correctly parse query parameter with style:form and explode:false using url encoded values', async () => {
46+
return request(app)
47+
.get(
48+
`${app.basePath}/users?id=%7B%22role%22%3A%22admin%22%2C%22firstName%22%3A%22Alex%22%7D`,
49+
)
50+
.expect(200)
51+
.then((r) => {
52+
console.log(r.body);
53+
expect(r.body.id).to.deep.equal({
54+
role: 'admin',
55+
firstName: 'Alex',
56+
});
57+
});
58+
});
59+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Query Object Explode Test
4+
version: '1'
5+
servers:
6+
- url: /v1
7+
paths:
8+
/users:
9+
get:
10+
parameters:
11+
- name: id
12+
in: query
13+
required: true
14+
style: form
15+
explode: false
16+
schema:
17+
type: object
18+
properties:
19+
role:
20+
type: string
21+
firstName:
22+
type: string
23+
required:
24+
- role
25+
- firstName
26+
responses:
27+
200:
28+
description: 'Success'

0 commit comments

Comments
 (0)