Skip to content

Commit 49bb5a9

Browse files
authored
Merge pull request #376 from LarryHu0217/codex/x-correlator-doc-371
feat(validation): check x-correlator documentation
2 parents dc48802 + 2154161 commit 49bb5a9

5 files changed

Lines changed: 346 additions & 1 deletion

File tree

linting/config/.spectral-r4.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# array items description, notification content-type
1717
# - 14.06.2026: Added camara-integer-safe-range rule (S-038)
1818
# - 08.07.2026: Reworded S-038 description and message to the OpenAPI Format Registry framing
19+
# - 15.07.2026: Added x-correlator documentation rules (S-039, S-040)
1920

2021

2122
# Note: @stoplight/spectral-owasp-ruleset is installed via validation/package.json.
@@ -37,6 +38,7 @@ functions:
3738
- camara-notification-content-type
3839
- camara-no-numeric-resource-ids
3940
- camara-integer-safe-range
41+
- camara-x-correlator-documentation
4042
functionsDir: "./lint_function"
4143
rules:
4244
# Built-in OpenAPI Specification ruleset. Each rule then can be enabled individually.
@@ -552,6 +554,28 @@ rules:
552554
function: camara-integer-safe-range
553555
recommended: true
554556

557+
camara-x-correlator-request-parameter:
558+
description: "Every operation MUST document the x-correlator request header parameter."
559+
message: "{{error}}"
560+
severity: warn
561+
given: "$"
562+
then:
563+
function: camara-x-correlator-documentation
564+
functionOptions:
565+
target: request
566+
recommended: true
567+
568+
camara-x-correlator-response-header:
569+
description: "Every documented response MUST document the x-correlator response header."
570+
message: "{{error}}"
571+
severity: warn
572+
given: "$"
573+
then:
574+
function: camara-x-correlator-documentation
575+
functionOptions:
576+
target: response
577+
recommended: true
578+
555579
# ===== OWASP API Security Top 10 2023 =====
556580
# Source: Commonalities Linting-rules.md section 5
557581
# Severity overrides per CAMARA agreement (Commonalities #539, #548, #551, #552)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// CAMARA Project - support function for Spectral linter
2+
// Checks x-correlator documentation on regular and callback operations.
3+
4+
const OPERATION_METHODS = ["get", "put", "post", "delete", "patch", "options"];
5+
6+
function isObject(value) {
7+
return value !== null && typeof value === "object";
8+
}
9+
10+
function hasXCorrelatorParameter(parameters) {
11+
return Array.isArray(parameters) && parameters.some((parameter) => (
12+
isObject(parameter) &&
13+
String(parameter.in).toLowerCase() === "header" &&
14+
String(parameter.name).toLowerCase() === "x-correlator"
15+
));
16+
}
17+
18+
function hasXCorrelatorHeader(response) {
19+
return isObject(response?.headers) && Object.keys(response.headers).some(
20+
(name) => name.toLowerCase() === "x-correlator"
21+
);
22+
}
23+
24+
export default (document, options, context) => {
25+
const target = options?.target;
26+
if (target !== "request" && target !== "response") return [];
27+
28+
const findings = [];
29+
30+
function checkOperation(operation, inheritedParameters, path, ancestors) {
31+
if (!isObject(operation)) return;
32+
33+
if (target === "request") {
34+
const effectiveParameters = [
35+
...(Array.isArray(inheritedParameters) ? inheritedParameters : []),
36+
...(Array.isArray(operation.parameters) ? operation.parameters : []),
37+
];
38+
if (!hasXCorrelatorParameter(effectiveParameters)) {
39+
findings.push({
40+
message: "Operation must document the 'x-correlator' request header parameter",
41+
path: [...path, "parameters"],
42+
});
43+
}
44+
} else if (isObject(operation.responses)) {
45+
for (const [status, response] of Object.entries(operation.responses)) {
46+
if (!hasXCorrelatorHeader(response)) {
47+
findings.push({
48+
message: `Response '${status}' must document the 'x-correlator' response header`,
49+
path: [...path, "responses", status, "headers"],
50+
});
51+
}
52+
}
53+
}
54+
55+
if (!isObject(operation.callbacks)) return;
56+
for (const [callbackName, callback] of Object.entries(operation.callbacks)) {
57+
if (!isObject(callback)) continue;
58+
for (const [expression, pathItem] of Object.entries(callback)) {
59+
walkPathItem(
60+
pathItem,
61+
[...path, "callbacks", callbackName, expression],
62+
ancestors
63+
);
64+
}
65+
}
66+
}
67+
68+
function walkPathItem(pathItem, path, ancestors) {
69+
if (!isObject(pathItem) || ancestors.has(pathItem)) return;
70+
71+
const nextAncestors = new Set(ancestors);
72+
nextAncestors.add(pathItem);
73+
const inheritedParameters = pathItem.parameters;
74+
75+
for (const method of OPERATION_METHODS) {
76+
if (isObject(pathItem[method])) {
77+
checkOperation(
78+
pathItem[method],
79+
inheritedParameters,
80+
[...path, method],
81+
nextAncestors
82+
);
83+
}
84+
}
85+
}
86+
87+
if (isObject(document?.paths)) {
88+
for (const [pathName, pathItem] of Object.entries(document.paths)) {
89+
walkPathItem(pathItem, [...context.path, "paths", pathName], new Set());
90+
}
91+
}
92+
93+
return findings;
94+
};

validation/rules/spectral-rules.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,16 @@
219219
engine_rule: camara-integer-safe-range
220220
short_title: "Integer value exceeds safe range"
221221

222+
- id: S-039
223+
engine: spectral
224+
engine_rule: camara-x-correlator-request-parameter
225+
short_title: "x-correlator request header is undocumented"
226+
227+
- id: S-040
228+
engine: spectral
229+
engine_rule: camara-x-correlator-response-header
230+
short_title: "x-correlator response header is undocumented"
231+
222232
# ===== Built-in OAS rules (S-200+) =====
223233

224234
- id: S-200

validation/tests/test_rule_metadata_integrity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_expected_rule_counts(self, all_rules):
8989
for r in all_rules:
9090
counts[r.engine] = counts.get(r.engine, 0) + 1
9191
assert counts["python"] == 36
92-
assert counts["spectral"] == 86
92+
assert counts["spectral"] == 88
9393
assert counts["gherkin"] == 25
9494
assert counts["yamllint"] == 13
9595

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Regression tests for x-correlator documentation rules S-039 and S-040."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import subprocess
8+
import tempfile
9+
from pathlib import Path
10+
11+
12+
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
13+
_RULESET = _REPO_ROOT / "linting" / "config" / ".spectral-r4.yaml"
14+
_NODE_MODULES = _REPO_ROOT / "validation" / "node_modules"
15+
_REQUEST_RULE = "camara-x-correlator-request-parameter"
16+
_RESPONSE_RULE = "camara-x-correlator-response-header"
17+
18+
19+
def _run_spectral(files: dict[str, str], entrypoint: str = "api.yaml") -> list[dict]:
20+
with tempfile.TemporaryDirectory() as directory:
21+
root = Path(directory)
22+
for name, content in files.items():
23+
target = root / name
24+
target.parent.mkdir(parents=True, exist_ok=True)
25+
target.write_text(content, encoding="utf-8")
26+
27+
env = {
28+
"PATH": os.environ.get("PATH", ""),
29+
"NODE_PATH": str(_NODE_MODULES),
30+
"HOME": os.environ.get("HOME", ""),
31+
}
32+
result = subprocess.run(
33+
[
34+
"node",
35+
str(_NODE_MODULES / ".bin" / "spectral"),
36+
"lint",
37+
str(root / entrypoint),
38+
"-r",
39+
str(_RULESET),
40+
"--format",
41+
"json",
42+
],
43+
capture_output=True,
44+
text=True,
45+
env=env,
46+
timeout=30,
47+
)
48+
assert result.stdout.strip(), result.stderr
49+
return json.loads(result.stdout)
50+
51+
52+
def _for_rule(findings: list[dict], rule: str) -> list[dict]:
53+
return [finding for finding in findings if finding["code"] == rule]
54+
55+
56+
_SPEC = """\
57+
openapi: 3.0.3
58+
info:
59+
title: Correlator Test
60+
version: wip
61+
paths:
62+
/widgets:
63+
get:
64+
operationId: getWidgets
65+
parameters:
66+
- name: x-correlator
67+
in: header
68+
schema:
69+
type: string
70+
responses:
71+
"200":
72+
description: OK
73+
headers:
74+
x-correlator:
75+
schema:
76+
type: string
77+
"""
78+
79+
80+
def test_documented_request_and_response_pass():
81+
findings = _run_spectral({"api.yaml": _SPEC})
82+
assert _for_rule(findings, _REQUEST_RULE) == []
83+
assert _for_rule(findings, _RESPONSE_RULE) == []
84+
85+
86+
def test_path_level_request_parameter_applies_to_operation():
87+
spec = _SPEC.replace(
88+
" get:\n operationId: getWidgets\n parameters:\n",
89+
" parameters:\n - name: x-correlator\n in: header\n schema:\n type: string\n get:\n operationId: getWidgets\n parameters:\n",
90+
).replace(
91+
" - name: x-correlator\n in: header\n schema:\n type: string\n",
92+
" - name: limit\n in: query\n schema:\n type: integer\n",
93+
)
94+
findings = _run_spectral({"api.yaml": spec})
95+
assert _for_rule(findings, _REQUEST_RULE) == []
96+
97+
98+
def test_missing_request_parameter_reports_operation_path():
99+
spec = _SPEC.replace(" - name: x-correlator", " - name: traceparent")
100+
findings = _for_rule(_run_spectral({"api.yaml": spec}), _REQUEST_RULE)
101+
assert len(findings) == 1
102+
assert findings[0]["path"] == ["paths", "/widgets", "get", "parameters"]
103+
104+
105+
def test_each_response_without_header_is_reported():
106+
spec = _SPEC.replace(
107+
" headers:\n x-correlator:\n schema:\n type: string\n",
108+
"",
109+
) + """\
110+
"400":
111+
description: Bad request
112+
headers: {}
113+
"""
114+
findings = _for_rule(_run_spectral({"api.yaml": spec}), _RESPONSE_RULE)
115+
assert len(findings) == 2
116+
assert any("200" in finding["path"] for finding in findings)
117+
assert any("400" in finding["path"] for finding in findings)
118+
119+
120+
def test_component_references_are_resolved():
121+
spec = """\
122+
openapi: 3.0.3
123+
info:
124+
title: Correlator Test
125+
version: wip
126+
paths:
127+
/widgets:
128+
get:
129+
operationId: getWidgets
130+
parameters:
131+
- $ref: "#/components/parameters/XCorrelator"
132+
responses:
133+
"200":
134+
$ref: "#/components/responses/CorrelatedResponse"
135+
components:
136+
parameters:
137+
XCorrelator:
138+
name: x-correlator
139+
in: header
140+
schema:
141+
type: string
142+
responses:
143+
CorrelatedResponse:
144+
description: OK
145+
headers:
146+
x-correlator:
147+
schema:
148+
type: string
149+
"""
150+
findings = _run_spectral({"api.yaml": spec})
151+
assert _for_rule(findings, _REQUEST_RULE) == []
152+
assert _for_rule(findings, _RESPONSE_RULE) == []
153+
154+
155+
def test_external_references_are_resolved():
156+
spec = """\
157+
openapi: 3.0.3
158+
info:
159+
title: Correlator Test
160+
version: wip
161+
paths:
162+
/widgets:
163+
get:
164+
operationId: getWidgets
165+
parameters:
166+
- $ref: "./common.yaml#/components/parameters/XCorrelator"
167+
responses:
168+
"200":
169+
$ref: "./common.yaml#/components/responses/CorrelatedResponse"
170+
"""
171+
common = """\
172+
openapi: 3.0.3
173+
info:
174+
title: Common
175+
version: wip
176+
paths: {}
177+
components:
178+
parameters:
179+
XCorrelator:
180+
name: x-correlator
181+
in: header
182+
schema:
183+
type: string
184+
responses:
185+
CorrelatedResponse:
186+
description: OK
187+
headers:
188+
x-correlator:
189+
schema:
190+
type: string
191+
"""
192+
findings = _run_spectral({"api.yaml": spec, "common.yaml": common})
193+
assert _for_rule(findings, _REQUEST_RULE) == []
194+
assert _for_rule(findings, _RESPONSE_RULE) == []
195+
196+
197+
def test_callback_operation_is_in_scope():
198+
spec = _SPEC.replace(
199+
" responses:\n",
200+
" callbacks:\n"
201+
" onEvent:\n"
202+
" '{$request.body#/sink}':\n"
203+
" post:\n"
204+
" operationId: onEvent\n"
205+
" responses:\n"
206+
" '204':\n"
207+
" description: Accepted\n"
208+
" responses:\n",
209+
1,
210+
)
211+
findings = _run_spectral({"api.yaml": spec})
212+
request_findings = _for_rule(findings, _REQUEST_RULE)
213+
response_findings = _for_rule(findings, _RESPONSE_RULE)
214+
assert len(request_findings) == 1
215+
assert "callbacks" in request_findings[0]["path"]
216+
assert len(response_findings) == 1
217+
assert "callbacks" in response_findings[0]["path"]

0 commit comments

Comments
 (0)