Skip to content

Commit 12cd39a

Browse files
committed
feat(spectral): add rule enforcing Problem Details error responses for 400,401,403,404,500
1 parent b2784a1 commit 12cd39a

File tree

3 files changed

+174
-0
lines changed

3 files changed

+174
-0
lines changed

example/example.1.0.0.oas.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ paths:
3333
application/json:
3434
schema:
3535
$ref: '#/components/schemas/ApiInfo'
36+
'400':
37+
$ref: '#/components/responses/BadRequest'
38+
'401':
39+
$ref: '#/components/responses/Unauthorized'
40+
'403':
41+
$ref: '#/components/responses/Forbidden'
42+
'404':
43+
$ref: '#/components/responses/NotFound'
44+
'500':
45+
$ref: '#/components/responses/UnexpectedError'
3646
default:
3747
$ref: '#/components/responses/UnexpectedError'
3848

@@ -78,6 +88,16 @@ paths:
7888
application/json:
7989
schema:
8090
$ref: '#/components/schemas/ResultsResponse'
91+
'400':
92+
$ref: '#/components/responses/BadRequest'
93+
'401':
94+
$ref: '#/components/responses/Unauthorized'
95+
'403':
96+
$ref: '#/components/responses/Forbidden'
97+
'404':
98+
$ref: '#/components/responses/NotFound'
99+
'500':
100+
$ref: '#/components/responses/UnexpectedError'
81101
default:
82102
$ref: '#/components/responses/UnexpectedError'
83103
post:
@@ -120,6 +140,14 @@ paths:
120140
$ref: '#/components/schemas/ResultResponse'
121141
'400':
122142
$ref: '#/components/responses/BadRequest'
143+
'401':
144+
$ref: '#/components/responses/Unauthorized'
145+
'403':
146+
$ref: '#/components/responses/Forbidden'
147+
'404':
148+
$ref: '#/components/responses/NotFound'
149+
'500':
150+
$ref: '#/components/responses/UnexpectedError'
123151
default:
124152
$ref: '#/components/responses/UnexpectedError'
125153
/results/{resultId}:
@@ -145,8 +173,16 @@ paths:
145173
schema:
146174
$ref: '#/components/schemas/ResultResponse'
147175
description: This response returns a JSON object containing the test result data.
176+
'400':
177+
$ref: '#/components/responses/BadRequest'
178+
'401':
179+
$ref: '#/components/responses/Unauthorized'
180+
'403':
181+
$ref: '#/components/responses/Forbidden'
148182
'404':
149183
$ref: '#/components/responses/NotFound'
184+
'500':
185+
$ref: '#/components/responses/UnexpectedError'
150186
default:
151187
$ref: '#/components/responses/UnexpectedError'
152188

@@ -448,6 +484,24 @@ components:
448484
$ref: '#/components/examples/validation-error'
449485
validation-errors:
450486
$ref: '#/components/examples/validation-errors'
487+
Unauthorized:
488+
description: The request is not authenticated.
489+
content:
490+
application/problem+json:
491+
schema:
492+
$ref: '#/components/schemas/ProblemDetails'
493+
examples:
494+
unauthorized:
495+
$ref: '#/components/examples/unauthorized'
496+
Forbidden:
497+
description: The request is authenticated but the user is not authorized.
498+
content:
499+
application/problem+json:
500+
schema:
501+
$ref: '#/components/schemas/ProblemDetails'
502+
examples:
503+
forbidden:
504+
$ref: '#/components/examples/forbidden'
451505
NotFound:
452506
description: The requested resource was not found.
453507
content:
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict';
2+
3+
/*
4+
Common Error Response Requirements (RFC 9457):
5+
6+
Every operation MUST include:
7+
- 400 Bad Request
8+
- 404 Not Found
9+
- 500 Internal Server Error
10+
→ MUST use `application/problem+json`
11+
→ MUST include examples
12+
13+
If the API or operation is secured, then also required:
14+
- 401 Unauthorized
15+
- 403 Forbidden
16+
17+
If an operation explicitly disables security (`security: []`) or the API has no global security:
18+
- 401 and 403 are NOT required
19+
20+
All responses must conform to the Problem Details standard (RFC 9457).
21+
*/
22+
23+
const REQUIRED_ALWAYS = ['400', '404', '500'];
24+
const REQUIRED_IF_SECURED = ['401', '403'];
25+
26+
/**
27+
* Checks if a response defines the application/problem+json content type.
28+
*/
29+
function hasProblemJsonContent(response) {
30+
return Boolean(response?.content?.['application/problem+json']);
31+
}
32+
33+
/**
34+
* Checks if a response includes at least one example.
35+
*/
36+
function hasExamples(response) {
37+
const content = response?.content?.['application/problem+json'];
38+
return Boolean(content?.examples && Object.keys(content.examples).length);
39+
}
40+
41+
/**
42+
* Determines if security is enabled for the operation or globally.
43+
*/
44+
function isSecurityEnabled(operationSecurity, globalSecurity) {
45+
if (Array.isArray(operationSecurity)) return operationSecurity.length > 0;
46+
if (Array.isArray(globalSecurity)) return globalSecurity.length > 0;
47+
return false;
48+
}
49+
50+
/**
51+
* Validates a single response for presence, media type, and examples.
52+
*/
53+
function validateResponseRequirement(responses, statusCode, requireExample) {
54+
const issues = [];
55+
const response = responses?.[statusCode];
56+
if (!response) {
57+
issues.push('missing response');
58+
return { statusCode, issues };
59+
}
60+
if (!hasProblemJsonContent(response)) {
61+
issues.push('missing application/problem+json');
62+
}
63+
if (requireExample && !hasExamples(response)) {
64+
issues.push('missing example');
65+
}
66+
return issues.length ? { statusCode, issues } : null;
67+
}
68+
69+
/**
70+
* Spectral custom function to validate common error responses on operations.
71+
*/
72+
export default function validateCommonErrorResponses(targetVal, _opts, context) {
73+
const operation = targetVal;
74+
const responses = operation.responses || {};
75+
const globalSecurity = context.document?.data?.security;
76+
const operationSecurity = operation.security;
77+
const securityEnabled = isSecurityEnabled(operationSecurity, globalSecurity);
78+
79+
const requiredStatusCodes = [
80+
...REQUIRED_ALWAYS,
81+
...(securityEnabled ? REQUIRED_IF_SECURED : [])
82+
];
83+
84+
const issues = requiredStatusCodes
85+
.map(code => validateResponseRequirement(responses, code, true))
86+
.filter(Boolean);
87+
88+
if (issues.length > 0) {
89+
const details = issues
90+
.map(item => `${item.statusCode} (${item.issues.join(', ')})`)
91+
.join('; ');
92+
93+
return [{
94+
message: `Each operation must define Problem Details for: ${requiredStatusCodes.join(', ')}. Issues: ${details}.`,
95+
path: [...context.path, 'responses'],
96+
}];
97+
}
98+
99+
return [];
100+
}

ukhsa.oas.rules.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ documentationUrl: https://refactored-chainsaw-8qmo7ge.pages.github.io/
77

88
functions:
99
- is-api-info-json-schema
10+
- has-required-problem-details-error-responses
1011

1112
functionsDir: functions
1213

@@ -264,3 +265,22 @@ rules:
264265
recommended: true
265266
description: '`201 Created` responses to `POST` methods SHOULD have a `Location` header identifying the location of the newly created resource.'
266267
message: '{{error}}'
268+
269+
# MUST each operation include Problem Details error responses
270+
271+
must-define-common-problem-details-errors:
272+
description: |
273+
Each operation MUST declare HTTP error responses for:
274+
• 400 Bad Request
275+
• 404 Not Found
276+
• 500 Internal Server Error
277+
If security is required (global or operation-level), also:
278+
• 401 Unauthorized
279+
• 403 Forbidden
280+
All responses must use `application/problem+json` and include examples.
281+
message: '{{error}}'
282+
severity: error
283+
recommended: true
284+
given: $.paths[*][*]
285+
then:
286+
function: has-required-problem-details-error-responses

0 commit comments

Comments
 (0)