Skip to content

Commit 76de03e

Browse files
authored
Add Spectral rules to validate that interfaces are defined on the specific node referenced (169) (#743)
* 169 detect missing interfaces on node for architectures * 169 add equivalent rules for patterns * Lint and remove unused lint annotations * Fix tests and add link back to dev deps * Try rollup fix * Add install for rollup needed by spectral CLI * Fix package.json and gh action * Update package-lock
1 parent c424cf6 commit 76de03e

File tree

11 files changed

+276
-120
lines changed

11 files changed

+276
-120
lines changed

.github/workflows/validate-spectral.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ jobs:
3737
- name: Build workspace
3838
run: npm run build
3939

40-
- name: Install Spectral-CLI
41-
run: npm install @stoplight/spectral-cli
40+
- name: Install dependencies for Spectral
41+
run: npm install @stoplight/spectral-cli rollup
4242

4343
- name: Run Example Spectral Linting
4444
run: npx spectral lint --ruleset ./shared/dist/spectral/rules-architecture.js 'calm/samples/api-gateway-architecture(*.json|*.yaml)'

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"eslint": "^9.13.0",
4848
"globals": "^15.12.0",
4949
"jest": "^29.7.0",
50+
"link": "^2.1.1",
5051
"ts-jest": "^29.2.5",
5152
"ts-node": "10.9.2",
5253
"tsup": "^8.0.0",

cli/test_fixtures/validate_output_junit.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<testsuites tests="23" failures="0" errors="0" skipped="0">
2+
<testsuites tests="25" failures="0" errors="0" skipped="0">
33
<testsuite name="JSON Schema Validation" tests="1" failures="0"
44
errors="0" skipped="0">
55
<testcase name="JSON Schema Validation succeeded" />
66
</testsuite>
7-
<testsuite name="Spectral Suite" tests="22"
7+
<testsuite name="Spectral Suite" tests="24"
88
failures="0" errors="0" skipped="0">
99
<testcase name="architecture-has-nodes-relationships" />
1010
<testcase name="architecture-has-no-empty-properties" />
@@ -16,6 +16,7 @@
1616
<testcase
1717
name="connects-relationship-references-existing-nodes-in-architecture" />
1818
<testcase name="referenced-interfaces-defined-in-architecture" />
19+
<testcase name="referenced-interfaces-defined-on-correct-node-in-architecture" />
1920
<testcase name="composition-relationships-reference-existing-nodes-in-architecture" />
2021
<testcase name="architecture-nodes-must-be-referenced" />
2122
<testcase
@@ -31,6 +32,7 @@
3132
<testcase
3233
name="relationship-references-existing-nodes-in-pattern" />
3334
<testcase name="referenced-interfaces-defined-in-pattern" />
35+
<testcase name="referenced-interfaces-defined-on-correct-node-in-pattern" />
3436
<testcase name="pattern-nodes-must-be-referenced" />
3537
<testcase name="unique-ids-must-be-unique-in-pattern" />
3638
</testsuite>

package-lock.json

Lines changed: 138 additions & 113 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"link:cli": "npm link --workspace cli"
2121
},
2222
"devDependencies": {
23+
"link": "^2.1.1",
2324
"npm-run-all2": "^5.0.0"
2425
}
2526
}

shared/src/commands/generate/components/instantiate.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
1+
22

33
import { SchemaDirectory } from '../schema-directory';
44
import { instantiateGenericObject } from './instantiate';

shared/src/commands/generate/components/property.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
1+
22

33
import { getConstValue, getEnumPlaceholder, getPropertyValue } from './property';
44

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { JSONPath } from 'jsonpath-plus';
2+
import { difference } from 'lodash';
3+
4+
/**
5+
* Checks that the input value exists as an interface with matching unique ID defined under a node in the document.
6+
*/
7+
export function interfaceIdExistsOnNode(input, _, context) {
8+
if (!input || !input.interfaces) {
9+
return [];
10+
}
11+
12+
if (!input.node) {
13+
return [{
14+
message: 'Invalid connects relationship - no node defined.',
15+
path: [...context.path]
16+
}];
17+
}
18+
19+
const nodeId = input.node;
20+
const nodeMatch: object[] = JSONPath({ path: `$.nodes[?(@['unique-id'] == '${nodeId}')]`, json: context.document.data });
21+
if (!nodeMatch || nodeMatch.length === 0) {
22+
// other rule will report undefined node
23+
return [];
24+
}
25+
26+
// all of these must be present on the referenced node
27+
const desiredInterfaces = input.interfaces;
28+
29+
const node = nodeMatch[0];
30+
31+
const nodeInterfaces = JSONPath({ path: '$.interfaces[*].unique-id', json: node });
32+
if (!nodeInterfaces || nodeInterfaces.length === 0) {
33+
return [
34+
{ message: `Node with unique-id ${nodeId} has no interfaces defined, expected interfaces [${desiredInterfaces}].` }
35+
];
36+
}
37+
38+
const missingInterfaces = difference(desiredInterfaces, nodeInterfaces);
39+
40+
// difference always returns an array
41+
if (missingInterfaces.length === 0) {
42+
return [];
43+
}
44+
const results = [];
45+
46+
for (const missing of missingInterfaces) {
47+
results.push({
48+
message: `Referenced interface with ID '${missing}' was not defined on the node with ID '${nodeId}'.`,
49+
path: [...context.path]
50+
});
51+
}
52+
return results;
53+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { JSONPath } from 'jsonpath-plus';
2+
import { difference } from 'lodash';
3+
4+
/**
5+
* Checks that the input value exists as an interface with matching unique ID defined under a node in the document.
6+
*/
7+
export function interfaceIdExistsOnNode(input, _, context) {
8+
if (!input || !input.interfaces) {
9+
return [];
10+
}
11+
12+
if (!input.node) {
13+
return [{
14+
message: 'Invalid connects relationship - no node defined.',
15+
path: [...context.path]
16+
}];
17+
}
18+
19+
const nodeId = input.node;
20+
const nodeMatch: object[] = JSONPath({ path: `$.properties.nodes.prefixItems[?(@.properties['unique-id'].const == '${nodeId}')]`, json: context.document.data });
21+
if (!nodeMatch || nodeMatch.length === 0) {
22+
// other rule will report undefined node
23+
return [];
24+
}
25+
26+
// all of these must be present on the referenced node
27+
const desiredInterfaces = input.interfaces;
28+
29+
const node = nodeMatch[0];
30+
31+
const nodeInterfaces = JSONPath({ path: '$.properties.interfaces.prefixItems[*].properties.unique-id.const', json: node });
32+
if (!nodeInterfaces || nodeInterfaces.length === 0) {
33+
return [
34+
{ message: `Node with unique-id ${nodeId} has no interfaces defined, expected interfaces [${desiredInterfaces}]` }
35+
];
36+
}
37+
38+
const missingInterfaces = difference(desiredInterfaces, nodeInterfaces);
39+
40+
// difference always returns an array
41+
if (missingInterfaces.length === 0) {
42+
return [];
43+
}
44+
const results = [];
45+
46+
for (const missing of missingInterfaces) {
47+
results.push({
48+
message: `Referenced interface with ID '${missing}' was not defined on the node with ID '${nodeId}'.`,
49+
path: [...context.path]
50+
});
51+
}
52+
return results;
53+
}

shared/src/spectral/rules-architecture.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { idsAreUnique } from './functions/architecture/ids-are-unique';
55
import { nodeIdExists } from './functions/architecture/node-id-exists';
66
import { interfaceIdExists } from './functions/architecture/interface-id-exists';
77
import { nodeHasRelationship } from './functions/architecture/node-has-relationship';
8+
import { interfaceIdExistsOnNode } from './functions/architecture/interface-id-exists-on-node';
89

910
const architectureRules: RulesetDefinition = {
1011
rules: {
@@ -101,14 +102,24 @@ const architectureRules: RulesetDefinition = {
101102
},
102103

103104
'referenced-interfaces-defined-in-architecture': {
104-
description: 'Referenced interfaces must be defined ',
105+
description: 'Referenced interfaces must be defined',
105106
severity: 'error',
106107
message: '{{error}}',
107108
given: '$.relationships[*].relationship-type.connects.*.interfaces[*]',
108109
then: {
109110
function: interfaceIdExists
110111
},
111112
},
113+
114+
'referenced-interfaces-defined-on-correct-node-in-architecture': {
115+
description: 'Connects relationships must reference interfaces that exist on the correct nodes',
116+
severity: 'error',
117+
message: '{{error}}',
118+
given: '$.relationships[*].relationship-type.connects.*',
119+
then: {
120+
function: interfaceIdExistsOnNode
121+
},
122+
},
112123

113124
'composition-relationships-reference-existing-nodes-in-architecture': {
114125
description: 'All nodes in a composition relationship must reference existing nodes',

0 commit comments

Comments
 (0)