Skip to content

Commit 0f7fcda

Browse files
authored
feat: #467 support for URI path params (#491)
* feat: #467 support for URI path params * update README * update README * feat: wildcard routes not found
1 parent 3e6a26b commit 0f7fcda

File tree

5 files changed

+205
-9
lines changed

5 files changed

+205
-9
lines changed

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ Determines whether the validator should validate requests.
561561
responses:
562562
200:
563563
description: success
564-
```
564+
```
565565
566566
**coerceTypes:**
567567
@@ -774,10 +774,13 @@ Defines a regular expression or function that determines whether a path(s) shoul
774774

775775
The following ignores any path that ends in `/pets` e.g. `/v1/pets`.
776776
As a regular expression:
777+
777778
```
778779
ignorePaths: /.*\/pets$/
779780
```
781+
780782
or as a function:
783+
781784
```
782785
ignorePaths: (path) => path.endsWith('/pets')
783786
```
@@ -1048,6 +1051,31 @@ module.exports = app;
10481051

10491052
## FAQ
10501053

1054+
**Q:** How do I match paths, like those described in RFC-6570?
1055+
1056+
**A:** OpenAPI 3.0 does not support RFC-6570. That said, we provide a minimalistic mechanism that conforms syntactically to OpenAPI 3 and accomplishes a common use case. For example, matching file paths and storing the matched path in `req.params`
1057+
1058+
Using the following OpenAPI 3.x defintion
1059+
1060+
```yaml
1061+
/files/{path}*:
1062+
get:
1063+
parameters:
1064+
- name: path
1065+
in: path
1066+
required: true
1067+
schema:
1068+
type: string
1069+
```
1070+
1071+
With the following Express route defintion
1072+
1073+
```javascript
1074+
app.get(`/files/:path(*)`, (req, res) => { /* do stuff */ }`
1075+
```
1076+
1077+
A path like `/files/some/long/path` will pass validation. The Express `req.params.path` property will hold the value `some/long/path`.
1078+
10511079
**Q:** What happened to the `securityHandlers` property?
10521080

10531081
**A:** In v3, `securityHandlers` have been replaced by `validateSecurity.handlers`. To use v3 security handlers, move your existing security handlers to the new property. No other change is required. Note that the v2 `securityHandlers` property is supported in v3, but deprecated
@@ -1172,6 +1200,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
11721200

11731201
<!-- markdownlint-enable -->
11741202
<!-- prettier-ignore-end -->
1203+
11751204
<!-- ALL-CONTRIBUTORS-LIST:END -->
11761205

11771206
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

src/framework/openapi.spec.loader.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ export class OpenApiSpecLoader {
9898
}
9999

100100
private toExpressParams(part: string): string {
101-
return part.replace(/\{([^}]+)}/g, ':$1');
101+
// substitute wildcard path with express equivalent
102+
// {/path} => /path(*) <--- RFC 6570 format (not supported by openapi)
103+
// const pass1 = part.replace(/\{(\/)([^\*]+)(\*)}/g, '$1:$2$3');
104+
105+
// instead create our own syntax that is compatible with express' pathToRegex
106+
// /{path}* => /:path*)
107+
// /{path}(*) => /:path*)
108+
const pass1 = part.replace(/\/{([^\*]+)}\({0,1}(\*)\){0,1}/g, '/:$1$2');
109+
// substitute params with express equivalent
110+
// /path/{id} => /path/:id
111+
return pass1.replace(/\{([^}]+)}/g, ':$1');
102112
}
103113
}

src/middlewares/openapi.request.validator.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,22 @@ export class RequestValidator {
114114

115115
return (req: OpenApiRequest, res: Response, next: NextFunction): void => {
116116
const openapi = <OpenApiRequestMetadata>req.openapi;
117-
const hasPathParams = Object.keys(openapi.pathParams).length > 0;
117+
const pathParams = Object.keys(openapi.pathParams);
118+
const hasPathParams = pathParams.length > 0;
118119

119120
if (hasPathParams) {
121+
// handle wildcard path param syntax
122+
if (openapi.expressRoute.endsWith('*')) {
123+
// if we have an express route /data/:p*, we require a path param, p
124+
// if the p param is empty, the user called /p which is not found
125+
// if it was found, it would match a different route
126+
if (pathParams.filter((p) => openapi.pathParams[p]).length === 0) {
127+
throw new NotFound({
128+
path: req.path,
129+
message: 'not found',
130+
});
131+
}
132+
}
120133
req.params = openapi.pathParams ?? req.params;
121134
}
122135

@@ -189,13 +202,13 @@ export class RequestValidator {
189202
if (discriminator) {
190203
const { options, property, validators } = discriminator;
191204
const discriminatorValue = req.body[property]; // TODO may not alwasy be in this position
192-
if (options.find(o => o.option === discriminatorValue)) {
205+
if (options.find((o) => o.option === discriminatorValue)) {
193206
return validators[discriminatorValue];
194207
} else {
195208
throw new BadRequest({
196209
path: req.path,
197210
message: `'${property}' should be equal to one of the allowed values: ${options
198-
.map(o => o.option)
211+
.map((o) => o.option)
199212
.join(', ')}.`,
200213
});
201214
}
@@ -213,7 +226,7 @@ export class RequestValidator {
213226
keys.push(key);
214227
}
215228
const knownQueryParams = new Set(keys);
216-
whiteList.forEach(item => knownQueryParams.add(item));
229+
whiteList.forEach((item) => knownQueryParams.add(item));
217230
const queryParams = Object.keys(query);
218231
const allowedEmpty = schema.allowEmptyValue;
219232
for (const q of queryParams) {
@@ -324,12 +337,12 @@ class Security {
324337
): string[] {
325338
return usedSecuritySchema && securitySchema
326339
? usedSecuritySchema
327-
.filter(obj => Object.entries(obj).length !== 0)
328-
.map(sec => {
340+
.filter((obj) => Object.entries(obj).length !== 0)
341+
.map((sec) => {
329342
const securityKey = Object.keys(sec)[0];
330343
return <SecuritySchemeObject>securitySchema[securityKey];
331344
})
332-
.filter(sec => sec?.type === 'apiKey' && sec?.in == 'query')
345+
.filter((sec) => sec?.type === 'apiKey' && sec?.in == 'query')
333346
.map((sec: ApiKeySecurityScheme) => sec.name)
334347
: [];
335348
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
openapi: 3.0.1
2+
info:
3+
title: dummy api
4+
version: 1.0.0
5+
servers:
6+
- url: /v1
7+
8+
paths:
9+
/d1/{id}:
10+
get:
11+
parameters:
12+
- name: id
13+
in: path
14+
required: true
15+
schema:
16+
type: string
17+
responses:
18+
200:
19+
description: dummy response
20+
content: {}
21+
22+
/d2/{path}(*):
23+
get:
24+
parameters:
25+
- name: path
26+
in: path
27+
required: true
28+
schema:
29+
type: string
30+
responses:
31+
200:
32+
description: dummy response
33+
content: {}
34+
35+
/d3/{path}*:
36+
get:
37+
parameters:
38+
- name: path
39+
in: path
40+
required: true
41+
schema:
42+
type: string
43+
- name: qp
44+
in: query
45+
required: true
46+
schema:
47+
type: string
48+
responses:
49+
200:
50+
description: dummy response
51+
content: {}
52+
53+
/d3:
54+
get:
55+
responses:
56+
200:
57+
description: dummy response
58+
content: {}

test/wildcard.path.params.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as path from 'path';
2+
import { expect } from 'chai';
3+
import * as request from 'supertest';
4+
import { createApp } from './common/app';
5+
6+
describe('wildcard path params', () => {
7+
let app = null;
8+
9+
before(async () => {
10+
const apiSpec = path.join('test', 'resources', 'wildcard.path.params.yaml');
11+
app = await createApp(
12+
{
13+
apiSpec,
14+
},
15+
3001,
16+
(app) => {
17+
app
18+
.get(`${app.basePath}/d1/:id`, (req, res) =>
19+
res.json({
20+
...req.params,
21+
}),
22+
)
23+
.get(`${app.basePath}/d2/:path(*)`, (req, res) =>
24+
res.json({
25+
...req.params,
26+
}),
27+
)
28+
.get(`${app.basePath}/d3/:path(*)`, (req, res) =>
29+
res.json({
30+
...req.params,
31+
}),
32+
)
33+
.get(`${app.basePath}/d3`, (req, res) =>
34+
res.json({
35+
success: true,
36+
}),
37+
);
38+
},
39+
);
40+
});
41+
42+
after(() => app.server.close());
43+
44+
it('should allow path param without wildcard', async () =>
45+
request(app)
46+
.get(`${app.basePath}/d1/my-id`)
47+
.expect(200)
48+
.then((r) => {
49+
expect(r.body.id).to.equal('my-id');
50+
}));
51+
52+
it('should allow path param with slashes "/" using wildcard', async () =>
53+
request(app)
54+
.get(`${app.basePath}/d2/some/long/path`)
55+
.expect(200)
56+
.then((r) => {
57+
expect(r.body.path).to.equal('some/long/path');
58+
}));
59+
60+
it('should return not found if no path is specified', async () =>
61+
request(app).get(`${app.basePath}/d2`).expect(404));
62+
63+
it('should return 200 when wildcard path includes all required params', async () =>
64+
request(app)
65+
.get(`${app.basePath}/d3/long/path/file.csv`)
66+
.query({
67+
qp: 'present',
68+
})
69+
.expect(200));
70+
71+
it('should 400 when wildcard path is missing a required query param', async () =>
72+
request(app)
73+
.get(`${app.basePath}/d3/long/path/file.csv`)
74+
.expect(400)
75+
.then((r) => {
76+
expect(r.body.message).to.include('required');
77+
}));
78+
79+
it('should return 200 if root of an existing wildcard route is defined', async () =>
80+
request(app)
81+
.get(`${app.basePath}/d3`)
82+
.expect(200)
83+
.then((r) => {
84+
expect(r.body.success).to.be.true;
85+
}));
86+
});

0 commit comments

Comments
 (0)