diff --git a/README.md b/README.md index 7daebb6c..00c2a5f7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ your project. | [CLI](./cli) | @aidanm3341, @lbulanti-ms, @willosborne | [![Build CLI](https://github.com/finos-labs/architecture-as-code/actions/workflows/cli-tests.yml/badge.svg)](https://github.com/finos-labs/architecture-as-code/actions/workflows/cli-tests.yml) | | [Spectral](./spectral) | @willosborne, @lbulanti-ms | [![Validation of CALM Samples](https://github.com/finos-labs/architecture-as-code/actions/workflows/spectral-validation.yml/badge.svg)](https://github.com/finos-labs/architecture-as-code/actions/workflows/spectral-validation.yml) | | [Translators](./translator) | @Budlee @matthewgardner @jpgough-ms | [![Build Translators](https://github.com/finos-labs/architecture-as-code/actions/workflows/translator.yml/badge.svg)](https://github.com/finos-labs/architecture-as-code/actions/workflows/translator.yml) | -| [Visualizer](./cli/visualizer) | @aidanm3341, @Budlee, @willosborne | | ## Getting Involved diff --git a/calm/pattern/api-gateway.json b/calm/pattern/api-gateway.json index 68ea9868..c4ddb47c 100644 --- a/calm/pattern/api-gateway.json +++ b/calm/pattern/api-gateway.json @@ -157,8 +157,12 @@ "relationship-type": { "const": { "connects": { - "source": "api-gateway", - "destination": "idp" + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "idp" + } } } }, diff --git a/calm/samples/traderx/traderx.json b/calm/samples/traderx/traderx.json index ba6d7ca8..3e69514d 100644 --- a/calm/samples/traderx/traderx.json +++ b/calm/samples/traderx/traderx.json @@ -309,10 +309,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "web-gui-process" + "node": "web-gui-process" }, "destination": { - "nodes": "trade-feed" + "node": "trade-feed" } } }, @@ -324,10 +324,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "trade-processor" + "node": "trade-processor" }, "destination": { - "nodes": "trade-feed" + "node": "trade-feed" } } }, @@ -339,10 +339,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "trade-processor" + "node": "trade-processor" }, "destination": { - "nodes": "traderx-db" + "node": "traderx-db" } } }, @@ -354,10 +354,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "web-gui-process" + "node": "web-gui-process" }, "destination": { - "nodes": "accounts-service" + "node": "accounts-service" } } }, @@ -369,10 +369,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "web-gui-process" + "node": "web-gui-process" }, "destination": { - "nodes": "people-service" + "node": "people-service" } } }, @@ -384,10 +384,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "people-service" + "node": "people-service" }, "destination": { - "nodes": "user-directory" + "node": "user-directory" } } }, @@ -399,10 +399,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "trading-services" + "node": "trading-services" }, "destination": { - "nodes": "reference-data-service" + "node": "reference-data-service" } } }, @@ -414,10 +414,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "trading-services" + "node": "trading-services" }, "destination": { - "nodes": "trade-feed" + "node": "trade-feed" } } }, @@ -429,10 +429,10 @@ "relationship-type": { "connects": { "source": { - "nodes": "trading-services" + "node": "trading-services" }, "destination": { - "nodes": "accounts-service" + "node": "accounts-service" } } }, diff --git a/cli/src/commands/generate/generate.ts b/cli/src/commands/generate/generate.ts index ff2617ab..39680117 100644 --- a/cli/src/commands/generate/generate.ts +++ b/cli/src/commands/generate/generate.ts @@ -6,12 +6,13 @@ import { mkdirp } from 'mkdirp'; import * as winston from 'winston'; import { initLogger } from '../helper.js'; +import { CALMInstantiation } from '../../types.js'; let logger: winston.Logger; // defined later at startup function loadFile(path: string): any { logger.info('Loading pattern from file: ' + path); - const raw = fs.readFileSync(path, { encoding: 'utf8' }); + const raw = fs.readFileSync(path, 'utf-8'); logger.debug('Attempting to load pattern file: ' + raw); const pattern = JSON.parse(raw); @@ -138,9 +139,8 @@ export const exportedForTesting = { instantiateNodeInterfaces }; -export function runGenerate (patternPath: string, outputPath: string, debug: boolean): void { +export function generate(patternPath: string, debug: boolean): CALMInstantiation { logger = initLogger(debug); - const pattern = loadFile(patternPath); const outputNodes = instantiateNodes(pattern); const relationshipNodes = instantiateRelationships(pattern); @@ -150,6 +150,12 @@ export function runGenerate (patternPath: string, outputPath: string, debug: boo 'relationships': relationshipNodes, }; + return final; +} + +export function runGenerate(patternPath: string, outputPath: string, debug: boolean): void { + const final = generate(patternPath, debug); + const output = JSON.stringify(final, null, 2); logger.debug('Generated instantiation: ' + output); diff --git a/cli/src/commands/visualize/calmToDot.spec.ts b/cli/src/commands/visualize/calmToDot.spec.ts index 939e64a6..cb0d6f2a 100644 --- a/cli/src/commands/visualize/calmToDot.spec.ts +++ b/cli/src/commands/visualize/calmToDot.spec.ts @@ -1,4 +1,4 @@ -import { CALMInstantiation } from './Types'; +import { CALMInstantiation } from '../../types'; import calmToDot from './calmToDot'; jest.mock('../helper.js', () => { diff --git a/cli/src/commands/visualize/calmToDot.ts b/cli/src/commands/visualize/calmToDot.ts index 929002ff..7b8ccafe 100644 --- a/cli/src/commands/visualize/calmToDot.ts +++ b/cli/src/commands/visualize/calmToDot.ts @@ -1,5 +1,5 @@ import { Digraph, Subgraph, Node, Edge, toDot, attribute } from 'ts-graphviz'; -import { CALMInstantiation, CALMComposedOfRelationship, CALMConnectsRelationship, CALMDeployedInRelationship, CALMInteractsRelationship, CALMRelationship, CALMNode } from './Types'; +import { CALMInstantiation, CALMComposedOfRelationship, CALMConnectsRelationship, CALMDeployedInRelationship, CALMInteractsRelationship, CALMRelationship, CALMNode } from '../../types'; import { initLogger } from '../helper.js'; import winston from 'winston'; @@ -68,10 +68,10 @@ function addConnectsRelationship(g: Digraph, relationship: CALMConnectsRelations const r = relationship['relationship-type']; logger.debug(`${JSON.stringify(r)}`); logger.debug(`Creating a connects relationship from [${sourceId}] to [${destinationId}]`); - + g.addEdge(new Edge([ - idToNode[sourceId], - idToNode[destinationId] + getNode(sourceId), + getNode(destinationId) ], { label: `connects ${relationship.protocol || ''} ${relationship.authentication || ''}` })); @@ -84,8 +84,8 @@ function addInteractsRelationship(g: Digraph, relationship: CALMInteractsRelatio const targetId = maybeId; g.addEdge(new Edge([ - idToNode[sourceId], - idToNode[targetId] + getNode(sourceId), + getNode(targetId) ], { label: 'interacts' })); @@ -100,11 +100,10 @@ function addDeployedInRelationship(g: Digraph, relationship: CALMDeployedInRelat label: containerId }); - targetIds.forEach(maybeId => { - const targetId = maybeId; + targetIds.forEach(targetId => { logger.debug(`Creating a deployed-in relationship from [${containerId}] to [${targetId}]`); - subgraph.addNode(idToNode[targetId]); + subgraph.addNode(getNode(targetId)); }); g.addSubgraph(subgraph); @@ -118,11 +117,19 @@ function addComposedOfRelationship(g: Digraph, relationship: CALMComposedOfRelat label: containerId }); - targetIds.forEach(maybeId => { - const targetId = maybeId; + targetIds.forEach(targetId => { logger.debug(`Creating a composed-of relationship from [${containerId}] to [${targetId}]`); - subgraph.addNode(idToNode[targetId]); + subgraph.addNode(getNode(targetId)); }); g.addSubgraph(subgraph); -} \ No newline at end of file +} + +function getNode(id: string) { + const node = idToNode[id]; + if (!node) { + throw new Error(`There does not exist a node with ID [${id}]`); + } else { + return node; + } +} \ No newline at end of file diff --git a/cli/src/commands/visualize/visualize.spec.ts b/cli/src/commands/visualize/visualize.spec.ts index 95e1b626..1de3db3b 100644 --- a/cli/src/commands/visualize/visualize.spec.ts +++ b/cli/src/commands/visualize/visualize.spec.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import visualize from './visualize'; import * as graphviz from 'graphviz-cli'; +import { visualizeInstantiation, visualizePattern } from './visualize'; jest.mock('node:fs', () => { return { @@ -28,7 +28,7 @@ jest.mock('../helper.js', () => { }; }); -describe('visualize', () => { +describe('visualize instantiation', () => { beforeEach(() => { (readFileSync as jest.Mock).mockReturnValue(` { @@ -46,21 +46,68 @@ describe('visualize', () => { it('reads from the given input file', async () => { jest.spyOn(graphviz, 'renderGraphFromSource').mockResolvedValue(''); - await visualize('./input.json', './output.svg', false); + await visualizeInstantiation('./input.json', './output.svg', false); expect(readFileSync).toHaveBeenCalledWith('./input.json', 'utf-8'); }); it('writes to the given output file', async () => { jest.spyOn(graphviz, 'renderGraphFromSource').mockResolvedValue(''); - await visualize('./input.json', './output.svg', false); + await visualizeInstantiation('./input.json', './output.svg', false); expect(writeFileSync).toHaveBeenCalledWith('./output.svg', ''); }); it('doesnt write if an error is thrown', async () => { jest.spyOn(graphviz, 'renderGraphFromSource').mockRejectedValue(new Error()); - await visualize('./input.json', './output.svg', false); + await visualizeInstantiation('./input.json', './output.svg', false); + expect(writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('visualize pattern', () => { + beforeEach(() => { + (readFileSync as jest.Mock).mockReturnValue(` + { + "properties": { + "nodes": { + "prefixItems": [] + }, + "relationships": { + "prefixItems": [] + } + }, + "required": [ + "nodes", + "relationships" + ] + } + `); + }); + + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('reads from the given input file', async () => { + jest.spyOn(graphviz, 'renderGraphFromSource').mockResolvedValue(''); + + await visualizePattern('./input.json', './output.svg', false); + expect(readFileSync).toHaveBeenCalledWith('./input.json', 'utf-8'); + }); + + it('writes to the given output file', async () => { + jest.spyOn(graphviz, 'renderGraphFromSource').mockResolvedValue(''); + + await visualizePattern('./input.json', './output.svg', false); + expect(writeFileSync).toHaveBeenCalledWith('./output.svg', ''); + }); + + it('doesnt write if an error is thrown', async () => { + jest.spyOn(graphviz, 'renderGraphFromSource').mockRejectedValue(new Error()); + + await visualizePattern('./input.json', './output.svg', false); expect(writeFileSync).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/cli/src/commands/visualize/visualize.ts b/cli/src/commands/visualize/visualize.ts index 4ea6753a..cca21d46 100644 --- a/cli/src/commands/visualize/visualize.ts +++ b/cli/src/commands/visualize/visualize.ts @@ -3,20 +3,45 @@ import * as winston from 'winston'; import { initLogger } from '../helper.js'; import calmToDot from './calmToDot.js'; import { renderGraphFromSource } from 'graphviz-cli'; +import { generate } from '../generate/generate.js'; let logger: winston.Logger; -export default async function(input: string, output: string, debug: boolean) { +export async function visualizeInstantiation(instantiationPath: string, output: string, debug: boolean) { logger = initLogger(debug); - logger.info(`Reading CALM file from [${input}]`); - const calm = fs.readFileSync(input, 'utf-8'); + logger.info(`Reading CALM file from [${instantiationPath}]`); + const calm = fs.readFileSync(instantiationPath, 'utf-8'); logger.info('Generating an SVG from input'); try { const dot = calmToDot(JSON.parse(calm), debug); - logger.debug(`Creating the following dot: + logger.debug(`Generated the following dot: + ${dot} + `); + + const svg = await renderGraphFromSource({ input: dot }, { format: 'svg', engine: 'dot' }); + + logger.info(`Outputting file at [${output}]`); + fs.writeFileSync(output, svg); + return; + } catch (err) { + logger.error(err); + return; + } +} + +export async function visualizePattern(patternPath: string, output: string, debug: boolean) { + logger = initLogger(debug); + + const instantiation = generate(patternPath, debug); + + logger.info('Generating an SVG from input'); + + try { + const dot = calmToDot(instantiation, debug); + logger.debug(`Generated the following dot: ${dot} `); diff --git a/cli/src/index.ts b/cli/src/index.ts index 98fe5c05..86b70309 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,7 +1,7 @@ #! /usr/bin/env node -import { program } from 'commander'; -import visualize from './commands/visualize/visualize.js'; +import { Option, program } from 'commander'; +import { visualizeInstantiation, visualizePattern } from './commands/visualize/visualize.js'; import { runGenerate } from './commands/generate/generate.js'; import validate from './commands/validate/validate.js'; @@ -12,11 +12,18 @@ program program .command('visualize') .description('Produces an SVG file representing a visualization of the CALM Specification.') - .requiredOption('-i, --instantiation ', 'Path to an instantiation of a CALM pattern.') + .addOption(new Option('-i, --instantiation ', 'Path to an instantiation of a CALM pattern.').conflicts('pattern')) + .addOption(new Option('-p, --pattern ', 'Path to a CALM pattern.').conflicts('instantiation')) .requiredOption('-o, --output ', 'Path location at which to output the SVG.', 'calm-visualization.svg') .option('-v, --verbose', 'Enable verbose logging.', false) - .action((options) => { - visualize(options.instantiation, options.output, !!options.verbose); + .action(async (options) => { + if (!options.instantiation && ! options.pattern) { + throw new Error('You must provide either a pattern or an instantiation'); + } else if (options.instantiation) { + await visualizeInstantiation(options.instantiation, options.output, !!options.verbose); + } else if (options.pattern) { + await visualizePattern(options.pattern, options.output, !!options.verbose); + } }); program diff --git a/cli/src/commands/visualize/Types.d.ts b/cli/src/types.d.ts similarity index 100% rename from cli/src/commands/visualize/Types.d.ts rename to cli/src/types.d.ts