Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Spectral rules to validate that interfaces are defined on the specific node referenced (169) #743

Merged
merged 9 commits into from
Jan 7, 2025
Merged
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
255 changes: 140 additions & 115 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
Loading