Skip to content

Commit

Permalink
Add Spectral rules to validate that interfaces are defined on the spe…
Browse files Browse the repository at this point in the history
…cific 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
  • Loading branch information
willosborne authored Jan 7, 2025
1 parent c424cf6 commit 76de03e
Show file tree
Hide file tree
Showing 11 changed files with 276 additions and 120 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/validate-spectral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ jobs:
- name: Build workspace
run: npm run build

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

- name: Run Example Spectral Linting
run: npx spectral lint --ruleset ./shared/dist/spectral/rules-architecture.js 'calm/samples/api-gateway-architecture(*.json|*.yaml)'
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"eslint": "^9.13.0",
"globals": "^15.12.0",
"jest": "^29.7.0",
"link": "^2.1.1",
"ts-jest": "^29.2.5",
"ts-node": "10.9.2",
"tsup": "^8.0.0",
Expand Down
6 changes: 4 additions & 2 deletions cli/test_fixtures/validate_output_junit.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="23" failures="0" errors="0" skipped="0">
<testsuites tests="25" failures="0" errors="0" skipped="0">
<testsuite name="JSON Schema Validation" tests="1" failures="0"
errors="0" skipped="0">
<testcase name="JSON Schema Validation succeeded" />
</testsuite>
<testsuite name="Spectral Suite" tests="22"
<testsuite name="Spectral Suite" tests="24"
failures="0" errors="0" skipped="0">
<testcase name="architecture-has-nodes-relationships" />
<testcase name="architecture-has-no-empty-properties" />
Expand All @@ -16,6 +16,7 @@
<testcase
name="connects-relationship-references-existing-nodes-in-architecture" />
<testcase name="referenced-interfaces-defined-in-architecture" />
<testcase name="referenced-interfaces-defined-on-correct-node-in-architecture" />
<testcase name="composition-relationships-reference-existing-nodes-in-architecture" />
<testcase name="architecture-nodes-must-be-referenced" />
<testcase
Expand All @@ -31,6 +32,7 @@
<testcase
name="relationship-references-existing-nodes-in-pattern" />
<testcase name="referenced-interfaces-defined-in-pattern" />
<testcase name="referenced-interfaces-defined-on-correct-node-in-pattern" />
<testcase name="pattern-nodes-must-be-referenced" />
<testcase name="unique-ids-must-be-unique-in-pattern" />
</testsuite>
Expand Down
251 changes: 138 additions & 113 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"link:cli": "npm link --workspace cli"
},
"devDependencies": {
"link": "^2.1.1",
"npm-run-all2": "^5.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { SchemaDirectory } from '../schema-directory';
import { instantiateGenericObject } from './instantiate';
Expand Down
2 changes: 1 addition & 1 deletion shared/src/commands/generate/components/property.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { JSONPath } from 'jsonpath-plus';
import { difference } from 'lodash';

/**
* Checks that the input value exists as an interface with matching unique ID defined under a node in the document.
*/
export function interfaceIdExistsOnNode(input, _, context) {
if (!input || !input.interfaces) {
return [];
}

if (!input.node) {
return [{
message: 'Invalid connects relationship - no node defined.',
path: [...context.path]
}];
}

const nodeId = input.node;
const nodeMatch: object[] = JSONPath({ path: `$.nodes[?(@['unique-id'] == '${nodeId}')]`, json: context.document.data });
if (!nodeMatch || nodeMatch.length === 0) {
// other rule will report undefined node
return [];
}

// all of these must be present on the referenced node
const desiredInterfaces = input.interfaces;

const node = nodeMatch[0];

const nodeInterfaces = JSONPath({ path: '$.interfaces[*].unique-id', json: node });
if (!nodeInterfaces || nodeInterfaces.length === 0) {
return [
{ message: `Node with unique-id ${nodeId} has no interfaces defined, expected interfaces [${desiredInterfaces}].` }
];
}

const missingInterfaces = difference(desiredInterfaces, nodeInterfaces);

// difference always returns an array
if (missingInterfaces.length === 0) {
return [];
}
const results = [];

for (const missing of missingInterfaces) {
results.push({
message: `Referenced interface with ID '${missing}' was not defined on the node with ID '${nodeId}'.`,
path: [...context.path]
});
}
return results;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { JSONPath } from 'jsonpath-plus';
import { difference } from 'lodash';

/**
* Checks that the input value exists as an interface with matching unique ID defined under a node in the document.
*/
export function interfaceIdExistsOnNode(input, _, context) {
if (!input || !input.interfaces) {
return [];
}

if (!input.node) {
return [{
message: 'Invalid connects relationship - no node defined.',
path: [...context.path]
}];
}

const nodeId = input.node;
const nodeMatch: object[] = JSONPath({ path: `$.properties.nodes.prefixItems[?(@.properties['unique-id'].const == '${nodeId}')]`, json: context.document.data });
if (!nodeMatch || nodeMatch.length === 0) {
// other rule will report undefined node
return [];
}

// all of these must be present on the referenced node
const desiredInterfaces = input.interfaces;

const node = nodeMatch[0];

const nodeInterfaces = JSONPath({ path: '$.properties.interfaces.prefixItems[*].properties.unique-id.const', json: node });
if (!nodeInterfaces || nodeInterfaces.length === 0) {
return [
{ message: `Node with unique-id ${nodeId} has no interfaces defined, expected interfaces [${desiredInterfaces}]` }
];
}

const missingInterfaces = difference(desiredInterfaces, nodeInterfaces);

// difference always returns an array
if (missingInterfaces.length === 0) {
return [];
}
const results = [];

for (const missing of missingInterfaces) {
results.push({
message: `Referenced interface with ID '${missing}' was not defined on the node with ID '${nodeId}'.`,
path: [...context.path]
});
}
return results;
}
13 changes: 12 additions & 1 deletion shared/src/spectral/rules-architecture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { idsAreUnique } from './functions/architecture/ids-are-unique';
import { nodeIdExists } from './functions/architecture/node-id-exists';
import { interfaceIdExists } from './functions/architecture/interface-id-exists';
import { nodeHasRelationship } from './functions/architecture/node-has-relationship';
import { interfaceIdExistsOnNode } from './functions/architecture/interface-id-exists-on-node';

const architectureRules: RulesetDefinition = {
rules: {
Expand Down Expand Up @@ -101,14 +102,24 @@ const architectureRules: RulesetDefinition = {
},

'referenced-interfaces-defined-in-architecture': {
description: 'Referenced interfaces must be defined ',
description: 'Referenced interfaces must be defined',
severity: 'error',
message: '{{error}}',
given: '$.relationships[*].relationship-type.connects.*.interfaces[*]',
then: {
function: interfaceIdExists
},
},

'referenced-interfaces-defined-on-correct-node-in-architecture': {
description: 'Connects relationships must reference interfaces that exist on the correct nodes',
severity: 'error',
message: '{{error}}',
given: '$.relationships[*].relationship-type.connects.*',
then: {
function: interfaceIdExistsOnNode
},
},

'composition-relationships-reference-existing-nodes-in-architecture': {
description: 'All nodes in a composition relationship must reference existing nodes',
Expand Down
10 changes: 10 additions & 0 deletions shared/src/spectral/rules-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import nodeIdExists from './functions/pattern/node-id-exists';
import idsAreUnique from './functions/pattern/ids-are-unique';
import nodeHasRelationship from './functions/pattern/node-has-relationship';
import { interfaceIdExists } from './functions/pattern/interface-id-exists';
import { interfaceIdExistsOnNode } from './functions/pattern/interface-id-exists-on-node';


const patternRules: RulesetDefinition = {
Expand Down Expand Up @@ -110,6 +111,15 @@ const patternRules: RulesetDefinition = {
function: interfaceIdExists,
},
},
'referenced-interfaces-defined-on-correct-node-in-pattern': {
description: 'Connects relationships must reference interfaces that exist on the correct nodes',
severity: 'error',
message: '{{error}}',
given: '$..relationship-type.const.connects.*',
then: {
function: interfaceIdExistsOnNode
},
},
'pattern-nodes-must-be-referenced': {
description: 'Nodes must be referenced by at least one relationship',
severity: 'warn',
Expand Down

0 comments on commit 76de03e

Please sign in to comment.