Skip to content

Commit 875ea4c

Browse files
author
Mike Kistler
authored
feat: Add rule to check pagination list operations conform to API Handbook (#118)
* feat: pagination support * fix: Refactor & fix pagination support and tests * Address PR review comments * Add check for collection property in paginated list response
1 parent a90f3fc commit 875ea4c

File tree

5 files changed

+1507
-5
lines changed

5 files changed

+1507
-5
lines changed

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ The supported categories are described below:
179179
| schemas | Rules pertaining to [Schema Objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject) |
180180
| security_definitions | Rules pertaining to [Security Definition Objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#securityDefinitionsObject) |
181181
| security | Rules pertaining to [Security Objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#securityRequirementObject) |
182-
| walker | Rules pertaining to the entire document. |
183-
182+
| walker | Rules pertaining to the entire document. |
183+
| pagination | Rules pertaining to pagination |
184184
#### Rules
185185

186186
Each category contains a group of rules. The spec that each rule applies to is marked in the third column. For the actual configuration structure, see the [default values](#default-values).
@@ -199,7 +199,13 @@ The supported rules are described below:
199199
| no_array_responses | Flag any operations with a top-level array response. | shared |
200200
| parameter_order | Flag any operations with optional parameters before a required param. | shared |
201201
| no_request_body_content | [Flag any operations with a `requestBody` that does not have a `content` field.][3] | oas3 |
202-
| no_request_body_name | Flag any operations with a non-form `requestBody` that does not have a name set with `x-codegen-request-body-name`. | oas3 |
202+
| no_request_body_name | Flag any operations with a non-form `requestBody` that does not have a name set with `x-codegen-request-body-name`. | oas3|
203+
204+
##### pagination
205+
| Rule | Description | Spec |
206+
| --------------------------- | ------------------------------------------------------------------------ | ------ |
207+
| pagination_style | Flag any parameter or response schema that does not follow pagination requirements. | oas3 |
208+
203209

204210
##### parameters
205211
| Rule | Description | Spec |
@@ -362,6 +368,11 @@ The default values for each rule are described below.
362368
| no_array_responses | error |
363369
| parameter_order | warning |
364370

371+
###### pagination
372+
| Rule | Default |
373+
| --------------------------- | --------|
374+
| pagination_style | warning |
375+
365376
###### parameters
366377
| Rule | Default |
367378
| --------------------------- | --------|

src/.defaultsForValidator.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const defaults = {
2828
'parameter_order': 'warning',
2929
'unused_tag': 'warning'
3030
},
31+
'pagination': {
32+
'pagination_style': 'warning'
33+
},
3134
'parameters': {
3235
'no_parameter_description': 'error',
3336
'param_name_case_convention': ['error', 'lower_snake_case'],
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// This validator checks "list" type operations for correct pagaination style.
2+
//
3+
// An operation is considered to be a "list" operation if:
4+
// - its path does not end in a path parameter
5+
// - it is a "get"
6+
// - it contains an array at the top level of the response body object
7+
//
8+
// A "list" operation is considered to be a "paginated list" operation if:
9+
// - it has a `limit` query parameter
10+
//
11+
// The following checks are performed on "paginated list" operations:
12+
// - The `limit` query parameter be type integer, optional, and have default and maximum values.
13+
// - If the operation has an `offset` query parameter, it must be type integer and optional
14+
// - If the operation has a `start`, `cursor`, or `page_token` query parameter, it must be type string and optional
15+
// - The response body must contain a `limit` property that is type integer and required
16+
// - If the operation has an `offset` query parameter, the response body must contain an `offset` property this is type integer and required
17+
// - The response body must contain an array property with the same plural resource name appearing in the collection’s URL.
18+
19+
module.exports.validate = function({ resolvedSpec }, config) {
20+
const result = {};
21+
result.error = [];
22+
result.warning = [];
23+
const checkStatus = config.pagination.pagination_style;
24+
25+
//when pagnation is turned off, skip all of the pagination checks
26+
if (checkStatus == 'off') {
27+
return { errors: [], warnings: [] };
28+
}
29+
30+
// Loop through all paths looking for "list" operations.
31+
for (const path in resolvedSpec.paths) {
32+
// For now, just consider the get operation.
33+
const operation = resolvedSpec.paths[path].get;
34+
35+
// Skip any path that ends in a path parameter or does not have a "get" operation
36+
if (/}$/.test(path) || !operation) {
37+
continue;
38+
}
39+
40+
// Find first success response code
41+
const resp = Object.keys(operation.responses || {}).find(code =>
42+
code.startsWith('2')
43+
);
44+
// Now get the json content of that response
45+
const content = resp && operation.responses[resp].content;
46+
const jsonResponse = content && content['application/json'];
47+
48+
// Can't check response schema for array property, so skip this path
49+
if (
50+
!jsonResponse ||
51+
!jsonResponse.schema ||
52+
!jsonResponse.schema.properties
53+
) {
54+
continue;
55+
}
56+
57+
// If no array at top level of response, skip this path
58+
if (
59+
!Object.values(jsonResponse.schema.properties).some(
60+
prop => prop.type === 'array'
61+
)
62+
) {
63+
continue;
64+
}
65+
66+
// Check for "limit" query param -- if none, skip this path
67+
const params = operation.parameters;
68+
if (
69+
!params ||
70+
!params.some(param => param.name === 'limit' && param.in === 'query')
71+
) {
72+
continue;
73+
}
74+
75+
// Now we know we have a "paginated list" operation, so lets perform our checks
76+
77+
// - The `limit` query parameter be type integer, optional, and have default and maximum values.
78+
79+
const limitParamIndex = params.findIndex(
80+
param => param.name === 'limit' && param.in === 'query'
81+
);
82+
const limitParam = params[limitParamIndex];
83+
if (
84+
!limitParam.schema ||
85+
limitParam.schema.type !== 'integer' ||
86+
!!limitParam.required ||
87+
!limitParam.schema.default ||
88+
!limitParam.schema.maximum
89+
) {
90+
result[checkStatus].push({
91+
path: ['paths', path, 'get', 'parameters', limitParamIndex],
92+
message:
93+
'The limit parameter must be of type integer and optional with default and maximum values.'
94+
});
95+
}
96+
97+
// - If the operation has an `offset` query parameter, it must be type integer and optional
98+
99+
const offsetParamIndex = params.findIndex(
100+
param => param.name === 'offset' && param.in === 'query'
101+
);
102+
if (offsetParamIndex !== -1) {
103+
const offsetParam = params[offsetParamIndex];
104+
if (
105+
!offsetParam.schema ||
106+
offsetParam.schema.type !== 'integer' ||
107+
!!offsetParam.required
108+
) {
109+
result[checkStatus].push({
110+
path: ['paths', path, 'get', 'parameters', offsetParamIndex],
111+
message: 'The offset parameter must be of type integer and optional.'
112+
});
113+
}
114+
}
115+
116+
// - if the operation has a `start`, `cursor`, or `page_token` query parameter, it must be type string and optional
117+
118+
const startParamNames = ['start', 'cursor', 'page_token'];
119+
const startParamIndex = params.findIndex(
120+
param =>
121+
param.in === 'query' && startParamNames.indexOf(param.name) !== -1
122+
);
123+
if (startParamIndex !== -1) {
124+
const startParam = params[startParamIndex];
125+
if (
126+
!startParam.schema ||
127+
startParam.schema.type !== 'string' ||
128+
!!startParam.required
129+
) {
130+
result[checkStatus].push({
131+
path: ['paths', path, 'get', 'parameters', startParamIndex],
132+
message: `The ${
133+
startParam.name
134+
} parameter must be of type string and optional.`
135+
});
136+
}
137+
}
138+
139+
// - The response body must contain a `limit` property that is type integer and required
140+
141+
const propertiesPath = [
142+
'paths',
143+
path,
144+
'get',
145+
'responses',
146+
resp,
147+
'content',
148+
'application/json',
149+
'schema',
150+
'properties'
151+
];
152+
153+
const limitProp = jsonResponse.schema.properties.limit;
154+
if (!limitProp) {
155+
result[checkStatus].push({
156+
path: propertiesPath,
157+
message: `A paginated list operation must include a "limit" property in the response body schema.`
158+
});
159+
} else if (
160+
limitProp.type !== 'integer' ||
161+
!jsonResponse.schema.required ||
162+
jsonResponse.schema.required.indexOf('limit') === -1
163+
) {
164+
result[checkStatus].push({
165+
path: [...propertiesPath, 'limit'],
166+
message: `The "limit" property in the response body of a paginated list operation must be of type integer and required.`
167+
});
168+
}
169+
170+
// - If the operation has an `offset` query parameter, the response body must contain an `offset` property this is type integer and required
171+
172+
if (offsetParamIndex !== -1) {
173+
const offsetProp = jsonResponse.schema.properties.offset;
174+
if (!offsetProp) {
175+
result[checkStatus].push({
176+
path: propertiesPath,
177+
message: `A paginated list operation with an "offset" parameter must include an "offset" property in the response body schema.`
178+
});
179+
} else if (
180+
offsetProp.type !== 'integer' ||
181+
!jsonResponse.schema.required ||
182+
jsonResponse.schema.required.indexOf('offset') === -1
183+
) {
184+
result[checkStatus].push({
185+
path: [...propertiesPath, 'offset'],
186+
message: `The "offset" property in the response body of a paginated list operation must be of type integer and required.`
187+
});
188+
}
189+
}
190+
191+
// - The response body must contain an array property with the same plural resource name appearing in the collection’s URL.
192+
193+
const pluralResourceName = path.split('/').pop();
194+
const resourcesProp = jsonResponse.schema.properties[pluralResourceName];
195+
if (!resourcesProp || resourcesProp.type !== 'array') {
196+
result[checkStatus].push({
197+
path: propertiesPath,
198+
message: `A paginated list operation must include an array property whose name matches the final segment of the path.`
199+
});
200+
}
201+
}
202+
203+
return { errors: result.error, warnings: result.warning };
204+
};

test/cli-validator/mockFiles/oas3/clean.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ paths:
2121
schema:
2222
type: integer
2323
format: int32
24+
default: 10
25+
maximum: 100
26+
- name: offset
27+
in: query
28+
description: Offset of first element to return in results.
29+
required: false
30+
schema:
31+
type: integer
32+
format: int32
2433
responses:
2534
'200':
2635
description: An paged array of pets
@@ -102,13 +111,31 @@ components:
102111
description:
103112
A list of pets
104113
required:
105-
- list
114+
- pets
115+
- next_url
116+
- limit
117+
- offset
106118
properties:
107-
list:
119+
pets:
108120
type: array
109121
description: "object containing a list of pets"
110122
items:
111123
$ref: "#/components/schemas/Pet"
124+
next_url:
125+
type: string
126+
description: this is the url to next page
127+
limit:
128+
type: integer
129+
format: int32
130+
description: limit
131+
offset:
132+
type: integer
133+
format: int32
134+
description: offset
135+
next_token:
136+
type: string
137+
description: next token
138+
112139
Error:
113140
description:
114141
An error in processing a service request

0 commit comments

Comments
 (0)