@aep_dev/aep-openapi-linter 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/aep/0004.yaml +38 -0
- package/aep/0122.yaml +110 -0
- package/aep/0131.yaml +78 -0
- package/aep/0132.yaml +115 -0
- package/aep/0133.yaml +165 -0
- package/aep/0134.yaml +124 -0
- package/aep/0135.yaml +49 -0
- package/aep/0136.yaml +53 -0
- package/aep/0137.yaml +115 -0
- package/aep/0140.yaml +17 -0
- package/aep/0142.yaml +37 -0
- package/aep/0143.yaml +31 -0
- package/aep/0144.yaml +39 -0
- package/aep/0151.yaml +94 -0
- package/aep/0158.yaml +175 -0
- package/aep/0193.yaml +54 -0
- package/functions/aep-142-time-field-type.js +107 -0
- package/functions/operations-endpoint.js +40 -0
- package/functions/parameter-names-unique.js +78 -0
- package/functions/singleton-utils.js +87 -0
- package/functions/skipSingletonsSchema.js +35 -0
- package/functions/standardized-codes.js +50 -0
- package/package.json +60 -0
- package/spectral.yaml +50 -0
package/aep/0158.yaml
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
aliases:
|
|
2
|
+
ListOperation:
|
|
3
|
+
description: A list operation is a get on path that does not end in a path parameter
|
|
4
|
+
targets:
|
|
5
|
+
- formats: ['oas2', 'oas3']
|
|
6
|
+
given:
|
|
7
|
+
# first condition excludes custom methods and second condition excludes paths ending in a path parameter
|
|
8
|
+
- $.paths[?(!@property.match(/:[^/]*$/) && !@property.match(/\}$/))].get
|
|
9
|
+
|
|
10
|
+
functionsDir: ../functions
|
|
11
|
+
functions:
|
|
12
|
+
- skipSingletonsSchema
|
|
13
|
+
|
|
14
|
+
rules:
|
|
15
|
+
aep-158-max-page-size-parameter:
|
|
16
|
+
description: Operations that return collections should define an integer max_page_size parameter.
|
|
17
|
+
severity: warn
|
|
18
|
+
formats: ['oas3']
|
|
19
|
+
given:
|
|
20
|
+
- '#ListOperation'
|
|
21
|
+
then:
|
|
22
|
+
function: skipSingletonsSchema
|
|
23
|
+
functionOptions:
|
|
24
|
+
schema:
|
|
25
|
+
type: object
|
|
26
|
+
required: ['parameters']
|
|
27
|
+
properties:
|
|
28
|
+
parameters:
|
|
29
|
+
type: array
|
|
30
|
+
contains:
|
|
31
|
+
type: object
|
|
32
|
+
required: ['name', 'schema']
|
|
33
|
+
properties:
|
|
34
|
+
name:
|
|
35
|
+
enum: ['max_page_size']
|
|
36
|
+
schema:
|
|
37
|
+
type: object
|
|
38
|
+
required: ['type']
|
|
39
|
+
properties:
|
|
40
|
+
type:
|
|
41
|
+
enum: ['integer']
|
|
42
|
+
|
|
43
|
+
aep-158-page-token-parameter:
|
|
44
|
+
description: Operations that return collections should define a string page_token parameter.
|
|
45
|
+
severity: warn
|
|
46
|
+
formats: ['oas3']
|
|
47
|
+
given:
|
|
48
|
+
- '#ListOperation'
|
|
49
|
+
then:
|
|
50
|
+
function: skipSingletonsSchema
|
|
51
|
+
functionOptions:
|
|
52
|
+
schema:
|
|
53
|
+
type: object
|
|
54
|
+
required: ['parameters']
|
|
55
|
+
properties:
|
|
56
|
+
parameters:
|
|
57
|
+
type: array
|
|
58
|
+
contains:
|
|
59
|
+
type: object
|
|
60
|
+
required: ['name', 'schema']
|
|
61
|
+
properties:
|
|
62
|
+
name:
|
|
63
|
+
enum: ['page_token']
|
|
64
|
+
schema:
|
|
65
|
+
type: object
|
|
66
|
+
required: ['type']
|
|
67
|
+
properties:
|
|
68
|
+
type:
|
|
69
|
+
enum: ['string']
|
|
70
|
+
|
|
71
|
+
aep-158-page-token-parameter-optional:
|
|
72
|
+
description: The page_token parameter must not be required.
|
|
73
|
+
severity: error
|
|
74
|
+
formats: ['oas3']
|
|
75
|
+
given:
|
|
76
|
+
- '#ListOperation.parameters[?(@.name == "page_token")]'
|
|
77
|
+
then:
|
|
78
|
+
function: schema
|
|
79
|
+
functionOptions:
|
|
80
|
+
schema:
|
|
81
|
+
type: object
|
|
82
|
+
properties:
|
|
83
|
+
required:
|
|
84
|
+
not:
|
|
85
|
+
enum: [true]
|
|
86
|
+
|
|
87
|
+
aep-158-response-array-property:
|
|
88
|
+
description: The response schema must include an array property.
|
|
89
|
+
severity: error
|
|
90
|
+
formats: ['oas3']
|
|
91
|
+
given:
|
|
92
|
+
- '#ListOperation.responses.200.content.application/json.schema'
|
|
93
|
+
then:
|
|
94
|
+
function: schema
|
|
95
|
+
functionOptions:
|
|
96
|
+
schema:
|
|
97
|
+
type: object
|
|
98
|
+
anyOf:
|
|
99
|
+
- required: ['properties']
|
|
100
|
+
properties:
|
|
101
|
+
properties:
|
|
102
|
+
# You can use this pattern to get an object contains constraint
|
|
103
|
+
type: object
|
|
104
|
+
not:
|
|
105
|
+
additionalProperties:
|
|
106
|
+
not:
|
|
107
|
+
type: object
|
|
108
|
+
required: ['type']
|
|
109
|
+
properties:
|
|
110
|
+
type:
|
|
111
|
+
enum: ['array']
|
|
112
|
+
- # Ignore for singleton resources
|
|
113
|
+
required: ['x-aep-resource']
|
|
114
|
+
properties:
|
|
115
|
+
'x-aep-resource':
|
|
116
|
+
type: object
|
|
117
|
+
required: ['singleton']
|
|
118
|
+
properties:
|
|
119
|
+
'singleton':
|
|
120
|
+
enum: [true]
|
|
121
|
+
|
|
122
|
+
aep-158-response-next-page-token-property:
|
|
123
|
+
description: The response schema must include a string next_page_token property.
|
|
124
|
+
severity: error
|
|
125
|
+
formats: ['oas3']
|
|
126
|
+
given:
|
|
127
|
+
- '#ListOperation.responses.200.content.application/json.schema'
|
|
128
|
+
then:
|
|
129
|
+
function: schema
|
|
130
|
+
functionOptions:
|
|
131
|
+
schema:
|
|
132
|
+
type: object
|
|
133
|
+
anyOf:
|
|
134
|
+
- required: ['properties']
|
|
135
|
+
properties:
|
|
136
|
+
properties:
|
|
137
|
+
type: object
|
|
138
|
+
required: ['next_page_token']
|
|
139
|
+
properties:
|
|
140
|
+
next_page_token:
|
|
141
|
+
type: object
|
|
142
|
+
required: ['type']
|
|
143
|
+
properties:
|
|
144
|
+
type:
|
|
145
|
+
enum: ['string']
|
|
146
|
+
- # Ignore for singleton resources
|
|
147
|
+
required: ['x-aep-resource']
|
|
148
|
+
properties:
|
|
149
|
+
'x-aep-resource':
|
|
150
|
+
type: object
|
|
151
|
+
required: ['singleton']
|
|
152
|
+
properties:
|
|
153
|
+
'singleton':
|
|
154
|
+
enum: [true]
|
|
155
|
+
|
|
156
|
+
aep-158-skip-parameter:
|
|
157
|
+
description: Operations that return collections may define an integer skip parameter.
|
|
158
|
+
message: The skip parameter must be an integer.
|
|
159
|
+
severity: warn
|
|
160
|
+
formats: ['oas3']
|
|
161
|
+
given:
|
|
162
|
+
- '#ListOperation.parameters[?(@.name == "skip")]'
|
|
163
|
+
then:
|
|
164
|
+
function: schema
|
|
165
|
+
functionOptions:
|
|
166
|
+
schema:
|
|
167
|
+
type: object
|
|
168
|
+
required: ['schema']
|
|
169
|
+
properties:
|
|
170
|
+
schema:
|
|
171
|
+
type: object
|
|
172
|
+
required: ['type']
|
|
173
|
+
properties:
|
|
174
|
+
type:
|
|
175
|
+
enum: ['integer']
|
package/aep/0193.yaml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
aep-193-error-response-schema:
|
|
3
|
+
description: Error response body should contain all required fields according to AEP-193.
|
|
4
|
+
message: '{{error}}'
|
|
5
|
+
severity: warn
|
|
6
|
+
formats: ['oas2', 'oas3']
|
|
7
|
+
given: $.paths[*][*].responses[?(@property >= 400)].content[*].schema.properties
|
|
8
|
+
then:
|
|
9
|
+
function: schema
|
|
10
|
+
functionOptions:
|
|
11
|
+
schema:
|
|
12
|
+
type: object
|
|
13
|
+
required: ['type']
|
|
14
|
+
properties:
|
|
15
|
+
type:
|
|
16
|
+
type: object
|
|
17
|
+
required: ['type', 'format']
|
|
18
|
+
properties:
|
|
19
|
+
type:
|
|
20
|
+
enum: ['string']
|
|
21
|
+
format:
|
|
22
|
+
enum: ['uri-reference']
|
|
23
|
+
title:
|
|
24
|
+
type: object
|
|
25
|
+
required: ['type']
|
|
26
|
+
properties:
|
|
27
|
+
type:
|
|
28
|
+
enum: ['string']
|
|
29
|
+
status:
|
|
30
|
+
type: object
|
|
31
|
+
required: ['type']
|
|
32
|
+
properties:
|
|
33
|
+
type:
|
|
34
|
+
enum: ['integer']
|
|
35
|
+
minimum:
|
|
36
|
+
type: integer
|
|
37
|
+
minimum: 100
|
|
38
|
+
maximum:
|
|
39
|
+
type: integer
|
|
40
|
+
maximum: 599
|
|
41
|
+
detail:
|
|
42
|
+
type: object
|
|
43
|
+
required: ['type']
|
|
44
|
+
properties:
|
|
45
|
+
type:
|
|
46
|
+
enum: ['string']
|
|
47
|
+
instance:
|
|
48
|
+
type: object
|
|
49
|
+
required: ['type', 'format']
|
|
50
|
+
properties:
|
|
51
|
+
type:
|
|
52
|
+
enum: ['string']
|
|
53
|
+
format:
|
|
54
|
+
enum: ['uri-reference']
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that fields with time-related suffixes use the correct OpenAPI types.
|
|
3
|
+
*
|
|
4
|
+
* Based on AEP-142 specification (https://aep.dev/142).
|
|
5
|
+
*
|
|
6
|
+
* AEP-documented suffixes and their expected types:
|
|
7
|
+
* - _time, _times → string with format: date-time (RFC 3339)
|
|
8
|
+
* - _date → string with format: date
|
|
9
|
+
* - _seconds → integer or number
|
|
10
|
+
* - _millis → integer or number
|
|
11
|
+
* - _micros → integer or number
|
|
12
|
+
* - _nanos → integer or number
|
|
13
|
+
*
|
|
14
|
+
* @param {object} field - The field object being validated
|
|
15
|
+
* @param {object} _opts - Options (unused)
|
|
16
|
+
* @param {object} context - Spectral context containing the path
|
|
17
|
+
* @returns {Array<object>} Array of error objects, or empty array if valid
|
|
18
|
+
*/
|
|
19
|
+
module.exports = (field, _opts, context) => {
|
|
20
|
+
if (!field || typeof field !== 'object') {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract the field name from the path - it should be the last element
|
|
25
|
+
// path looks like:
|
|
26
|
+
// ['paths', '/test', 'get', 'responses', '200', 'content',
|
|
27
|
+
// 'application/json', 'schema', 'properties', 'create_time']
|
|
28
|
+
const path = context.path || [];
|
|
29
|
+
const fieldName = path[path.length - 1];
|
|
30
|
+
|
|
31
|
+
if (typeof fieldName !== 'string' || !fieldName.includes('_')) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Extract the suffix (last word after underscore)
|
|
36
|
+
const parts = fieldName.split('_');
|
|
37
|
+
const suffix = parts[parts.length - 1];
|
|
38
|
+
|
|
39
|
+
// Define suffix categories and their expected types (AEP-142 documented only)
|
|
40
|
+
const timestampSuffixes = ['time', 'times'];
|
|
41
|
+
const dateSuffixes = ['date'];
|
|
42
|
+
const durationSecondSuffixes = ['seconds'];
|
|
43
|
+
const durationMilliSuffixes = ['millis'];
|
|
44
|
+
const durationMicroSuffixes = ['micros'];
|
|
45
|
+
const durationNanoSuffixes = ['nanos'];
|
|
46
|
+
|
|
47
|
+
const allSuffixes = [
|
|
48
|
+
...timestampSuffixes,
|
|
49
|
+
...dateSuffixes,
|
|
50
|
+
...durationSecondSuffixes,
|
|
51
|
+
...durationMilliSuffixes,
|
|
52
|
+
...durationMicroSuffixes,
|
|
53
|
+
...durationNanoSuffixes,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Check if this field has a time-related suffix
|
|
57
|
+
if (!allSuffixes.includes(suffix)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const errors = [];
|
|
62
|
+
|
|
63
|
+
// Validate timestamp fields (_time, _times)
|
|
64
|
+
if (timestampSuffixes.includes(suffix)) {
|
|
65
|
+
// For _times (plural), allow arrays of timestamps
|
|
66
|
+
if (suffix === 'times' && field.type === 'array') {
|
|
67
|
+
// Check that array items are strings with date-time format
|
|
68
|
+
if (!field.items || field.items.type !== 'string' || field.items.format !== 'date-time') {
|
|
69
|
+
errors.push({
|
|
70
|
+
message:
|
|
71
|
+
`Field "${fieldName}" should be an array with items of type "string" ` +
|
|
72
|
+
`and format "date-time" (RFC 3339 timestamp).`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} else if (field.type !== 'string' || field.format !== 'date-time') {
|
|
76
|
+
errors.push({
|
|
77
|
+
message: `Field "${fieldName}" should have type "string" and format "date-time" (RFC 3339 timestamp).`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Validate date fields (_date)
|
|
83
|
+
else if (dateSuffixes.includes(suffix)) {
|
|
84
|
+
if (field.type !== 'string' || field.format !== 'date') {
|
|
85
|
+
errors.push({
|
|
86
|
+
message: `Field "${fieldName}" should have type "string" and format "date" (RFC 3339 date).`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validate duration fields (seconds, millis, micros, nanos)
|
|
92
|
+
else if (
|
|
93
|
+
durationSecondSuffixes.includes(suffix) ||
|
|
94
|
+
durationMilliSuffixes.includes(suffix) ||
|
|
95
|
+
durationMicroSuffixes.includes(suffix) ||
|
|
96
|
+
durationNanoSuffixes.includes(suffix)
|
|
97
|
+
) {
|
|
98
|
+
// Duration fields should be integers (or numbers for fractional values)
|
|
99
|
+
if (field.type !== 'integer' && field.type !== 'number') {
|
|
100
|
+
errors.push({
|
|
101
|
+
message: `Field "${fieldName}" should have type "integer" or "number" for duration values.`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return errors;
|
|
107
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Check that services with long-running operations (202 responses) have the required operations endpoints
|
|
2
|
+
|
|
3
|
+
module.exports = (paths, _opts, context) => {
|
|
4
|
+
if (!paths || typeof paths !== 'object') {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const errors = [];
|
|
9
|
+
const pathKeys = Object.keys(paths);
|
|
10
|
+
|
|
11
|
+
// Check if any path has a 202 response
|
|
12
|
+
const hasLongRunningOperation = pathKeys.some((pathKey) => {
|
|
13
|
+
const pathItem = paths[pathKey];
|
|
14
|
+
if (!pathItem || typeof pathItem !== 'object') return false;
|
|
15
|
+
|
|
16
|
+
// Check all HTTP methods that can have 202 responses
|
|
17
|
+
const methods = ['post', 'put', 'patch', 'delete'];
|
|
18
|
+
return methods.some(
|
|
19
|
+
(method) => pathItem[method] && pathItem[method].responses && pathItem[method].responses['202']
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// If no long-running operations, no need to check for operations endpoints
|
|
24
|
+
if (!hasLongRunningOperation) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for required operations endpoints
|
|
29
|
+
const hasOperationsList = paths['/v1/operations'] && paths['/v1/operations'].get;
|
|
30
|
+
const hasOperationsGet = paths['/v1/operations/{operation}'] && paths['/v1/operations/{operation}'].get;
|
|
31
|
+
|
|
32
|
+
if (!hasOperationsList || !hasOperationsGet) {
|
|
33
|
+
errors.push({
|
|
34
|
+
message: 'Services with long-running operations must define an operations endpoint with list and get operations',
|
|
35
|
+
path: context.path || ['paths'],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return errors;
|
|
40
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Check that the parameters of an operation -- including those specified on the path -- are
|
|
2
|
+
// are case-insensitive unique regardless of "in".
|
|
3
|
+
|
|
4
|
+
// Return the "canonical" casing for a string.
|
|
5
|
+
// Currently just lowercase but should be extended to convert kebab/camel/snake/Pascal.
|
|
6
|
+
function canonical(name) {
|
|
7
|
+
return typeof name === 'string' ? name.toLowerCase() : name;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Accept an array and return a list of unique duplicate entries in canonical form.
|
|
11
|
+
// This function is intended to work on strings but is resilient to non-strings.
|
|
12
|
+
function dupIgnoreCase(arr) {
|
|
13
|
+
if (!Array.isArray(arr)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isDup = (value, index, self) => self.indexOf(value) !== index;
|
|
18
|
+
|
|
19
|
+
return [...new Set(arr.map((v) => canonical(v)).filter(isDup))];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// targetVal should be a
|
|
23
|
+
// [path item object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#pathItemObject).
|
|
24
|
+
// The code assumes it is running on a resolved doc
|
|
25
|
+
module.exports = (pathItem, _opts, paths) => {
|
|
26
|
+
if (pathItem === null || typeof pathItem !== 'object') {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const path = paths.path || paths.target || [];
|
|
30
|
+
|
|
31
|
+
const errors = [];
|
|
32
|
+
|
|
33
|
+
const pathParams = pathItem.parameters ? pathItem.parameters.map((p) => p.name) : [];
|
|
34
|
+
|
|
35
|
+
// Check path params for dups
|
|
36
|
+
const pathDups = dupIgnoreCase(pathParams);
|
|
37
|
+
|
|
38
|
+
// Report all dups
|
|
39
|
+
pathDups.forEach((dup) => {
|
|
40
|
+
// get the index of all names that match dup
|
|
41
|
+
const dupKeys = [...pathParams.keys()].filter((k) => canonical(pathParams[k]) === dup);
|
|
42
|
+
// Report errors for all the others
|
|
43
|
+
dupKeys.slice(1).forEach((key) => {
|
|
44
|
+
errors.push({
|
|
45
|
+
message: `Duplicate parameter name (ignoring case): ${dup}.`,
|
|
46
|
+
path: [...path, 'parameters', key, 'name'],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].forEach((method) => {
|
|
52
|
+
// If this method exists and it has parameters, check them
|
|
53
|
+
if (pathItem[method] && Array.isArray(pathItem[method].parameters)) {
|
|
54
|
+
const allParams = [...pathParams, ...pathItem[method].parameters.map((p) => p.name)];
|
|
55
|
+
|
|
56
|
+
// Check method params for dups -- including path params
|
|
57
|
+
const dups = dupIgnoreCase(allParams);
|
|
58
|
+
|
|
59
|
+
// Report all dups
|
|
60
|
+
dups.forEach((dup) => {
|
|
61
|
+
// get the index of all names that match dup
|
|
62
|
+
const dupKeys = [...allParams.keys()].filter((k) => canonical(allParams[k]) === dup);
|
|
63
|
+
// Report errors for any others that are method parameters
|
|
64
|
+
dupKeys
|
|
65
|
+
.slice(1)
|
|
66
|
+
.filter((k) => k >= pathParams.length)
|
|
67
|
+
.forEach((key) => {
|
|
68
|
+
errors.push({
|
|
69
|
+
message: `Duplicate parameter name (ignoring case): ${dup}.`,
|
|
70
|
+
path: [...path, method, 'parameters', key - pathParams.length, 'name'],
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return errors;
|
|
78
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared utility functions for singleton resource validation
|
|
2
|
+
// according to AEP-156 specifications
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a path or pattern: ensure leading slash, no duplicate slashes
|
|
6
|
+
* @param {string} str
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function normalizePath(str) {
|
|
10
|
+
if (!str) return '';
|
|
11
|
+
// Remove leading/trailing whitespace, ensure leading slash, remove duplicate slashes
|
|
12
|
+
let s = str.trim();
|
|
13
|
+
if (!s.startsWith('/')) s = `/${s}`;
|
|
14
|
+
s = s.replace(/\/+/g, '/');
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get all singleton resource patterns from the OAS document
|
|
20
|
+
* @param {Object} oasDoc - The OpenAPI document
|
|
21
|
+
* @returns {Array} Array of singleton patterns
|
|
22
|
+
*/
|
|
23
|
+
function getSingletonPatterns(oasDoc) {
|
|
24
|
+
if (!oasDoc || !oasDoc.components || !oasDoc.components.schemas) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const singletonPatterns = [];
|
|
28
|
+
const { schemas } = oasDoc.components;
|
|
29
|
+
Object.values(schemas).forEach((schema) => {
|
|
30
|
+
if (
|
|
31
|
+
schema &&
|
|
32
|
+
schema['x-aep-resource'] &&
|
|
33
|
+
schema['x-aep-resource'].singleton === true &&
|
|
34
|
+
Array.isArray(schema['x-aep-resource'].patterns)
|
|
35
|
+
) {
|
|
36
|
+
singletonPatterns.push(...schema['x-aep-resource'].patterns);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return singletonPatterns;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a path string matches any singleton pattern exactly
|
|
44
|
+
* @param {string} pathString - The path to check
|
|
45
|
+
* @param {Object} oasDoc - The OpenAPI document
|
|
46
|
+
* @returns {boolean} True if the path matches a singleton pattern
|
|
47
|
+
*/
|
|
48
|
+
function pathMatchesSingletonPattern(pathString, oasDoc) {
|
|
49
|
+
if (!pathString) return false;
|
|
50
|
+
const normalizedPath = normalizePath(pathString);
|
|
51
|
+
const singletonPatterns = getSingletonPatterns(oasDoc);
|
|
52
|
+
return singletonPatterns.some((pattern) => {
|
|
53
|
+
const normalizedPattern = normalizePath(pattern);
|
|
54
|
+
const regexPattern = `^${normalizedPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\{[^/]+\}/g, '[^/]+')}$`;
|
|
55
|
+
const regex = new RegExp(regexPattern);
|
|
56
|
+
return regex.test(normalizedPath);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a path string matches a singleton list pattern
|
|
62
|
+
* @param {string} pathString - The path to check
|
|
63
|
+
* @param {Object} oasDoc - The OpenAPI document
|
|
64
|
+
* @returns {boolean} True if the path matches a singleton list pattern
|
|
65
|
+
*/
|
|
66
|
+
function pathMatchesSingletonListPattern(pathString, oasDoc) {
|
|
67
|
+
if (!pathString) return false;
|
|
68
|
+
const normalizedPath = normalizePath(pathString);
|
|
69
|
+
const singletonPatterns = getSingletonPatterns(oasDoc);
|
|
70
|
+
return singletonPatterns.some((pattern) => {
|
|
71
|
+
// Convert singleton pattern to list pattern: {parent}/{parent-id}/{singleton} -> {parent}/-/configs
|
|
72
|
+
const listPattern = pattern.replace(/\/[^/]+$/, '/-/configs');
|
|
73
|
+
const normalizedListPattern = normalizePath(listPattern);
|
|
74
|
+
const regexPattern = `^${normalizedListPattern
|
|
75
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
76
|
+
.replace(/\{[^/]+\}/g, '[^/]+')}$`;
|
|
77
|
+
const regex = new RegExp(regexPattern);
|
|
78
|
+
return regex.test(normalizedPath);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
getSingletonPatterns,
|
|
84
|
+
pathMatchesSingletonPattern,
|
|
85
|
+
pathMatchesSingletonListPattern,
|
|
86
|
+
normalizePath,
|
|
87
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// This function first checks if the target operation is for a singleton resource
|
|
2
|
+
// by looking for the `x-aep-resource` extension with `singleton: true`.
|
|
3
|
+
// If it is a singleton, it does not report any errors.
|
|
4
|
+
// Otherwise, it performs validation of the target using the `schema` specified in the function parameters.
|
|
5
|
+
|
|
6
|
+
// Spectral allows custom functions to invoke core functions.
|
|
7
|
+
// The documentation for this feature can be found at:
|
|
8
|
+
// https://docs.stoplight.io/docs/spectral/a781e290eb9f9-custom-functions#referencing-core-functions
|
|
9
|
+
// This function uses the `schema` function from Spectral to validate the operation object.
|
|
10
|
+
const { schema: schemaFunction } = require('@stoplight/spectral-functions');
|
|
11
|
+
|
|
12
|
+
// targetVal should be an operation object.
|
|
13
|
+
// The code assumes it is running on a resolved doc
|
|
14
|
+
module.exports = (op, opts, context) => {
|
|
15
|
+
if (op === null || typeof op !== 'object') {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// opts must contain a schema to validate the operation against
|
|
20
|
+
if (opts === null || typeof opts !== 'object' || !opts.schema) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check if the operation is for a singleton resource
|
|
25
|
+
const responseSchema = op.responses?.['200']?.content?.['application/json']?.schema;
|
|
26
|
+
if (responseSchema?.['x-aep-resource']?.singleton) {
|
|
27
|
+
return []; // No errors for singleton resources
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The operation is not for a singleton resource, apply the schema to the operation
|
|
31
|
+
// and return any errors found
|
|
32
|
+
const schemaErrors = schemaFunction(op, opts, context);
|
|
33
|
+
|
|
34
|
+
return schemaErrors;
|
|
35
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Validates that standardized code field names follow AEP-143 conventions.
|
|
2
|
+
// Checks schema property names and suggests correct standardized field names.
|
|
3
|
+
|
|
4
|
+
// Map of incorrect field name variants to their correct standardized names
|
|
5
|
+
const fieldNameVariants = {
|
|
6
|
+
// Content/Media types
|
|
7
|
+
mime: 'content_type',
|
|
8
|
+
mimetype: 'content_type',
|
|
9
|
+
mime_type: 'content_type',
|
|
10
|
+
media_type: 'content_type',
|
|
11
|
+
mediatype: 'content_type',
|
|
12
|
+
// Countries/Regions
|
|
13
|
+
country: 'region_code',
|
|
14
|
+
country_code: 'region_code',
|
|
15
|
+
region: 'region_code',
|
|
16
|
+
// Currency
|
|
17
|
+
currency: 'currency_code',
|
|
18
|
+
// Language
|
|
19
|
+
lang: 'language_code',
|
|
20
|
+
language: 'language_code',
|
|
21
|
+
locale: 'language_code',
|
|
22
|
+
// Time zones
|
|
23
|
+
tz: 'time_zone',
|
|
24
|
+
timezone: 'time_zone',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// targetVal is a schema object containing properties
|
|
28
|
+
module.exports = (schema, _opts, paths) => {
|
|
29
|
+
if (!schema || typeof schema !== 'object') {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const errors = [];
|
|
34
|
+
const path = paths.path || paths.target || [];
|
|
35
|
+
|
|
36
|
+
// Check if this is a schema with properties
|
|
37
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
38
|
+
Object.keys(schema.properties).forEach((propertyName) => {
|
|
39
|
+
const correctName = fieldNameVariants[propertyName];
|
|
40
|
+
if (correctName) {
|
|
41
|
+
errors.push({
|
|
42
|
+
message: `Use "${correctName}" instead of "${propertyName}" for standardized code fields.`,
|
|
43
|
+
path: [...path, 'properties', propertyName],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return errors;
|
|
50
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aep_dev/aep-openapi-linter",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Linter for OpenAPI definitions to check compliance to AEPs",
|
|
5
|
+
"main": "spectral.yaml",
|
|
6
|
+
"files": [
|
|
7
|
+
"aep",
|
|
8
|
+
"functions",
|
|
9
|
+
"spectral.yaml"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"lint": "prettier --check . && eslint --cache --quiet --ext '.js' functions test",
|
|
16
|
+
"lint-fix": "prettier --write . && eslint --cache --quiet --ext '.js' --fix functions test",
|
|
17
|
+
"test": "jest --coverage",
|
|
18
|
+
"release:patch": "./scripts/prepare-release.sh patch",
|
|
19
|
+
"release:minor": "./scripts/prepare-release.sh minor",
|
|
20
|
+
"release:major": "./scripts/prepare-release.sh major"
|
|
21
|
+
},
|
|
22
|
+
"author": "Mike Kistler",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"url": "https://github.com/aep-dev/aep-openapi-linter"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@jest/globals": "^29.7.0",
|
|
29
|
+
"@stoplight/spectral-core": "^1.20.0",
|
|
30
|
+
"@stoplight/spectral-parsers": "^1.0.5",
|
|
31
|
+
"@stoplight/spectral-ruleset-migrator": "^1.11.1",
|
|
32
|
+
"@stoplight/spectral-rulesets": "^1.21.3",
|
|
33
|
+
"ajv": "^8.6.2",
|
|
34
|
+
"eslint": "^8.57.0",
|
|
35
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
36
|
+
"eslint-config-prettier": "^9.1.0",
|
|
37
|
+
"eslint-plugin-import": "^2.23.4",
|
|
38
|
+
"jest": "^29.7.0",
|
|
39
|
+
"prettier": "^3.2.5"
|
|
40
|
+
},
|
|
41
|
+
"jest": {
|
|
42
|
+
"collectCoverage": true,
|
|
43
|
+
"collectCoverageFrom": [
|
|
44
|
+
"functions/*.js"
|
|
45
|
+
],
|
|
46
|
+
"coverageThreshold": {
|
|
47
|
+
"./functions/*.js": {
|
|
48
|
+
"statements": 80
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"moduleNameMapper": {
|
|
52
|
+
"^nimma/legacy$": "<rootDir>/node_modules/nimma/dist/legacy/cjs/index.js",
|
|
53
|
+
"^nimma/(.*)": "<rootDir>/node_modules/nimma/dist/cjs/$1",
|
|
54
|
+
"^@stoplight/spectral-ruleset-bundler/(.*)$": "<rootDir>/node_modules/@stoplight/spectral-ruleset-bundler/dist/$1"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@stoplight/spectral-functions": "^1.10.1"
|
|
59
|
+
}
|
|
60
|
+
}
|