diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 9432fa591..07cd9183c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -13,15 +13,19 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + java: [ '17' ] + max-parallel: 1 steps: - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v3 + - name: Set up Java + uses: actions/setup-java@v3 with: - java-version: '11' - distribution: 'adopt' + java-version: ${{ matrix.java }} + distribution: 'temurin' - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle - run: ./gradlew + run: ./gradlew -x :structurizr-autolayout:test diff --git a/.gitignore b/.gitignore index 7068eac3c..4a491ce38 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,12 @@ build out bin **/bin/ +**/target/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +structurizr-dsl/src/test/resources/dsl/spring-petclinic/.structurizr +structurizr-dsl/src/test/resources/dsl/spring-petclinic/workspace.json + +**/structurizr.properties \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 14e654e53..000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: java - -jdk: - - oraclejdk8 - -install: true - -script: - - ./gradlew --info \ No newline at end of file diff --git a/README.md b/README.md index bb3c09cce..d6ec8cb2d 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,3 @@ -![Structurizr](docs/images/structurizr-banner.png) - # Structurizr for Java -This GitHub repository is an official client library for the [Structurizr](https://structurizr.com) cloud service and on-premises installation, both of which are web-based publishing platforms for software architecture models based upon the [C4 model](https://c4model.com). The component finder, adr-tools importer, and alternative diagram export formats (e.g. PlantUML) can be found at [Structurizr for Java extensions](https://github.com/structurizr/java-extensions). - -## A quick example - -As an example, the following Java code can be used to create a software architecture __model__ and an associated __view__ that describes a user using a software system. - -```java -public static void main(String[] args) throws Exception { - Workspace workspace = new Workspace("Getting Started", "This is a model of my software system."); - Model model = workspace.getModel(); - - Person user = model.addPerson("User", "A user of my software system."); - SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "My software system."); - user.uses(softwareSystem, "Uses"); - - ViewSet views = workspace.getViews(); - SystemContextView contextView = views.createSystemContextView(softwareSystem, "SystemContext", "An example of a System Context diagram."); - contextView.addAllSoftwareSystems(); - contextView.addAllPeople(); -} -``` - -The view can then be exported to be visualised using the [Structurizr cloud service/on-premises installation](https://structurizr.com), or other formats including PlantUML and WebSequenceDiagrams via the [Structurizr for Java extensions](https://github.com/structurizr/java-extensions). - -![Views can be exported and visualised in many ways; e.g. PlantUML, Structurizr and Graphviz](docs/images/readme-1.png) - -## Table of contents - -* Introduction - * [Getting started](docs/getting-started.md) - * [About Structurizr and how it compares to other tooling](https://structurizr.com/help/about) - * [Why use code?](https://structurizr.com/help/code) - * [Basic concepts](https://structurizr.com/help/concepts) (workspaces, models, views and documentation) - * [C4 model](https://c4model.com) - * [Examples](https://github.com/structurizr/examples) - * [Binaries](docs/binaries.md) - * [Building from source](docs/building.md) - * [API client](docs/api-client.md) - * [Usage patterns](docs/usage-patterns.md) - * [FAQ](docs/faq.md) -* Model - * [Creating your model](docs/model.md) - * [Implied relationships](docs/implied-relationships.md) -* Views - * [Creating views](docs/views.md) - * [System Context diagram](docs/system-context-diagram.md) - * [Container diagram](docs/container-diagram.md) - * [Component diagram](docs/component-diagram.md) - * [Dynamic diagram](docs/dynamic-diagram.md) - * [Deployment diagram](docs/deployment-diagram.md) - * [System Landscape diagram](docs/system-landscape-diagram.md) - * [Styling elements](docs/styling-elements.md) - * [Styling relationships](docs/styling-relationships.md) - * [Filtered views](docs/filtered-views.md) - * [Graphviz automatic layout](https://github.com/structurizr/java-extensions/blob/master/structurizr-graphviz) -* Documentation - * [Documentation overview](docs/documentation.md) - * [Structurizr](docs/documentation-structurizr.md) - * [arc42](docs/documentation-arc42.md) - * [Viewpoints and Perspectives](docs/documentation-viewpoints-and-perspectives.md) - * [Automatic template](docs/documentation-automatic.md) - * [Architecture decision records](docs/decisions.md) -* Other - * [HTTP-based health checks](docs/health-checks.md) - * [Client-side encryption](docs/client-side-encryption.md) - * [Corporate branding](docs/corporate-branding.md) -* Related projects - * [java-quickstart](https://github.com/structurizr/java-quickstart): A simple starting point for using Structurizr for Java - * [java-extensions](https://github.com/structurizr/java-extensions): A collection of Structurizr for Java extensions; including the ability to extract software architecture information from code, export views to PlantUML, etc. - * [arch-as-code](https://github.com/nahknarmi/arch-as-code): A tool to store software architecture diagrams/documentation as YAML, and publish it to Structurizr. - * [structurizr-kotlin](https://github.com/Catalysts/structurizr-extensions/tree/master/structurizr-kotlin): An extension for Structurizr that lets you create your models in a fluent way. - * [structurizr-spring-boot](https://github.com/Catalysts/structurizr-extensions/tree/master/structurizr-spring-boot): A way to apply dependency management to help modularise Structurizr code. - * [structurizr-groovy](https://github.com/tidyjava/structurizr-groovy): An initial version of a Groovy wrapper around Structurizr for Java. - * [structurizr-dotnet](https://github.com/structurizr/dotnet): Structurizr for .NET -* [changelog](docs/changelog.md) - -[![Build Status](https://travis-ci.org/structurizr/java.svg?branch=master)](https://travis-ci.org/structurizr/java) - +> The code in this repo has been moved to https://github.com/structurizr/structurizr \ No newline at end of file diff --git a/build.gradle b/build.gradle index eb6c17aec..18adb8a7b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,36 +2,41 @@ defaultTasks 'clean', 'compileJava', 'test' subprojects { proj -> - apply plugin: 'java' + apply plugin: 'java-library' apply plugin: 'maven-publish' apply plugin: 'signing' description = 'Structurizr' group = 'com.structurizr' - version = '1.13.1' repositories { mavenCentral() } - sourceSets { - main { - java { - srcDir 'src' - } + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.11.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.11.3' + } + + test { + useJUnitPlatform { + excludeTags "IntegrationTest" } - test { - java { - srcDir 'test/unit' - } + } + + tasks.register("integrationTest", Test) { + useJUnitPlatform { + includeTags "IntegrationTest" } + mustRunAfter check } compileJava.options.encoding = 'UTF-8' compileTestJava.options.encoding = 'UTF-8' - sourceCompatibility = 1.8 - targetCompatibility = 1.8 + sourceCompatibility = 17 + targetCompatibility = 17 java { withJavadocJar() @@ -51,7 +56,7 @@ subprojects { proj -> repositories { maven { name = "ossrh" - url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + url = "https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" credentials { username = findProperty('ossrhUsername') password = findProperty('ossrhPassword') @@ -69,8 +74,8 @@ subprojects { proj -> url = 'https://github.com/structurizr/java' scm { - connection = 'scm:git:git://github.com/structurizr/structurizr-java.git' - developerConnection = 'scm:git:git@github.com:structurizr/structurizr-java.git' + connection = 'scm:git:git://github.com/structurizr/java.git' + developerConnection = 'scm:git:git@github.com:structurizr/java.git' url = 'https://github.com/structurizr/java' } diff --git a/changelog.md b/changelog.md new file mode 100644 index 000000000..396b73915 --- /dev/null +++ b/changelog.md @@ -0,0 +1,166 @@ +# Changelog + +## v5.0.3 (21st November 2025) + +- structurizr-export: Adds the `addSkinParam()` method back to the PlantUML exporters. + +## v5.0.2 (9th November 2025) + +- structurizr-client: Adds a `getWorkspaceAsJson()` to `WorkspaceApiClient`. +- structurizr-client: Adds branches and users information to the admin API response. + +## v5.0.1 (1st November 2025) + +- structurizr-core: Fixes https://github.com/structurizr/java/issues/449 (allow text/plain content types when loading themes). + +## v5.0.0 (28th October 2025) + +- structurizr-autolayout: Adds support for custom padding view/viewset properties: `structurizr.groupPadding`,`structurizr.boundaryPadding`, and `structurizr.deploymentNodePadding`. +- structurizr-core: Removes support for deprecated enterprise and location concepts. +- structurizr-core: Adds support for filtered deployment views (https://github.com/structurizr/java/issues/409). +- structurizr-core: Image views can have separate images for light and dark color schemes. +- structurizr-component: Fixes https://github.com/structurizr/java/issues/437 (Make ComponentFinder.run() not fail on empty Set). +- structurizr-dsl: Adds support for `iconPosition` on element styles (options are `Top`, `Bottom`, `Left`). +- structurizr-dsl: Adds support for defining element and relationship styles for light and dark color schemes. +- structurizr-dsl: Added `Bucket`, `Shell`, and `Terminal` shapes. +- structurizr-dsl: Adds an `instanceOf` keyword as an alternative for `softwareSystemInstance` and `containerInstance`. +- structurizr-dsl: Relationships to/from software system/container instances can be now defined by using the software system/container identifier. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/435 (Relationship archetype not applied to implicit-source relationships). +- structurizr-dsl: Adds a new operator (`-/>`) for removing relationships between software system/container instances, with a view to redefining them via infrastructure nodes. +- structurizr-dsl: Adds support for a `jump` property on relationship styles. +- structurizr-dsl: PlantUML, Mermaid, and Kroki image views can now be defined by an inline source block. +- structurizr-dsl: Constants and variables are now inherited when extending a DSL workspace. +- structurizr-dsl: DSL source is only stored in the JSON workspace when the DSL is deemed as "portable" (i.e. no file references, plugins, scripts). +- structurizr-dsl: Deprecates `StructurizrDSLParser.setRestricted(boolean)` in favour of finer-grained features. +- structurizr-dsl: Identifiers are no longer stored as lower case in the JSON (the `structurizr.dsl.identifier` property on elements and relationships). +- structurizr-export: Removes support for deprecated enterprise and location concepts. +- structurizr-export: PlantUML exporters - replaces skinparams with styles. +- structurizr-export: PlantUML exporters - adds support for dark mode exports. +- structurizr-export: PlantUML exporters - adds order number to relationships in sequence diagrams. +- structurizr-export: StructurizrPlantUMLExporter - adds technology to sequence diagrams (https://github.com/structurizr/java/issues/425) +- structurizr-import: Adds support for `plantuml.inline`, `mermaid.inline`, `kroki.inline`, and `image.inline` properties to inline the resulting PNG/SVG file into the workspace. +- structurizr-inspection: Adds a way to disable inspections via a workspace property named `structurizr.inspection` (`false` to disable). +- structurizr-inspection: Default inspector adds a summary of error/warning/info/ignore counts as workspace properties. +- structurizr-inspection: Fixes `model.deploymentnode.technology` (it was checking the description property rather than technology). +- structurizr-inspection: Fixes a bug preventing inspection severity to be specified via linked relationships. + +## v4.1.0 (28th May 2025) + +- structurizr-client: Fixes https://github.com/structurizr/java/issues/413 (Cannot push to main branch, when branch feature is activated). +- structurizr-dsl: Allows archetypes to be used via workspace extension. +- structurizr-dsl: Adds archetype support for custom elements. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/399 (Archetype tags sometimes missing). +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/392 (SVG not supported in base 64 encoding not mentioned in documentation). +- structurizr-dsl: Adds support for setting the symbols surrounding element/relationship metadata used when rendering diagrams. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/408 (Animation steps cannot be added to deployment views via static structure element references). +- structurizr-dsl: Adds support for specifying view animation steps via element expressions. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/404 (deploymentGroup does not obey !identifiers hierarchical). +- structurizr-export: Adds support for rank and node separation to the StructurizrPlantUMLExporter. + +## v4.0.0 (28th March 2025) + +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/374 (!identifiers hierarchical isn't propagated when extending a workspace). +- structurizr-dsl: Adds the ability to use the `group` keyword inside a component definition, to set the group name of that component. +- structurizr-dsl: Adds the ability to use the `group` keyword inside the component finder strategy `forEach` block. +- structurizr-dsl: Adds a reluctant version of `include *` (`include *?`) for system context views that only adds relationships to/from the scoped software system. +- structurizr-dsl: Adds a reluctant version of `include *` (`include *?`) for container views that only adds relationships to/from the containers in the scoped software system. +- structurizr-dsl: Adds a reluctant version of `include *` (`include *?`) for component views that only adds relationships to/from the components in the scoped container. +- structurizr-dsl: Removes deprecated `!ref` and `!extend` keywords. +- structurizr-dsl: Adds support for Java style `"""` multi-line text blocks. +- structurizr-dsl: Adds support for defining element and relationship archetypes. + +## 3.2.1 (10th December 2024) + +- structurizr-core: Fixes https://github.com/structurizr/java/issues/362 (Ordering of replicated relationships in deployment environment is non-deterministic). + +## 3.2.0 (6th December 2024) + +- structurizr-dsl: Adds support for `element!=` expressions. +- structurizr-dsl: `!elements` and `!relationships` now work inside deployment environment blocks. +- structurizr-dsl: `description` and `technology` now work inside `!elements` blocks. + +## 3.1.0 (4th November 2024) + +- structurizr-client: Workspace archive file now includes the branch name in the filename. +- structurizr-component: Adds `ImplementationWithPrefixSupportingTypesStrategy`. +- structurizr-component: Adds `ImplementationWithSuffixSupportingTypesStrategy`. +- structurizr-dsl: Adds `supportingTypes implementation-prefix `. +- structurizr-dsl: Adds `supportingTypes implementation-suffix `. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/346 (`// comment \` joins lines). +- structurizr-dsl: Anonymous identifiers for relationships (i.e. relationships not assigned to an identifier) are excluded from the model, and therefore also excluded from the serialised JSON. +- structurizr-dsl: Adds a way to configure whether the DSL source is retained via a workspace property named `structurizr.dsl.source` - `true` (default) or `false`. +- structurizr-dsl: Adds the ability to define a PlantUML/Mermaid image view that is an export of a workspace view. +- structurizr-dsl: Adds support for `url`, `properties`, and `perspectives` nested inside `!elements` and `!relationships`. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/347 (`->container->` expression does not work as expected in deployment view). +- structurizr-dsl: Adds support for `!elements group` (https://github.com/structurizr/java/issues/351). + +## 3.0.0 (19th September 2024) + +- structurizr-client: Adds support to get/put workspace branches on the [cloud service](https://docs.structurizr.com/cloud/workspace-branches) and [on-premises installation](https://docs.structurizr.com/onpremises/workspace-branches). +- structurizr-core: Adds name-value properties to dynamic view relationship views (https://github.com/structurizr/java/issues/316). +- structurizr-component: Initial rewrite of the original `structurizr-analysis` library - provides a way to automatically find components in a Java codebase. +- structurizr-dsl: Removes deprecated `!constant` keyword. +- structurizr-dsl: Adds name-value properties to dynamic view relationship views. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/312 (!include doesn't work with files encoded as UTF-8 BOM). +- structurizr-dsl: Adds a way to explicitly specify the order of relationships in dynamic views. +- structurizr-dsl: Adds support for element technology expressions (e.g. `element.technology==Java` and `element.technology!=Java`). +- structurizr-dsl: Deprecates `!ref` and `!extend`. +- structurizr-dsl: Adds an `!element` keyword that can be used to find a single element by identifier or canonical name (replaces `!ref` and `!extend`). +- structurizr-dsl: Adds an `!elements` keyword that can be used to find a set of elements via an expression. +- structurizr-dsl: Adds an `!relationship` keyword that can be used to find a single relationship by identifier (replaces `!ref` and `!extend`). +- structurizr-dsl: Adds a `!relationships` keyword that can be used to find a set of relationships via an expression. +- structurizr-dsl: Adds a DSL wrapper around the `structurizr-component` component finder (`!components`). +- structurizr-dsl: Adds support for local theme files to be specified via `theme` (https://github.com/structurizr/java/issues/331). +- structurizr-dsl: An exception is now thrown when trying to use disallowed features in restricted mode (e.g. `!docs`, `!include `, etc). +- structurizr-export: Adds support for icons to the Ilograph exporter (https://github.com/structurizr/java/issues/332). +- structurizr-export: Adds support for imports to the Ilograph exporter (https://github.com/structurizr/java/issues/332). +- structurizr-export: Fixes https://github.com/structurizr/java/issues/337 (Malformed subgraph name in Mermaid render). + +## 2.2.0 (2nd July 2024) + +- structurizr-dsl: Adds support for element/relationship property expressions (https://github.com/structurizr/java/issues/297). +- structurizr-dsl: Adds a way to specify the implied relationships strategy via a fully qualified class name when using `!impliedRelationships`. +- structurizr-dsl: Adds the ability to include single files as documentation (https://github.com/structurizr/java/issues/303). + +## 2.1.4 (18th June 2024) + +- structurizr-core: Fixes https://github.com/structurizr/java/issues/306 (Workspace.trim() is not correctly removing relationships when the destination of a relationship is removed from the workspace). + +## 2.1.3 (16th June 2024) + +- structurizr-core: Fixes https://github.com/structurizr/java/issues/298 (Unknown model item type on 'element'). + +## 2.1.2 (30th April 2024) + +- structurizr-core: Adds better backwards compatibility to deal with old workspaces and those created by third party tooling that are missing view `order` property on views. +- structurizr-export: Fixes https://github.com/structurizr/java/issues/263 (C4PlantUMLExporter not following C4-PlantUML best practices with c4plantuml.tags true). + +## 2.1.1 (3rd March 2024) + +- structurizr-core: Fixes problem with ordering of relationship view vertices. + +## 2.1.0 (2nd March 2024) + +- structurizr-core: `ViewSet.isEmpty()` was missing a check for image views. +- structurizr-core: Promotes `ModelView.copyLayoutInformationFrom()` to be public, to allow individual view layout information to be merged. +- structurizr-client: Fixes https://github.com/structurizr/java/issues/257 (Serialization to JSON is not deterministic). +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/252 (DSL parser does not seem to handle curly brackets balance). +- structurizr-dsl: Deprecates `!constant`, adds `!const` and `!var` (see https://github.com/structurizr/java/issues/253). +- structurizr-export: Fixes https://github.com/structurizr/java/issues/258 (Plantuml renderer: Group and system of same name yields puml code resulting in error). + +## 2.0.0 (22nd February 2024) + +- structurizr-core: Removes deprecated concepts - enterprise and software system/person location. +- structurizr-core: Adds `Workspace.trim()` to trim a workspace of unused elements (i.e. those not associated with any views). +- structurizr-core: Adds support for SVG image views (https://github.com/structurizr/java/issues/249). +- structurizr-core: View keys will be automatically generated if not specified. +- structurizr-client: Removes `StructurizrClient` (use `WorkspaceApiClient` instead). +- structurizr-client: Merges https://github.com/structurizr/java/pull/238 (fix: re-enable system properties for theme http client). +- structurizr-dsl: Removes `enterprise` keyword. +- structurizr-dsl: Adds `!decisions` as a synonym for `!adrs`. +- structurizr-dsl: Allows `!identifiers` to be used inside `model`. +- structurizr-dsl: Fixes https://github.com/structurizr/java/issues/233 (Implied relationships not configured correctly when DSL workspace extends a JSON workspace). +- structurizr-import: Adds support for importing decisions managed by Log4brains. +- structurizr-import: Adds support for importing decisions in MADR format. +- structurizr-import: Fixes https://github.com/structurizr/java/issues/251 (Importing docs fails on files without extension). +- structurizr-inspection: Initial version. \ No newline at end of file diff --git a/docs/api-client.md b/docs/api-client.md deleted file mode 100644 index 7a45400ac..000000000 --- a/docs/api-client.md +++ /dev/null @@ -1,95 +0,0 @@ -# API client - -The Structurizr for Java library includes a client for the [Structurizr web API](https://api.structurizr.com), which allows you to get and put workspaces using JSON over HTTPS. This page provides a quick overview of how to use the API client. - -## Configuration - -The are two ways to configure the API client. - -### 1. Programmatically - -The easiest way to configure the API client is to provide values for the API key and API secret programmatically. Each workspace has its own API key and secret, the values for which can be found on [your Structurizr dashboard](https://structurizr.com/dashboard). - -```java -StructurizrClient structurizrClient = new StructurizrClient("key", "secret"); -``` - -If you're using the [on-premises installation](https://structurizr.com/help/on-premises), there is a three argument version of the constructor where you can also specify the API URL. - -```java -StructurizrClient structurizrClient = new StructurizrClient("url", "key", "secret"); -``` - -### 2. Properties file - -If you would like to separate your API credentials from the code, you can configure the values in a Java properties file. This should be named ```structurizr.properties``` and located on the classpath. - -``` -structurizr.api.url=https://api.structurizr.com -structurizr.api.key=key -structurizr.api.secret=secret -``` - -The API client can then be constructed using the default, no args, constructor. - -```java -StructurizrClient structurizrClient = new StructurizrClient(); -``` - -## Usage - -The following operations are available on the API client. - -### 1. getWorkspace - -This allows you to get the content of a remote workspace. - -```java -Workspace workspace = structurizrClient.getWorkspace(1234); -``` - -By default, a copy of the workspace (as a JSON document) is archived to the current working directory. You can modify this behaviour by calling ```setWorkspaceArchiveLocation```. A ```null``` value will disable archiving. - -### 2. putWorkspace - -This allows you to overwrite an existing remote workspace. If the ```mergeFromRemote``` property (on the ```StructurizrClient``` instance) is set to ```true``` (this is the default), any layout information (i.e. the location of boxes on diagrams) is preserved where possible (i.e. where diagram elements haven't been renamed). - -```java -structurizrClient.putWorkspace(1234, workspace); -``` - -### 3. lockWorkspace - -If your workspace supports sharing (not available with the Free Plan), you can optionally attempt to lock your workspace before writing to it, to prevent concurrent updates. - -```java -structurizrClient.lockWorkspace(1234); -``` - -This method returns a boolean; ```true``` if the workspace could be locked, ```false``` otherwise. - -### 4. unlockWorkspace - -Similarly, you can unlock a workspace. - -```java -structurizrClient.unlockWorkspace(1234); -``` - -This method also returns a boolean; ```true``` if the workspace could be unlocked, ```false``` otherwise. - -## SSL handshake errors - -SSL handshake errors are likely if using a self-signed certificate with the on-premises installation, because the Structurizr client program runtime won't trust a self-signed certificate by default. - -If this happens, you can use the ```javax.net.ssl.trustStore``` JVM option to point to your keystore. For example: - -``` -java -Djavax.net.ssl.trustStore=/some/path/to/keystore.jks YourJavaProgram -``` - -Alternatively, you can specify this property in your Java program: - -``` -System.setProperty("javax.net.ssl.trustStore", "/some/path/to/keystore.jks"); -``` diff --git a/docs/binaries.md b/docs/binaries.md deleted file mode 100644 index 74ef946e0..000000000 --- a/docs/binaries.md +++ /dev/null @@ -1,7 +0,0 @@ -# Binaries -The "Structurizr for Java" binaries are hosted on [Maven Central](https://repo1.maven.org/maven2/com/structurizr/) and the dependencies for use with Maven, Ivy, Gradle, etc are as follows. - -Name | Description ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- -com.structurizr:structurizr-core | The core library that can used to create software architecture models. -com.structurizr:structurizr-client | The API client for publishing models on the Structurizr cloud service and on-premises installation. \ No newline at end of file diff --git a/docs/building.md b/docs/building.md deleted file mode 100644 index f6f8b2402..000000000 --- a/docs/building.md +++ /dev/null @@ -1,17 +0,0 @@ -# Building - -[![Build Status](https://travis-ci.org/structurizr/java.svg?branch=master)](https://travis-ci.org/structurizr/java) - -To build "Structurizr for Java" from the sources (you'll need Java 8)... - -``` -git clone https://github.com/structurizr/java.git -cd java -./gradlew compileJava test -``` - -If necessary, after building, you can install "Structurizr for Java" into your local Maven repo using: - -``` -./gradlew publishToMavenLocal -``` \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index d1566a0f2..000000000 --- a/docs/changelog.md +++ /dev/null @@ -1,261 +0,0 @@ -# Changelog - -## 1.13.1 (unreleased to Maven Central) - -- Adds a helper method (`AbstractImpliedRelationshipsStrategy.createImpliedRelationship`) to create implied relationships, which can then be used by custom implementations. - -## 1.13.0 (25th June 2022) - -- Adds support for name/value properties on element and relationship styles. - -## 1.12.2 (30th March 2022) - -- Adds support for sorting views by the order in which they are created. - -## 1.12.1 (2nd March 2022) - -- Renamed `Decision.Link.type` to `Decision.Link.description`. - -## 1.12.0 (1st March 2022) - -- Breaking API changes to how documentation and decisions are managed. -- Moved documentation importers/templates to [structurizr-documentation](https://github.com/structurizr/documentation). -- Moved examples to [structurizr-examples](https://github.com/structurizr/examples) -- Removal of deprecated `Model.addImplicitRelationships()` method. - -## 1.11.0 (18th February 2022) - -- Fixes #167 (ImpliedRelationship Strategy replication of URL and perspectives). -- Makes the `Decision.setContent()` method public, to allow pre-processing of content before workspace upload/rendering. - -## 1.10.1 (1st February 2022) - -- Makes the `Section.setContent()` method public, to allow pre-processing of content before workspace upload/rendering. - -## 1.10.0 (29th December 2021) - -- Adds support for different relationship line styles (solid, dashed, dotted). -- Adds the ability to indicate that individual views should not merge layout from remotes. -- Adds name/value properties to the view set configuration. - -## 1.9.10 (26th November 2021) - -- Promotes a couple of methods to be public; no functional changes. - -## 1.9.9 (16th October 2021) - -- Adds the implied relationships functionality for custom elements. -- "addDefaultElements" will now also add any connected custom elements. - -## 1.9.8 (1st October 2021) - -- Adds support for relationships from deployment nodes to infrastructure nodes. - -## 1.9.7 (9th September 2021) - -- Adds support for software system/container instances in multiple deployment groups. - -## 1.9.6 (31st August 2021) - -- Added validation logic to reject unsupported image data URIs. -- Fixes #166 (ContainerInstance/SoftwareSystemInstance and auto-generation of deployment diagram). - -## 1.9.5 (7th June 2021) - -- Provides a way to store view dimensions. - -## 1.9.4 (22nd May 2021) - -- Bug fixes to prevent parents and children to both be added to container/component views. - -## 1.9.3 (11th May 2021) - -- Added an `addTheme` method on `Configuration`. -- Removed the `addDefaultStyles` method on `Styles`. - -## 1.9.2 (27th April 2021) - -- Adds a `Diamond` shape. - -## 1.9.1 (28th March 2021) - -- Adds a `findTerminology` method on the `Terminology` class. -- `Styles.findElementStyle` better mirrors how the Structurizr web renderer deals with element styling. - -## 1.9.0 (20th March 2021) - -- Adds support for adding individual infrastructure nodes, software system instances, and container instances to a deployment view. -- Adds support for removing software system instances from deployment views. -- Improved support for themes (e.g. when exporting to PlantUML), which now works the same as described at [Structurizr - Themes](https://structurizr.com/help/themes). -- Adds support for "deployment groups", providing a way to scope how software system/container instance relationships are replicated when added to deployment nodes. __breaking change__ - -## 1.8.0 (20th February 2021) - -- Adds support for custom elements and custom views (experimental). -- Bug fixes and improved workspace validation. - -## 1.7.2 (2nd February 2021) - -- Bug fixes. - -## 1.7.1 (28th January 2021) - -- Bug fixes. - -## 1.7.0 (6th January 2021) - -- Removes the dynamic view restrictions related to adding containers/components outside the scoped software system/container. -- Adds an "externalBoundariesVisible" property to DynamicView, so that external software system/container boundaries can be shown/hidden. -- Enhanced the rules relating to whether elements can be added to a view or not. -- Enhanced the logic to merge layout information of elements on views. - -## 1.6.3 (30th November 2020) - -- When adding a relationship to a dynamic view, the first relationship between the source and destination would be chosen, even if there are multiple relationships with different technologies. This release adds a way to indicate which relationship (based upon technology) should be chosen. -- Suppress description warnings for software system instances. - -## 1.6.2 (10th October 2020) - -- Resolves an issue with the AutomaticDocumentationTemplate, where images were being included as documentation content. - -## 1.6.1 (27th September 2020) - -- Added a "recursive" option to the AutomaticDocumentationTemplate, so that sub-directories can optionally be scanned too. - -## 1.6.0 (18th September 2020) - -- Changed the way that internal canonical element names are generated, to improve layout merging for deployment views. -- getParent() of SoftwareSystemInstance and ContainerInstance now returns the parent deployment node. -- Refactoring and bug fixes. - -## 1.5.0 (4th August 2020) - -- Fixes #151: linked relationship tags were not being taken into account when finding relationship styling. -- Fixes #153: Allow relationships in DynamicView to go both ways without two relationships between Elements in Model. -- Adds support for software system instances on deployment views (#150: how do I provide tech details for an external system to show in Deployment View?) -- The interaction style on relationships no longer defaults to Synchronous. -- Adds support for software system instances on deployment views. - -## 1.4.8 (15th July 2020) - -- Implied relationships now also copy the interaction style and tags. -- Fixes a serialisation problem with themes and styles. - -## 1.4.7 (6th July 2020) - -- Remove default stroke styling. - -## 1.4.6 (6th July 2020) - -- Adds a way to load styles from external themes. - -## 1.4.5 (21st June 2020) - -- Bug fixes. - -## 1.4.4 (21st June 2020) - -- Adds an "addDefaultElements()" method to the static/deployment views. -- Adds an "addDefaultStyles()" method to Styles. -- Adds a "createDefaultViews()" method to Views. - -## 1.4.3 (19th June 2020) - -- Fixes a bug where all deployment nodes would be added to a deployment view, even if that view had an environment set. -- Adds support for removing deployment nodes, infrastructure nodes, and container instances from deployment views. -- Fixes a bug where deployment node instances could set to a non-positive integer. - -## 1.4.2 (18th June 2020) - -- Adds the ability to add container instances and infrastructure nodes to the same animation step on a deployment view. -- Adds the ability to override the Structurizr client agent string. - -## 1.4.1 (14th June 2020) - -- Fixes a bug that defaults the relationship interaction style to Synchronous, when it's specifically set to null. - -## 1.4.0 (5th June 2020) - -- Added a "Component" element shape. -- Added a "Dotted" element border style. -- Components from any container can now be added to a component view. -- Added an externalContainersBoundariesVisible property to ComponentView, to set whether container boundaries should be visible for "external" components (those outside the container in scope). -- Improved the support for creating [implied relationships](docs/implied-relationships.md). -- Added the ability to customize the symbols used when rendering metadata. -- Adds support for infrastructure nodes. -- Adds support for multiple themes. -- Adds support for curved relationship routing. - -## 1.3.5 (26th March 2020) - -- Added an externalSoftwareSystemBoundariesVisible property to ContainerView, to set whether software system boundaries should be visible for "external" containers (those outside the software system in scope). -- Added a 16:10 ratio paper size. - -## 1.3.4 (29th February 2020) - -- Split View.setAutomaticLayout(boolean) to enableAutomaticLayout() and disableAutomaticLayout() (__breaking change__). -- Added A1 and A0 paper sizes. -- Adds support for themes. -- Adds support for tags on deployment nodes. -- Adds support for animations on deployment views. -- Adds support for URLs on relationships. - -## 1.3.3 (24th December 2019) - -- Fixes a deserialization issue with component views. - -## 1.3.2 (22nd November 2019) - -- Added support for element stroke colours. - -## 1.3.1 (29th October 2019) - -- The automatic layout algorithm can now be configured on individual views. -- The structurizr-annotations library can now be more easily used with OSGi applications. -- Fixes a bug with the PlantUML and WebSequenceDiagram writers, where relationships were sorted incorrectly (alphabetically, rather than numerically). -- Fixes a bug that allows relationships to be created between parents and children. -- The way layout information is copied between different versions of a view is now configurable by setting a custom LayoutMergeStrategy on a per view basis. - -## 1.3.0 (3rd March 2019) - -- Added the ability to lock and unlock workspaces, to prevent concurrent updates. - -## 1.2.0 (3rd January 2019) - -- Fixes an issue with Java 11 and SSL handshaking. -- The terminology for relationships can now be customised. -- Added support for icons on element styles. -- Top-level deployment nodes can now be given an environment property, to represent which deployment environment they belong to (e.g. "Development", "Live", etc). -- Relationships can no longer be created between container instances (__breaking change__). -- When adding elements to views, you can now optionally specify whether relationships to/from that element are added. -- Provided a way to customize the sort order when displaying the list of views. - -## 1.1.0 (8th November 2018) - -- Added the ability to specify users who should have read-write or read-only workspace access, via the ```workspace.getConfiguration().addUser(username, role)``` method. - -## 1.0.0 (17th Oct 2018) - -- Added name-value properties to relationships. -- Added the ability to define animations on the static structure diagrams. -- Removed support for colours in the corporate branding feature (__breaking change__). -- The PlantUML writer can now export sequence diagrams. - -## 1.0.0-RC7 - -- HTTP-based health check interval and timeout can be specified via the factory method now (__breaking change__). Also added some documentation and an example. -- Added an ```endParallelSequence(boolean)``` method to the ```DynamicView``` class, which allows sequence numbering to continue. -- Fixed a bug where the software system associated with a SystemContextView could be removed from the view. -- Added support for architecture decision records. - -## 1.0.0-RC6 - -- Component finders are no longer idempotent, and an exception will be thrown if the same component is discovered more than once (__breaking change__). -- Removed the "groups" property of documentation sections (__breaking change__). -- Added some new shapes: web browser, mobile device (portrait and landscape), and robot. -- Addition of @NonNull annotations (JSR 305: Annotations for Software Defect Detection). -- Added the ability to enable/disable the enterprise boundary on system landscape and system context views. -- Added the ability to customise the terminology used when rendering views. -- Added the ability to hide element metadata and/or descriptions. -- The Spring component finder now supports the @Endpoint annotation. -- Bug fixes and performance enhancements. \ No newline at end of file diff --git a/docs/client-side-encryption.md b/docs/client-side-encryption.md deleted file mode 100644 index 644c48052..000000000 --- a/docs/client-side-encryption.md +++ /dev/null @@ -1,23 +0,0 @@ -# Client-side encryption - -> Note: this page describes a feature that is not available to use with Structurizr's Free Plan. - -The JSON representation of your workspace is stored on the Structurizr servers using AES encryption with a 128-bit key, a random salt and a server-side passphrase. For additional peace of mind, you can choose to encrypt your workspace with your own passphrase on the client before uploading it to Structurizr. In order to view a client-side encrypted workspace, you will be asked to enter your passphrase when you open the workspace in your web browser. The passphrase is then used to decrypt the workspace in your web browser - at no point does the passphrase leave your computer. - -To use client-side encryption, simply create an instance of ```AesEncryptionStrategy``` and associate it with your ```StructurizrClient``` instance. For example: - -```java -StructurizrClient structurizrClient = new StructurizrClient("key", "secret"); -structurizrClient.setEncryptionStrategy(new AesEncryptionStrategy("password")); -structurizrClient.putWorkspace(1234, workspace); -``` - -The default key size is 128 bits and the default iteration count is 1000. An alternative constructor for AesEncryptionStrategy takes the following parameters: - -- The key size (number of bits; e.g. 128, 192 or 256). -- The iteration count (used when generating keys). -- The passphrase. - -In addition, a random salt and initialization vector are generated automatically for you, using Java's ```SecureRandom``` class. - -See [ClientSideEncryption.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/ClientSideEncryption.java) for a full example, and [https://structurizr.com/share/41](https://structurizr.com/share/41) to access the workspace. \ No newline at end of file diff --git a/docs/component-diagram.md b/docs/component-diagram.md deleted file mode 100644 index 61d2b99c6..000000000 --- a/docs/component-diagram.md +++ /dev/null @@ -1,17 +0,0 @@ -# Component diagram - -Following on from a Container Diagram, next you can zoom in and decompose each container further to identify the major structural building blocks and their interactions. - -The Component diagram shows how a container is made up of a number of "components", what each of those components are, their responsibilities and the technology/implementation details. - -## Example - -This is an example Component diagram for a fictional Internet Banking System, showing some (rather than all) of the components within the API Application. Here, there are two Spring MVC Rest Controllers providing access points for the JSON/HTTPS API, with each controller subsequently using other components to access data from the Database and Mainframe Banking System. - -![An example Component diagram](images/component-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#Components](https://structurizr.com/share/36141#Components) for the diagram. - -### Extracting components automatically - -Please note that, in a real-world scenario, you would probably want to extract components automatically from a codebase with the [component finder](https://github.com/structurizr/java-extensions/blob/master/docs/component-finder.md), using static analysis and reflection techniques. \ No newline at end of file diff --git a/docs/container-diagram.md b/docs/container-diagram.md deleted file mode 100644 index 437c648a2..000000000 --- a/docs/container-diagram.md +++ /dev/null @@ -1,15 +0,0 @@ -# Container diagram - -Once you understand how your system fits in to the overall IT environment, a really useful next step is to zoom-in to the system boundary with a Container diagram. A "container" is something like a web application, desktop application, mobile app, database, file system, etc. Essentially, a container is a separately runnable/deployable unit that executes code or stores data. - -The Container diagram shows the high-level shape of the software architecture and how responsibilities are distributed across it. It also shows the major technology choices and how the containers communicate with one another. It's a simple, high-level technology focussed diagram that is useful for software developers and support/operations staff alike. - -## Example - -This is an example Container diagram for a fictional Internet Banking System. It shows that the Internet Banking System is made up of five containers: a server-side Web Application, a Single-Page Application, a Mobile App, a server-side API Application, and a Database. The Web Application is a Java/Spring MVC web application that simply serves static content (HTML, CSS and JavaScript), including the content that makes up the Single-Page Application. The Single-Page Application is an Angular application that runs in the customer's web browser, providing all of the Internet banking features. Alternatively, customers can use the cross-platform Xamarin Mobile App, to access a subset of the Internet banking functionality. - -Both the Single-Page Application and Mobile App use a JSON/HTTPS API, which is provided by another Java/Spring MVC application running on the server. The API Application gets user information from the Database (a relational database schema). The API Application also communicates with the existing Mainframe Banking System, using a propreitary XML/HTTPS interface, to get information about bank accounts or make transactions. The API Application also uses the existing E-mail System if it needs to send e-mails to customers. - -![An example Container diagram](images/container-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#Containers](https://structurizr.com/share/36141#Containers) for the diagram. \ No newline at end of file diff --git a/docs/corporate-branding.md b/docs/corporate-branding.md deleted file mode 100644 index d5bfb8874..000000000 --- a/docs/corporate-branding.md +++ /dev/null @@ -1,17 +0,0 @@ -# Corporate branding - -> Note: this page describes a feature that is not available to use with Structurizr's Free Plan. - -In addition to [styling diagram elements](styling-elements.md) and [relationships](styling-relationships.md), some corporate branding can be added to diagrams and documentation. This includes: - -- A font (font name and optional web font stylesheet URL). -- A logo (a URL to an image file or a data URI). - -You can add branding to an existing workspace, as follows: - -```java -Branding branding = views.getConfiguration().getBranding(); -branding.setLogo(ImageUtils.getImageAsDataUri(new File("./docs/images/structurizr-logo.png"))); -``` - -See [Help - Corporate Branding](https://structurizr.com/help/corporate-branding) for more details, [CorporateBranding.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/CorporateBranding.java) for a full example, and [https://structurizr.com/share/35031](https://structurizr.com/share/35031) to access the workspace. diff --git a/docs/decisions.md b/docs/decisions.md deleted file mode 100644 index bff92c907..000000000 --- a/docs/decisions.md +++ /dev/null @@ -1,7 +0,0 @@ -# Decisions - -Although architecture decisions can be included in supplementary documentation, Structurizr also provides support for publishing architecture decision records, [as described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). - -Decision records can either be created manually using the API on the [Documentation class](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/Documentation.java), or using the [AdrToolsImporter](https://github.com/structurizr/java-extensions/blob/master/structurizr-adr-tools/src/com/structurizr/documentation/AdrToolsImporter.java) to import ADRs from Nat Pryce's popular [adr-tools](https://github.com/npryce/adr-tools) tooling. Here is [an example](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/AdrTools.java). - -See [Structurizr - Decision Log](https://structurizr.com/help/decision-log) for more details. diff --git a/docs/deployment-diagram.md b/docs/deployment-diagram.md deleted file mode 100644 index 5abda112f..000000000 --- a/docs/deployment-diagram.md +++ /dev/null @@ -1,11 +0,0 @@ -# Deployment diagram - -A deployment diagram allows you to illustrate how containers in the static model are mapped to infrastructure. This deployment diagram is based upon a [UML deployment diagram](https://en.wikipedia.org/wiki/Deployment_diagram), although simplified slightly to show the mapping between containers and deployment nodes. A deployment node is something like physical infrastructure (e.g. a physical server or device), virtualised infrastructure (e.g. IaaS, PaaS, a virtual machine), containerised infrastructure (e.g. a Docker container), an execution environment (e.g. a database server, Java EE web/application server, Microsoft IIS), etc. Deployment nodes can be nested. - -## Example - -As an example, a Deployment diagram for the live environment of a simplified, fictional Internet Banking System might look something like this. In summary, it shows the deployment of the Web Application and the Database, with a secondary Database being used for failover purposes. - -![An example Deployment diagram](images/deployment-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#LiveDeployment](https://structurizr.com/share/36141#LiveDeployment) for the diagram. \ No newline at end of file diff --git a/docs/documentation-arc42.md b/docs/documentation-arc42.md deleted file mode 100644 index 99f2c1e0d..000000000 --- a/docs/documentation-arc42.md +++ /dev/null @@ -1,32 +0,0 @@ -# arc42 documentation template - -Structurizr for Java includes an implementation of the [arc42 documentation template](http://arc42.org), which can be used to document your software architecture. - -## Example - -To use this template, create an instance of the [Arc42DocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/Arc42DocumentationTemplate.java) class. -You can then add documentation sections as needed, each associated with a software system in your software architecture model, using Markdown or AsciiDoc. For example: - -```java -Arc42DocumentationTemplate template = new Arc42DocumentationTemplate(workspace); - -File documentationRoot = new File("./structurizr-examples/src/com/structurizr/example/documentation/arc42/markdown"); -template.addIntroductionAndGoalsSection(softwareSystem, new File(documentationRoot, "01-introduction-and-goals.md")); -template.addConstraintsSection(softwareSystem, new File(documentationRoot, "02-architecture-constraints.md")); -template.addContextAndScopeSection(softwareSystem, new File(documentationRoot, "03-system-scope-and-context.md")); -template.addSolutionStrategySection(softwareSystem, new File(documentationRoot, "04-solution-strategy.md")); -template.addBuildingBlockViewSection(softwareSystem, new File(documentationRoot, "05-building-block-view.md")); -template.addRuntimeViewSection(softwareSystem, new File(documentationRoot, "06-runtime-view.md")); -template.addDeploymentViewSection(softwareSystem, new File(documentationRoot, "07-deployment-view.md")); -template.addCrosscuttingConceptsSection(softwareSystem, new File(documentationRoot, "08-crosscutting-concepts.md")); -template.addArchitecturalDecisionsSection(softwareSystem, new File(documentationRoot, "09-architecture-decisions.md")); -template.addRisksAndTechnicalDebtSection(softwareSystem, new File(documentationRoot, "10-quality-requirements.md")); -template.addQualityRequirementsSection(softwareSystem, new File(documentationRoot, "11-risks-and-technical-debt.md")); -template.addGlossarySection(softwareSystem, new File(documentationRoot, "12-glossary.md")); -``` - -Structurizr will create navigation controls based upon the the sections in the documentation, and the software systems they have been associated with. See [Arc42DocumentationExample.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/Arc42DocumentationExample.java) for the full code, and [https://structurizr.com/share/27791/documentation](https://structurizr.com/share/27791/documentation) to see the rendered documentation. - -## More information - -See [Help - Documentation](https://structurizr.com/help/documentation) for more information about how headings are rendered, and how to embed diagrams from you workspace into the documentation. \ No newline at end of file diff --git a/docs/documentation-automatic.md b/docs/documentation-automatic.md deleted file mode 100644 index 7424fc5e9..000000000 --- a/docs/documentation-automatic.md +++ /dev/null @@ -1,22 +0,0 @@ -# Automatic documentation template - -Structurizr for Java includes an automatic documentation template, which will scan a given directory and automatically add all Markdown or AsciiDoc -files in that directory. Each file must represent a separate section, and the second level heading ("## Section Title" in Markdown and "== Section Title" in AsciiDoc) will be used as the section name. - -## Example - -To use this template, create an instance of the [AutomaticDocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/AutomaticDocumentationTemplate.java) class. -You can then add documentation sections as needed, each associated with a software system in your software architecture model, using Markdown or AsciiDoc. For example: - -```java -File documentationRoot = new File("./structurizr-examples/src/com/structurizr/example/documentation/automatic"); - -AutomaticDocumentationTemplate template = new AutomaticDocumentationTemplate(workspace); -template.addSections(softwareSystem, documentationRoot); -``` - -Structurizr will create navigation controls based upon the the sections in the documentation, and the software systems they have been associated with. See [AutomaticDocumentationTemplateExample.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/AutomaticDocumentationTemplateExample.java) for the full code, and [https://structurizr.com/share/35971/documentation](https://structurizr.com/share/35971/documentation) to see the rendered documentation. - -## More information - -See [Help - Documentation](https://structurizr.com/help/documentation) for more information about how headings are rendered, and how to embed diagrams from you workspace into the documentation. \ No newline at end of file diff --git a/docs/documentation-structurizr.md b/docs/documentation-structurizr.md deleted file mode 100644 index d6397403b..000000000 --- a/docs/documentation-structurizr.md +++ /dev/null @@ -1,32 +0,0 @@ -# Structurizr documentation template - -Structurizr for Java includes an implementation of the "software guidebook" from Simon Brown's [Software Architecture for Developers](https://leanpub.com/visualising-software-architecture) book, which can be used to document your software architecture. - -## Example - -To use this template, create an instance of the [StructurizrDocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/StructurizrDocumentationTemplate.java) class. -You can then add documentation sections as needed, each associated with a software system in your software architecture model, using Markdown or AsciiDoc. For example: - -```java -StructurizrDocumentationTemplate template = new StructurizrDocumentationTemplate(workspace); - -File documentationRoot = new File("./structurizr-examples/src/com/structurizr/example/documentation/structurizr/markdown"); -template.addContextSection(softwareSystem, new File(documentationRoot, "01-context.md")); -template.addFunctionalOverviewSection(softwareSystem, new File(documentationRoot, "02-functional-overview.md")); -template.addQualityAttributesSection(softwareSystem, new File(documentationRoot, "03-quality-attributes.md")); -template.addConstraintsSection(softwareSystem, new File(documentationRoot, "04-constraints.md")); -template.addPrinciplesSection(softwareSystem, new File(documentationRoot, "05-principles.md")); -template.addSoftwareArchitectureSection(softwareSystem, new File(documentationRoot, "06-software-architecture.md")); -template.addDataSection(softwareSystem, new File(documentationRoot, "07-data.md")); -template.addInfrastructureArchitectureSection(softwareSystem, new File(documentationRoot, "08-infrastructure-architecture.md")); -template.addDeploymentSection(softwareSystem, new File(documentationRoot, "09-deployment.md")); -template.addDevelopmentEnvironmentSection(softwareSystem, new File(documentationRoot, "10-development-environment.md")); -template.addOperationAndSupportSection(softwareSystem, new File(documentationRoot, "11-operation-and-support.md")); -template.addDecisionLogSection(softwareSystem, new File(documentationRoot, "12-decision-log.md")); -``` - -Structurizr will create navigation controls based upon the the sections in the documentation, and the software systems they have been associated with. See [StructurizrDocumentationExample.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/StructurizrDocumentationExample.java) for the full code, and [https://structurizr.com/share/14181/documentation](https://structurizr.com/share/14181/documentation) to see the rendered documentation. - -## More information - -See [Help - Documentation](https://structurizr.com/help/documentation) for more information about how headings are rendered, and how to embed diagrams from you workspace into the documentation. \ No newline at end of file diff --git a/docs/documentation-viewpoints-and-perspectives.md b/docs/documentation-viewpoints-and-perspectives.md deleted file mode 100644 index ab8c39b90..000000000 --- a/docs/documentation-viewpoints-and-perspectives.md +++ /dev/null @@ -1,27 +0,0 @@ -# Viewpoints and Perspectives documentation template - -Structurizr for Java includes an implementation of the [Viewpoints and Perspectives documentation template](http://www.viewpoints-and-perspectives.info), which can be used to document your software architecture. - -## Example - -To use this template, create an instance of the [ViewpointsAndPerspectivesDocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/ViewpointsAndPerspectivesDocumentationTemplate.java) class. -You can then add documentation sections as needed, each associated with a software system in your software architecture model, using Markdown or AsciiDoc. For example: - -```java -ViewpointsAndPerspectivesDocumentationTemplate template = new ViewpointsAndPerspectivesDocumentationTemplate(workspace); - -File documentationRoot = new File("./structurizr-examples/src/com/structurizr/example/documentation/viewpointsandperspectives/markdown"); -template.addIntroductionSection(softwareSystem, new File(documentationRoot, "01-introduction.md")); -template.addGlossarySection(softwareSystem, new File(documentationRoot, "02-glossary.md")); -template.addSystemStakeholdersAndRequirementsSection(softwareSystem, new File(documentationRoot, "03-system-stakeholders-and-requirements.md")); -template.addArchitecturalForcesSection(softwareSystem, new File(documentationRoot, "04-architectural-forces.md")); -template.addArchitecturalViewsSection(softwareSystem, new File(documentationRoot, "05-architectural-views")); -template.addSystemQualitiesSection(softwareSystem, new File(documentationRoot, "06-system-qualities.md")); -template.addAppendicesSection(softwareSystem, new File(documentationRoot, "07-appendices.md")); -``` - -Structurizr will create navigation controls based upon the the sections in the documentation, and the software systems they have been associated with. See [ViewpointsAndPerspectivesDocumentationExample.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/ViewpointsAndPerspectivesDocumentationExample.java) for the full code, and [https://structurizr.com/share/36371/documentation](https://structurizr.com/share/36371/documentation) to see the rendered documentation. - -## More information - -See [Help - Documentation](https://structurizr.com/help/documentation) for more information about how headings are rendered, and how to embed diagrams from you workspace into the documentation. \ No newline at end of file diff --git a/docs/documentation.md b/docs/documentation.md deleted file mode 100644 index 74869e488..000000000 --- a/docs/documentation.md +++ /dev/null @@ -1,53 +0,0 @@ -# Documentation - -In addition to diagrams, Structurizr lets you create supplementary documentation using the Markdown or AsciiDoc formats. - -![Example documentation](images/documentation-1.png) - -See [https://structurizr.com/share/31/documentation](https://structurizr.com/share/31/documentation) for an example. - -## Documentation templates - -The documentation is broken up into a number of sections, as defined by the template you are using, the following of which are included: - -- [Structurizr](documentation-structurizr.md) -- [arc42](documentation-arc42.md) -- [Viewpoints and Perspectives](documentation-viewpoints-and-perspectives.md) -- [Automatic template](documentation-automatic.md) - -## Custom sections - -You can add custom sections using the ```addSection``` method on the [DocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/DocumentationTemplate.java) class: - -```java -template.addSection(softwareSystem, "My custom section", Format.Markdown, ...); -``` - -## Images - -Images can be included using the regular Markdown/AsciiDoc syntax. - -![Including images](images/documentation-2.png) - -For this to work, the image files must be hosted externally (e.g. on your own web server, ideally accessible via HTTPS) or uploaded with your workspace using the ```addImages()``` or ```addImage()``` methods on the [DocumentationTemplate](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/documentation/DocumentationTemplate.java) class. - -```java -template.addImages(new File("...")); -``` - -See [functional-overview.md](https://raw.githubusercontent.com/structurizr/java/master/structurizr-examples/src/com/structurizr/example/financialrisksystem/functional-overview.md) and [FinancialRiskSystem](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/FinancialRiskSystem.java) for an example. - -## Embedding diagrams - -Software architecture diagrams from your workspace can be embedded within the documentation sections using an additional special syntax. - -![Embedding diagrams](images/documentation-3.png) - -The syntax is similar to that used for including images, for example: - -``` -Markdown - ![](embed:DiagramKey) -AsciiDoc - image::embed:DiagramKey[] -``` - -See [context.md](https://raw.githubusercontent.com/structurizr/java/master/structurizr-examples/src/com/structurizr/example/financialrisksystem/context.md), [context.adoc](https://raw.githubusercontent.com/structurizr/java/master/structurizr-examples/src/com/structurizr/example/financialrisksystem/context.adoc) and [FinancialRiskSystem](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/FinancialRiskSystem.java) for an example. \ No newline at end of file diff --git a/docs/dynamic-diagram.md b/docs/dynamic-diagram.md deleted file mode 100644 index 88374e8ea..000000000 --- a/docs/dynamic-diagram.md +++ /dev/null @@ -1,19 +0,0 @@ -# Dynamic diagram - -A simple dynamic diagram can be useful when you want to show how elements in a static model collaborate at runtime to implement a user story, use case, feature, etc. This dynamic diagram is based upon a [UML communication diagram](https://en.wikipedia.org/wiki/Communication_diagram) (previously known as a "UML collaboration diagram"). It is similar to a [UML sequence diagram](https://en.wikipedia.org/wiki/Sequence_diagram) although it allows a free-form arrangement of diagram elements with numbered interactions to indicate ordering. - -## Example - -As an example, a Dynamic diagram describing the customer sign in process for a simplified, fictional Internet Banking System might look something like this. In summary, it shows the components involved in the sign in process, and the interactions between them. - -![An example Dynamic diagram](images/dynamic-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#SignIn](https://structurizr.com/share/36141#SignIn) for the diagram. - -### Adding relationships - -In order to add a relationship between two elements to a dynamic view, that relationship must already exist between the two elements in the static view. - -### Parallel behaviour - -Showing parallel behaviour is also possible using the ```startParallelSequence()``` and ```endParallelSequence()``` methods on the ```DynamicView``` class. See [MicroservicesExample.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/MicroservicesExample.java) and [https://structurizr.com/share/4241#CustomerUpdateEvent](https://structurizr.com/share/4241#CustomerUpdateEvent) for an example. \ No newline at end of file diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index cae05f899..000000000 --- a/docs/faq.md +++ /dev/null @@ -1,13 +0,0 @@ -# Frequently asked questions - -## Why are many classes final with package-protected members, and not open to extension? - -First and foremost, this repo is a client library for the [Structurizr cloud service and on-premises installation](https://structurizr.com). It allows you to write Java code to create an in-memory object graph representing a software architecture model and views (a "workspace"), serialize that to JSON, and upload it via a web API. The workspace has an [OpenAPI definition](https://github.com/structurizr/json/blob/master/structurizr.yaml), but this library also implements a number of rules (think of them as the "business logic") to ensure that the workspace is valid. These rules include, for example, ensuring that all containers with a software system have unique names, and that you can't add components to a system context view. - -Removing the `final` modifier from the classes and leaving the them open for extension allows you to bypass/break these rules, which will likely lead to the serialized workspace definitions being incompatible with the Structurizr cloud service and on-premises installation. The output of this library also needs to be compatible with all of the other client libraries. - -You are welcome to fork this library for your own purposes. Alternatively, you can build a thin wrapper around the library, to provide your own custom functionality, or perhaps a more fluent API ... many teams have done this. - -## Can I submit a pull request? - -It depends on the nature of the change. Please open an issue first to discuss it. diff --git a/docs/filtered-views.md b/docs/filtered-views.md deleted file mode 100644 index 6e1f104dc..000000000 --- a/docs/filtered-views.md +++ /dev/null @@ -1,39 +0,0 @@ -# Filtered views - -A filtered view represents a view on top of another view, which can be used to include or exclude specific elements and/or relationships, based upon their tag. The benefit of using filtered views is that element and relationship positions are shared between the views. - -Filtered views can be created on top of static views only; i.e. System Landscape, System Context, Container and Component views. - -## Example - -As an example, let's imagine an organisation where a User uses Software System A for tasks 1 and 2. - -![A diagram showing the current state](images/filtered-views-1.png) - -And, in the future, Software System B will be introduced to fulfil task 2. - -![A diagram showing the future state](images/filtered-views-2.png) - -With Structurizr for Java, you can illustrate this by defining a single context diagram with two filtered views on top; one showing the current state and the other showing future state. - -```java -Person user = model.addPerson("User", "A description of the user."); -SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", "A description of software system A."); -SoftwareSystem softwareSystemB = model.addSoftwareSystem("Software System B", "A description of software system B."); -softwareSystemB.addTags(FUTURE_STATE); - -user.uses(softwareSystemA, "Uses for tasks 1 and 2").addTags(CURRENT_STATE); -user.uses(softwareSystemA, "Uses for task 1").addTags(FUTURE_STATE); -user.uses(softwareSystemB, "Uses for task 2").addTags(FUTURE_STATE); - -ViewSet views = workspace.getViews(); -SystemLandscapeView systemLandscapeView = views.createSystemLandscapeView("SystemLandscape", "An example System Landscape diagram."); -systemLandscapeView.addAllElements(); - -views.createFilteredView(systemLandscapeView, "CurrentState", "The current system landscape.", FilterMode.Exclude, FUTURE_STATE); -views.createFilteredView(systemLandscapeView, "FutureState", "The future state system landscape after Software System B is live.", FilterMode.Exclude, CURRENT_STATE); -``` - -In summary, you create a view with all of the elements and relationships that you want to show, and then create one or more filtered views on top, specifying the tags that you'd like to include or exclude. - -See [FilteredViews.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/FilteredViews.java) for the full code, and [https://structurizr.com/share/19911](https://structurizr.com/share/19911) for the diagram. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 43673ba73..000000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,94 +0,0 @@ -# Getting started - -Here is a quick overview of how to get started with Structurizr for Java so that you can create a software architecture model as code. You can find the code at [GettingStarted.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/GettingStarted.java) and the live example workspace at [https://structurizr.com/share/25441](https://structurizr.com/share/25441). - -> See the [java-quickstart project](https://github.com/structurizr/java-quickstart) for a quick and simple way to get started with Structurizr for Java. - -For more examples, please see [structurizr-examples](https://github.com/structurizr/java/tree/master/structurizr-examples/src/com/structurizr/example). - -## 1. Dependencies - -The Structurizr for Java binaries are hosted on [Maven Central](https://repo1.maven.org/maven2/com/structurizr/) and the dependencies for use with Maven, Ivy, Gradle, etc are as follows. - -Name | Description ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- -com.structurizr:structurizr-client:1.12.1 | The Structurizr API client library. - -## 2. Create a Java program - -The software architecture model is going to be created by a short Java program, so we'll need to start by creating a new Java class, with a ```main``` method as follows: - -```java -public class GettingStarted { - - public static void main(String[] args) throws Exception { - // all of the Structurizr code will go here - } - -} -``` - -## 3. Create a model - -The first step is to create a workspace in which the software architecture model will reside. - -```java -Workspace workspace = new Workspace("Getting Started", "This is a model of my software system."); -Model model = workspace.getModel(); -``` - -Now let's add some elements to the model to describe a user using a software system. - -```java -Person user = model.addPerson("User", "A user of my software system."); -SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "My software system."); -user.uses(softwareSystem, "Uses"); -``` - -## 4. Create some views - -With the model created, we need to create some views with which to visualise it. - -```java -ViewSet views = workspace.getViews(); -SystemContextView contextView = views.createSystemContextView(softwareSystem, "SystemContext", "An example of a System Context diagram."); -contextView.addAllSoftwareSystems(); -contextView.addAllPeople(); -``` - -## 5. Add some colour and shapes - -Optionally, elements and relationships can be styled by specifying colours, sizes and shapes. - -```java -Styles styles = views.getConfiguration().getStyles(); -styles.addElementStyle(Tags.SOFTWARE_SYSTEM).background("#1168bd").color("#ffffff"); -styles.addElementStyle(Tags.PERSON).background("#08427b").color("#ffffff").shape(Shape.Person); -``` - -## 6. Upload to Structurizr - -Structurizr provides a web API to get and put workspaces, and an API client is provided by the ```StructurizrClient``` class. - -```java -StructurizrClient structurizrClient = new StructurizrClient("key", "secret"); -structurizrClient.putWorkspace(25441, workspace); -``` - -> In order to upload your model to Structurizr using the web API, you'll need to [sign up for free](https://structurizr.com/signup) to get your own API key and secret. See [Structurizr - Workspaces](https://structurizr.com/help/workspaces) for information about finding your workspace ID, API key and secret. - -## 7. Open the workspace in Structurizr - -Once you've run your program to create and upload the workspace, you can now sign in to your Structurizr account, and open the workspace from [your dashboard](https://structurizr.com/dashboard). The result should be a diagram like this: - -![Getting Started with Structurizr for Java](images/getting-started-1.png) - -By default, Structurizr does not auto-layout your diagram elements. The diagram layout can be modified by dragging the elements around the diagram canvas in the diagram editor, and the layout saved using the "Save workspace" button. See [Structurizr - Help - Diagram editor](https://structurizr.com/help/diagram-editor) for more information. - -![Getting Started with Structurizr for Java](images/getting-started-2.png) - -A diagram key is automatically generated based upon the styles in the model. Click the "i" button on the toolbar (or press the 'i' key) to display the diagram key. - -![A diagram key](images/getting-started-diagram-key.png) - -When you upload a new version of the same workspace, the Structurizr client will try to retain the diagram layout information. See [API client](api-client.md) for more details. diff --git a/docs/health-checks.md b/docs/health-checks.md deleted file mode 100644 index 6cf5f1b5a..000000000 --- a/docs/health-checks.md +++ /dev/null @@ -1,7 +0,0 @@ -# HTTP-based health checks - -Structurizr's health checks feature allows you to supplement your deployment models with HTTP-based health checks to get an "at a glance" view of the health of your software systems. See [Structurizr - Health Checks](https://structurizr.com/help/health-checks) for more details. - -When defining your software architecture model using the client library, HTTP-based health checks can be added to the Container Instances in your deployment model. Each health check is defined by a name, an endpoint URL, a polling interval (e.g. 60 seconds), a timeout (e.g. 1000 milliseconds), and optionally one or more HTTP headers. - -[HttpHealthChecks.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/HttpHealthChecks.java) shows an example of how to setup the health checks, and the result can be see online at [https://structurizr.com/share/39441/health](https://structurizr.com/share/39441/health). \ No newline at end of file diff --git a/docs/images/component-diagram-1.png b/docs/images/component-diagram-1.png deleted file mode 100644 index a4046f7e8..000000000 Binary files a/docs/images/component-diagram-1.png and /dev/null differ diff --git a/docs/images/container-diagram-1.png b/docs/images/container-diagram-1.png deleted file mode 100644 index 7ae653de8..000000000 Binary files a/docs/images/container-diagram-1.png and /dev/null differ diff --git a/docs/images/deployment-diagram-1.png b/docs/images/deployment-diagram-1.png deleted file mode 100644 index cb8600ad7..000000000 Binary files a/docs/images/deployment-diagram-1.png and /dev/null differ diff --git a/docs/images/documentation-1.png b/docs/images/documentation-1.png deleted file mode 100644 index 4b354c868..000000000 Binary files a/docs/images/documentation-1.png and /dev/null differ diff --git a/docs/images/documentation-2.png b/docs/images/documentation-2.png deleted file mode 100644 index fb87ad77e..000000000 Binary files a/docs/images/documentation-2.png and /dev/null differ diff --git a/docs/images/documentation-3.png b/docs/images/documentation-3.png deleted file mode 100644 index c15564c1c..000000000 Binary files a/docs/images/documentation-3.png and /dev/null differ diff --git a/docs/images/dynamic-diagram-1.png b/docs/images/dynamic-diagram-1.png deleted file mode 100644 index a50cee470..000000000 Binary files a/docs/images/dynamic-diagram-1.png and /dev/null differ diff --git a/docs/images/filtered-views-1.png b/docs/images/filtered-views-1.png deleted file mode 100644 index 8212d29e9..000000000 Binary files a/docs/images/filtered-views-1.png and /dev/null differ diff --git a/docs/images/filtered-views-2.png b/docs/images/filtered-views-2.png deleted file mode 100644 index 8d8331658..000000000 Binary files a/docs/images/filtered-views-2.png and /dev/null differ diff --git a/docs/images/getting-started-1.png b/docs/images/getting-started-1.png deleted file mode 100644 index 1d57f32f8..000000000 Binary files a/docs/images/getting-started-1.png and /dev/null differ diff --git a/docs/images/getting-started-2.png b/docs/images/getting-started-2.png deleted file mode 100644 index 43cb96307..000000000 Binary files a/docs/images/getting-started-2.png and /dev/null differ diff --git a/docs/images/getting-started-diagram-key.png b/docs/images/getting-started-diagram-key.png deleted file mode 100644 index fbe246910..000000000 Binary files a/docs/images/getting-started-diagram-key.png and /dev/null differ diff --git a/docs/images/implied-relationships-1.png b/docs/images/implied-relationships-1.png deleted file mode 100644 index dd2f86f08..000000000 Binary files a/docs/images/implied-relationships-1.png and /dev/null differ diff --git a/docs/images/readme-1.png b/docs/images/readme-1.png deleted file mode 100644 index 40abf1645..000000000 Binary files a/docs/images/readme-1.png and /dev/null differ diff --git a/docs/images/structurizr-banner.png b/docs/images/structurizr-banner.png deleted file mode 100644 index a9a0b0abc..000000000 Binary files a/docs/images/structurizr-banner.png and /dev/null differ diff --git a/docs/images/structurizr-logo.png b/docs/images/structurizr-logo.png deleted file mode 100644 index 9324ae8dc..000000000 Binary files a/docs/images/structurizr-logo.png and /dev/null differ diff --git a/docs/images/structurizr-overview.png b/docs/images/structurizr-overview.png deleted file mode 100644 index 4d2746502..000000000 Binary files a/docs/images/structurizr-overview.png and /dev/null differ diff --git a/docs/images/styling-elements-1.png b/docs/images/styling-elements-1.png deleted file mode 100644 index 661a3e857..000000000 Binary files a/docs/images/styling-elements-1.png and /dev/null differ diff --git a/docs/images/styling-elements-2.png b/docs/images/styling-elements-2.png deleted file mode 100644 index 27abd955d..000000000 Binary files a/docs/images/styling-elements-2.png and /dev/null differ diff --git a/docs/images/styling-elements-3.png b/docs/images/styling-elements-3.png deleted file mode 100644 index 9fe17c82c..000000000 Binary files a/docs/images/styling-elements-3.png and /dev/null differ diff --git a/docs/images/styling-elements-4.png b/docs/images/styling-elements-4.png deleted file mode 100644 index 58ef6d5d9..000000000 Binary files a/docs/images/styling-elements-4.png and /dev/null differ diff --git a/docs/images/styling-elements-5.png b/docs/images/styling-elements-5.png deleted file mode 100644 index 9fb1cace7..000000000 Binary files a/docs/images/styling-elements-5.png and /dev/null differ diff --git a/docs/images/styling-elements-6.png b/docs/images/styling-elements-6.png deleted file mode 100644 index 4cc44cd5b..000000000 Binary files a/docs/images/styling-elements-6.png and /dev/null differ diff --git a/docs/images/styling-relationships-1.png b/docs/images/styling-relationships-1.png deleted file mode 100644 index 2e9643205..000000000 Binary files a/docs/images/styling-relationships-1.png and /dev/null differ diff --git a/docs/images/styling-relationships-2.png b/docs/images/styling-relationships-2.png deleted file mode 100644 index 2bbc6d600..000000000 Binary files a/docs/images/styling-relationships-2.png and /dev/null differ diff --git a/docs/images/styling-relationships-3.png b/docs/images/styling-relationships-3.png deleted file mode 100644 index ab842e5cb..000000000 Binary files a/docs/images/styling-relationships-3.png and /dev/null differ diff --git a/docs/images/styling-relationships-4.png b/docs/images/styling-relationships-4.png deleted file mode 100644 index 1bd7f7a6b..000000000 Binary files a/docs/images/styling-relationships-4.png and /dev/null differ diff --git a/docs/images/system-context-diagram-1.png b/docs/images/system-context-diagram-1.png deleted file mode 100644 index 813bd12b6..000000000 Binary files a/docs/images/system-context-diagram-1.png and /dev/null differ diff --git a/docs/images/system-landscape-diagram-1.png b/docs/images/system-landscape-diagram-1.png deleted file mode 100644 index 649e05fcc..000000000 Binary files a/docs/images/system-landscape-diagram-1.png and /dev/null differ diff --git a/docs/implied-relationships.md b/docs/implied-relationships.md deleted file mode 100644 index baff56730..000000000 --- a/docs/implied-relationships.md +++ /dev/null @@ -1,93 +0,0 @@ -# Implied relationships - -By default, the Structurizr for Java client library will not create implied relationships. For example, let's say that you have two components in different containers, and you create a relationship between them. - -``` -SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); -Container container1 = softwareSystem.addContainer("Container 1", "", ""); -Component component1 = container1.addComponent("Component 1", "", ""); - -Container container2 = softwareSystem.addContainer("Container 2", "", ""); -Component component2 = container2.addComponent("Component 2", "", ""); - -component1.uses(component2, "Sends data X to"); -``` - -At this point, the model contains a single relationship between the two components, but there are three other implied relationships that could be added: - -![Implied relationships](images/implied-relationships-1.png) - -- Container 1 Sends data X to Component 2 -- Component 1 Sends data X to Container 2 -- Container 1 Sends data X to Container 2 - -To have the client library create these for you, set an ```ImpliedRelationshipsStrategy``` implementation on your model. Possible implementations are as follows. - -## DefaultImpliedRelationshipsStrategy - -This strategy does not create any implied relationships. - -``` -SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); -Container container1 = softwareSystem.addContainer("Container 1", "", ""); -Component component1 = container1.addComponent("Component 1", "", ""); - -Container container2 = softwareSystem.addContainer("Container 2", "", ""); -Component component2 = container2.addComponent("Component 2", "", ""); - -model.setImpliedRelationshipsStrategy(new DefaultImpliedRelationshipsStrategy()); // default -component1.uses(component2, "Sends data X to"); -``` - -Relationships that exist in the model: - -- Component 1 Sends data X to Component 2 - -## CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy - -This strategy creates implied relationships between all valid combinations of the parent elements, unless the same relationship already exists between them. - -``` -SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); -Container container1 = softwareSystem.addContainer("Container 1", "", ""); -Component component1 = container1.addComponent("Component 1", "", ""); - -Container container2 = softwareSystem.addContainer("Container 2", "", ""); -Component component2 = container2.addComponent("Component 2", "", ""); - -model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); -component1.uses(component2, "Sends data X to"); -``` - -Relationships that exist in the model: - -- Component 1 Sends data X to Component 2 -- Component 1 Sends data X to Container 2 -- Container 1 Sends data X to Component 2 -- Container 1 Sends data X to Container 2 - -## CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy - -This strategy creates implied relationships between all valid combinations of the parent elements, unless *any* relationship already exists between them. - -``` -SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); -Container container1 = softwareSystem.addContainer("Container 1", "", ""); -Component component1 = container1.addComponent("Component 1", "", ""); - -Container container2 = softwareSystem.addContainer("Container 2", "", ""); -Component component2 = container2.addComponent("Component 2", "", ""); - -model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); -container1.uses(container2, "Sends data to"); -component1.uses(component2, "Sends data X to"); -``` - -Relationships that exist in the model: - -- Component 1 Sends data X to Component 2 -- Component 1 Sends data X to Container 2 -- Container 1 Sends data X to Component 2 -- Container 1 Sends data to Container 2 - -This strategy is useful when you want to show a summary relationship at higher levels in the model, especially when multiple implied relationships could be created between elements. \ No newline at end of file diff --git a/docs/model.md b/docs/model.md deleted file mode 100644 index 7c5999e19..000000000 --- a/docs/model.md +++ /dev/null @@ -1,24 +0,0 @@ -# Model - -This is the definition of the software architecture model, consisting of people, software systems, containers, components, code elements and deployment nodes, plus the relationships between them. - -All of the Java classes representing people, software systems, containers, components, etc, and the functionality related to creating a software architecture model can be found in the [com.structurizr.model](https://github.com/structurizr/java/tree/master/structurizr-core/src/com/structurizr/model) package. - -An empty model is created for you when you create a workspace. - -```java -Workspace workspace = new Workspace("Getting Started", "This is a model of my software system."); -Model model = workspace.getModel(); -``` - -Once you have a reference to a ```Model``` instance, you can add elements to it manually or automatically, using static analysis and reflection techniques. - -## 1. Manual model creation - -Manually adding elements to the model is the simplest way to use the Structurizr for Java client library. This can be done using the various public ```add*``` methods that you'll find on ```Model```, ```SoftwareSystem```, ```Container```, ```Component```, etc. - -## 2. Automatic extraction - -You can also extract components (and add them to a ```Container``` instance) automatically from a given codebase, using a number of different component finder strategies. See [Component finder](https://github.com/structurizr/java-extensions/blob/master/docs/component-finder.md) for more details. - -Although there is nothing included in the Structurizr for Java library to support this, you could also choose to parse an external definition of your software architecture (e.g. an AWS infrastructure topology, another Architecture Description Language, etc) and create model elements accordingly. \ No newline at end of file diff --git a/docs/styling-elements.md b/docs/styling-elements.md deleted file mode 100644 index d8e7674f3..000000000 --- a/docs/styling-elements.md +++ /dev/null @@ -1,80 +0,0 @@ -# Styling elements - -By default, all model elements are rendered as grey boxes, as illustrated by the example diagram below. - -![Default styling](images/styling-elements-1.png) - -However, the following characteristics of the elements can be customized: - -- Width (pixels) -- Height (pixels) -- Background colour (HTML hex value) -- Text colour (HTML hex value) -- Font size (pixels) -- Shape (see the [Shape](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/Shape.java) enum) -- Border (Solid or Dashed; see the [Border](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/Border.java) enum) -- Opacity (an integer between 0 and 100) - -## Tagging elements - -All elements within a software architecture model can have one or more tags associated with them. A tag is simply a free-format string. By default, the Java client library adds the following tags to elements. - -Element | Tags -------- | ---- -Software System | "Element", "Software System" -Person | "Element", "Person" -Container | "Element", "Container" -Component | "Element", "Component" - -All of these tags are defined as constants in the [Tags](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/model/Tags.java) class. As we'll see shortly, you can also add your own custom tags to elements using the ```addTags()``` method on the element. - -## Colour - -To style an element, simply create an [ElementStyle](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/ElementStyle.java) for a particular tag and specify the characteristics that you would like to change. For example, you can change the colour of all elements as follows. - -```java -Styles styles = workspace.getViews().getConfiguration().getStyles(); -styles.addElementStyle(Tags.ELEMENT).background("#438dd5").color("#ffffff"); -``` - - ![Colouring all elements](images/styling-elements-2.png) - -You can also change the colour of specific elements, for example based upon their type, as follows. - -```java -styles.addElementStyle(Tags.ELEMENT).color("#ffffff"); -styles.addElementStyle(Tags.PERSON).background("#08427b"); -styles.addElementStyle(Tags.CONTAINER).background("#438dd5"); -``` - -![Colouring elements based upon type](images/styling-elements-3.png) - -> If you're looking for a colour scheme for your diagrams, try the [Adobe Color Wheel](https://color.adobe.com/create/color-wheel/) or [Paletton](http://paletton.com). - -## Shapes - -You can also style elements using different shapes as follows. - -```java -styles.addElementStyle(Tags.ELEMENT).color("#ffffff"); -styles.addElementStyle(Tags.PERSON).background("#08427b").shape(Shape.Person); -styles.addElementStyle(Tags.CONTAINER).background("#438dd5"); -database.addTags("Database"); -styles.addElementStyle("Database").shape(Shape.Cylinder); -``` - -![Adding some shapes](images/styling-elements-4.png) - -As with CSS, styles cascade according to the order in which they are added. In the example above, the database element is coloured using the "Container" style, the shape of which is overriden by the "Database" style. - -The set of available shapes is as follows: - -![The shapes available in Structurizr](images/styling-elements-5.png) - -## Diagram key - -Structurizr will automatically add all element styles to a diagram key, showing you which styles are associated with which tags. - -![The diagram key](images/styling-elements-6.png) - -You can find the code for this example at [StylingElements.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/StylingElements.java) and the live example workspace at [https://structurizr.com/share/36111](https://structurizr.com/share/36111). \ No newline at end of file diff --git a/docs/styling-relationships.md b/docs/styling-relationships.md deleted file mode 100644 index 942a1c179..000000000 --- a/docs/styling-relationships.md +++ /dev/null @@ -1,51 +0,0 @@ -# Styling relationships - -By default, all relationships are rendered as dashed grey lines as shown in the example diagram below. - -![Default styling](images/styling-relationships-1.png) - -However, the following characteristics of the relationships can be customized: - -- Line thickness (pixels) -- Colour (HTML hex value) -- Dashed (true or false) -- Routing (Direct or Orthogonal; see the [Routing](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/Routing.java) enum) -- Font size (pixels) -- Width (of the description, in pixels) -- Position (of the description along the line, as a percentage from start to end) -- Opacity (an integer between 0 and 100) - -## Tagging relationships - -All relationships within a software architecture model can have one or more tags associated with them. A tag is simply a free-format string. By default, the Java client library adds the ```"Relationship"``` tag to relationships. As we'll see shortly, you can add your own custom tags to relationships using the ```addTags()``` method on the relationship. - -## Colour - -To style a relationship, simply create a [RelationshipStyle](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/RelationshipStyle.java) for a particular tag and specify the characteristics that you would like to change. For example, you can change the colour of all relationships as follows. - -```java -Styles styles = workspace.getViews().getConfiguration().getStyles(); -styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); -``` - -![Colouring all relationships](images/styling-relationships-2.png) - -You can also change the colour of specific relationships, based upon their tag, as follows. - -```java -model.getRelationships().stream().filter(r -> "HTTPS".equals(r.getTechnology())).forEach(r -> r.addTags("HTTPS")); -model.getRelationships().stream().filter(r -> "JDBC".equals(r.getTechnology())).forEach(r -> r.addTags("JDBC")); -styles.addRelationshipStyle("HTTPS").color("#ff0000"); -styles.addRelationshipStyle("JDBC").color("#0000ff"); -``` - -![Colouring relationships based upon tag](images/styling-relationships-3.png) - -## Diagram key - -Structurizr will automatically add all relationship styles to a diagram key. - -![The diagram key](images/styling-relationships-4.png) - - -You can find the code for this example at [StylingRelationships.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/StylingRelationships.java) and the live example workspace at [https://structurizr.com/share/36131](https://structurizr.com/share/36131). \ No newline at end of file diff --git a/docs/system-context-diagram.md b/docs/system-context-diagram.md deleted file mode 100644 index 25331aade..000000000 --- a/docs/system-context-diagram.md +++ /dev/null @@ -1,13 +0,0 @@ -# System Context diagram - -A System Context diagram is a good starting point for diagramming and documenting a software system, allowing you to step back and see the big picture. Draw a diagram showing your system as a box in the centre, surrounded by its users and the other systems that it interacts with. - -Detail isn't important here as this is your zoomed out view showing a big picture of the system landscape. The focus should be on people (actors, roles, personas, etc) and software systems rather than technologies, protocols and other low-level details. It's the sort of diagram that you could show to non-technical people. - -## Example - -This is an example System Context diagram for a fictional Internet Banking System. It shows the people who use it, and the other software systems that the Internet Banking System has a relationship with. Personal Customers of the bank use the Internet Banking System to view information about their bank accounts, and to make payments. The Internet Banking System itself uses the bank's existing Mainframe Banking System to do this, and uses the bank's existing E-mail System to send e-mails to customers. - -![An example System Context diagram](images/system-context-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#SystemContext](https://structurizr.com/share/36141#SystemContext) for the diagram. \ No newline at end of file diff --git a/docs/system-landscape-diagram.md b/docs/system-landscape-diagram.md deleted file mode 100644 index aed32f0a9..000000000 --- a/docs/system-landscape-diagram.md +++ /dev/null @@ -1,13 +0,0 @@ -# System Landscape diagram - -The C4 model provides a static view of a single software system but, in the real-world, software systems never live in isolation. For this reason, and particularly if you are responsible for a collection of software systems, it's often useful to understand how all of these software systems fit together within the bounds of an enterprise. To do this, simply add another diagram that sits "on top" of the C4 diagrams, to show the system landscape from an IT perspective. Like the System Context diagram, this diagram can show the organisational boundary, internal/external users and internal/external systems. - -Essentially this is a high-level map of the software systems at the enterprise level, with a C4 drill-down for each software system of interest. From a practical perspective, a system landscape diagram is really just a system context diagram without a specific focus on a particular software system. - -## Example - -As an example, a System Landscape diagram for a simplified, fictional bank might look something like this. - -![An example System Landscape diagram](images/system-landscape-diagram-1.png) - -See [BigBankPlc.java](https://github.com/structurizr/java/blob/master/structurizr-examples/src/com/structurizr/example/BigBankPlc.java) for the code, and [https://structurizr.com/share/36141#SystemLandscape](https://structurizr.com/share/36141#SystemLandscape) for the diagram. \ No newline at end of file diff --git a/docs/usage-patterns.md b/docs/usage-patterns.md deleted file mode 100644 index a2958b185..000000000 --- a/docs/usage-patterns.md +++ /dev/null @@ -1,18 +0,0 @@ -# Usage patterns - -## Single program - -The simplest way to create a software architecture model is to write a single Java program that first creates the model elements (people, software systems, containers and components) before subsequently creating the required views and uploading the workspace to Structurizr. If you have a particularly large model, or you'd like to share common elements between models, then one approach is to modularise your program appropriately, perhaps using something like [Structurizr Extensions](https://github.com/Catalysts/structurizr-extensions). - -## Multiple programs - -Another approach is to write a collection of Java programs, that are each responsible for creating a different part of the software architecture model. This is especially useful if you need to use the component finder with different classpaths. You can then write a script, or use your build script, to run these Java programs in sequence. Intermediate versions of the workspace can be saved to and loaded from disk using the [WorkspaceUtils](https://github.com/structurizr/java/blob/master/structurizr-client/src/com/structurizr/util/WorkspaceUtils.java) class. For example: - -1. Program 1: Create the basic model elements (people, software systems and containers) and the relationships between them. -2. Program 2: Add components for container 1 (e.g. run the component finder). -3. Program 3: Add components for container 2 (e.g. run the component finder with a different classpath). -4. Program 4: Create views. -5. Program 5: Add styling. -6. Program 6: Upload to Structurizr. - -In this example, the first program would write the initial version of the workspace to a local file on disk, which subsequent programs then load and add to, before writing the workspace back to disk. diff --git a/docs/views.md b/docs/views.md deleted file mode 100644 index 035a6fd8f..000000000 --- a/docs/views.md +++ /dev/null @@ -1,24 +0,0 @@ -# Views - -Once you've [added elements to a model](model.md), you can create one or more views to visualise parts of the model, which can subsequently be rendered as diagrams by a number of different tools. - -Structurizr for Java supports all of the view types described in the [C4 model](https://c4model.com), and the Java classes implementing these views can be found in the [com.structurizr.view](https://github.com/structurizr/java/tree/master/structurizr-core/src/com/structurizr/view) package as follows: - -* [SystemContextView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/SystemContextView.java) -* [ContainerView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/ContainerView.java) -* [ComponentView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/ComponentView.java) -* [SystemLandscapeView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/SystemLandscapeView.java) -* [DynamicView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/DynamicView.java) -* [DeploymentView](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/DeploymentView.java) - -## Creating views - -All views are associated with a [ViewSet](https://github.com/structurizr/java/blob/master/structurizr-core/src/com/structurizr/view/ViewSet.java), which is created for you when you create a workspace. - -```java -Workspace workspace = new Workspace("Getting Started", "This is a model of my software system."); -ViewSet views = workspace.getViews(); -``` - -Use the various ```create*View``` methods on the ```ViewSet``` class to create views. - diff --git a/gradle.properties b/gradle.properties index fe55ae08c..8e1b695b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,4 @@ signing.secretKeyRingFile=/some/path ossrhUsername=username ossrhPassword=password +version=5.0.3 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aa991fcea..3994438e2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/logging.properties b/logging.properties new file mode 100644 index 000000000..f6c3fb46c --- /dev/null +++ b/logging.properties @@ -0,0 +1,12 @@ +# Logging +handlers = java.util.logging.ConsoleHandler +.level = INFO + +java.util.logging.SimpleFormatter.format=%2$s: %5$s%n + +java.util.logging.ConsoleHandler.level = ALL + +com.structurizr.component.ComponentFinder.level = ALL +com.structurizr.component.TypeFinder.level = ALL +com.structurizr.component.TypeDependencyFinder.level = ALL +com.structurizr.component.ComponentFinderStrategy.level = ALL diff --git a/settings.gradle b/settings.gradle index 8872b5782..4226e5c13 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,11 @@ -rootProject.name = 'structurizr' +rootProject.name = 'structurizr-java' +include 'structurizr-annotation' +include 'structurizr-autolayout' include 'structurizr-client' -include 'structurizr-core' \ No newline at end of file +include 'structurizr-component' +include 'structurizr-core' +include 'structurizr-dsl' +include 'structurizr-export' +include 'structurizr-import' +include 'structurizr-inspection' \ No newline at end of file diff --git a/structurizr-annotation/README.md b/structurizr-annotation/README.md new file mode 100644 index 000000000..d57047c20 --- /dev/null +++ b/structurizr-annotation/README.md @@ -0,0 +1,10 @@ +# structurizr-annotation + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-annotation.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-annotation) + +This library defines some custom annotations that you can add to your code. +These serve to either make it explicit how components should be extracted from your codebase (e.g. `@Component`), +or they help supplement the software architecture model (e.g. `@Property`, `@Tag`). + +- This library has no dependencies. +- All annotations have a runtime retention policy, so they will be present in the compiled bytecode. \ No newline at end of file diff --git a/structurizr-annotation/build.gradle b/structurizr-annotation/build.gradle new file mode 100644 index 000000000..0ce6a1661 --- /dev/null +++ b/structurizr-annotation/build.gradle @@ -0,0 +1,3 @@ +dependencies { + +} \ No newline at end of file diff --git a/structurizr-annotation/src/main/java/com/structurizr/annotation/Component.java b/structurizr-annotation/src/main/java/com/structurizr/annotation/Component.java new file mode 100644 index 000000000..12fc05230 --- /dev/null +++ b/structurizr-annotation/src/main/java/com/structurizr/annotation/Component.java @@ -0,0 +1,12 @@ +package com.structurizr.annotation; + +import java.lang.annotation.*; + +/** + * A type-level annotation that can be used to indicate the type represents a component. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Component { +} \ No newline at end of file diff --git a/structurizr-annotation/src/main/java/com/structurizr/annotation/Properties.java b/structurizr-annotation/src/main/java/com/structurizr/annotation/Properties.java new file mode 100644 index 000000000..b477c2dd1 --- /dev/null +++ b/structurizr-annotation/src/main/java/com/structurizr/annotation/Properties.java @@ -0,0 +1,15 @@ +package com.structurizr.annotation; + +import java.lang.annotation.*; + +/** + * A wrapper for @Property annotations. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Properties { + + Property[] value(); + +} \ No newline at end of file diff --git a/structurizr-annotation/src/main/java/com/structurizr/annotation/Property.java b/structurizr-annotation/src/main/java/com/structurizr/annotation/Property.java new file mode 100644 index 000000000..405f62414 --- /dev/null +++ b/structurizr-annotation/src/main/java/com/structurizr/annotation/Property.java @@ -0,0 +1,17 @@ +package com.structurizr.annotation; + +import java.lang.annotation.*; + +/** + * A type-level annotation that can be used to add a name-value property to the model element represented by the type. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(Properties.class) +public @interface Property { + + String name(); + String value(); + +} \ No newline at end of file diff --git a/structurizr-annotation/src/main/java/com/structurizr/annotation/Tag.java b/structurizr-annotation/src/main/java/com/structurizr/annotation/Tag.java new file mode 100644 index 000000000..5da192346 --- /dev/null +++ b/structurizr-annotation/src/main/java/com/structurizr/annotation/Tag.java @@ -0,0 +1,16 @@ +package com.structurizr.annotation; + +import java.lang.annotation.*; + +/** + * A type-level annotation that can be used to add a tag to the model element represented by the type. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(Tags.class) +public @interface Tag { + + String name(); + +} \ No newline at end of file diff --git a/structurizr-annotation/src/main/java/com/structurizr/annotation/Tags.java b/structurizr-annotation/src/main/java/com/structurizr/annotation/Tags.java new file mode 100644 index 000000000..0af4f4b2f --- /dev/null +++ b/structurizr-annotation/src/main/java/com/structurizr/annotation/Tags.java @@ -0,0 +1,15 @@ +package com.structurizr.annotation; + +import java.lang.annotation.*; + +/** + * A wrapper for @Tag annotations. + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Tags { + + Tag[] value(); + +} \ No newline at end of file diff --git a/structurizr-autolayout/README.md b/structurizr-autolayout/README.md new file mode 100644 index 000000000..92e58e3e6 --- /dev/null +++ b/structurizr-autolayout/README.md @@ -0,0 +1,26 @@ +# structurizr-autolayout + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-autolayout.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-autolayout) + +This library provides automatic facilities for Structurizr views. +It's a wrapper around the [Graphviz tool](http://www.graphviz.org), +which allows you to apply the Graphviz layout algorithm to the views in a Structurizr workspace. + +> You will need Graphviz installed. + +For example: + +```java +Workspace workspace = ... + +GraphvizAutomaticLayout graphviz = new GraphvizAutomaticLayout(); +graphviz.apply(workspace); +``` + +The ```structurizr-autolayout``` library does the following for every view in the workspace: + +1. Export the view to a DOT file. +2. Run Graphviz (via the ```dot``` command), with the output format set to SVG. +3. Parse the generated SVG to extract layout information, and apply this to the Structurizr view (element x,y positions, relationship vertices, and paper size). + +Once the layout has been applied, you can upload your workspace to the Structurizr cloud service/on-premises installation as usual. diff --git a/structurizr-autolayout/build.gradle b/structurizr-autolayout/build.gradle new file mode 100644 index 000000000..23decccd6 --- /dev/null +++ b/structurizr-autolayout/build.gradle @@ -0,0 +1,9 @@ +dependencies { + + api project(':structurizr-export') + + testImplementation project(':structurizr-client') + +} + +description = 'Automatic layout facilities for Structurizr views' \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/Constants.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/Constants.java new file mode 100644 index 000000000..34cf8efc5 --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/Constants.java @@ -0,0 +1,17 @@ +package com.structurizr.autolayout.graphviz; + +/** + * Some constants used when applying graphviz. + */ +class Constants { + + // diagrams created by the Structurizr cloud service/on-premises installation/Lite are sized for 300dpi + static final double STRUCTURIZR_DPI = 300.0; + + // graphviz uses 72dpi by default + private static final double GRAPHVIZ_DPI = 72.0; + + // this is needed to convert coordinates provided by graphviz, to those used by Structurizr + static final double DPI_RATIO = STRUCTURIZR_DPI / GRAPHVIZ_DPI; + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTDiagram.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTDiagram.java new file mode 100644 index 000000000..0e2cc9e7b --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTDiagram.java @@ -0,0 +1,17 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.export.Diagram; +import com.structurizr.view.ModelView; + +class DOTDiagram extends Diagram { + + DOTDiagram(ModelView view, String definition) { + super(view, definition); + } + + @Override + public String getFileExtension() { + return "dot"; + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTExporter.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTExporter.java new file mode 100644 index 000000000..783ce7967 --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/DOTExporter.java @@ -0,0 +1,223 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.export.AbstractDiagramExporter; +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.DeploymentView; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.ModelView; +import com.structurizr.view.RelationshipView; + +import java.util.Locale; + +/** + * Writes a Structurizr view to a graphviz dot file. Please note that this is not a full export (colours, shapes, etc); + * it just contains the basics required for layout purposes. + */ +class DOTExporter extends AbstractDiagramExporter { + + private static final String DEFAULT_CLUSTER_INTERNAL_PADDING = "25"; + private static final String GROUP_PADDING_PROPERTY_NAME = "structurizr.groupPadding"; + private static final String BOUNDARY_PADDING_PROPERTY_NAME = "structurizr.boundaryPadding"; + private static final String DEPLOYMENT_NODE_PADDING_PROPERTY_NAME = "structurizr.deploymentNodePadding"; + + private Locale locale = Locale.US; + private final RankDirection rankDirection; + private final double rankSeparation; + private final double nodeSeparation; + + private int groupId = 1; + + DOTExporter(RankDirection rankDirection, double rankSeparation, double nodeSeparation) { + this.rankDirection = rankDirection != null ? rankDirection : RankDirection.TopBottom; + this.rankSeparation = rankSeparation / Constants.STRUCTURIZR_DPI; + this.nodeSeparation = nodeSeparation / Constants.STRUCTURIZR_DPI; + } + + void setLocale(Locale locale) { + this.locale = locale; + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + writer.writeLine("digraph {"); + writer.indent(); + writer.writeLine("compound=true"); + writer.writeLine(String.format(locale, "graph [splines=polyline,rankdir=%s,ranksep=%s,nodesep=%s,fontsize=5]", rankDirection.getCode(), rankSeparation, nodeSeparation)); + writer.writeLine("node [shape=box,fontsize=5]"); + writer.writeLine("edge []"); + writer.writeLine(); + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + writer.writeLine("subgraph \"cluster_group_" + (groupId++) + "\" {"); + writer.indent(); + writer.writeLine("margin=" + Integer.parseInt(getViewOrViewSetProperty(view, GROUP_PADDING_PROPERTY_NAME, DEFAULT_CLUSTER_INTERNAL_PADDING))); + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + writer.writeLine(String.format("subgraph cluster_%s {", softwareSystem.getId())); + writer.indent(); + writer.writeLine("margin=" + Integer.parseInt(getViewOrViewSetProperty(view, BOUNDARY_PADDING_PROPERTY_NAME, DEFAULT_CLUSTER_INTERNAL_PADDING))); + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + writer.writeLine(String.format("subgraph cluster_%s {", container.getId())); + writer.indent(); + writer.writeLine("margin=" + Integer.parseInt(getViewOrViewSetProperty(view, BOUNDARY_PADDING_PROPERTY_NAME, DEFAULT_CLUSTER_INTERNAL_PADDING))); + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + writer.writeLine(String.format("subgraph cluster_%s {", deploymentNode.getId())); + writer.indent(); + writer.writeLine("margin=" + Integer.parseInt(getViewOrViewSetProperty(view, DEPLOYMENT_NODE_PADDING_PROPERTY_NAME, DEFAULT_CLUSTER_INTERNAL_PADDING))); + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + ElementStyle elementStyle = StyleUtils.findElementStyle(view, element); + + writer.writeLine(String.format(locale, "%s [width=%f,height=%f,fixedsize=true,id=%s,label=\"%s: %s\"]", + element.getId(), + elementStyle.getWidth() / Constants.STRUCTURIZR_DPI, // convert Structurizr dimensions to inches + elementStyle.getHeight() / Constants.STRUCTURIZR_DPI, // convert Structurizr dimensions to inches + element.getId(), + element.getId(), + escape(element.getName()) + )); + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + if (relationshipView.getRelationship().getSource() instanceof DeploymentNode || relationshipView.getRelationship().getDestination() instanceof DeploymentNode) { + Element source = relationshipView.getRelationship().getSource(); + if (source instanceof DeploymentNode) { + source = findElementInside((DeploymentNode)source, view); + } + + Element destination = relationshipView.getRelationship().getDestination(); + if (destination instanceof DeploymentNode) { + destination = findElementInside((DeploymentNode)destination, view); + } + + if (source != null && destination != null) { + String clusterConfig = ""; + + if (relationshipView.getRelationship().getSource() instanceof DeploymentNode) { + clusterConfig += ",ltail=cluster_" + relationshipView.getRelationship().getSource().getId(); + } + + if (relationshipView.getRelationship().getDestination() instanceof DeploymentNode) { + clusterConfig += ",lhead=cluster_" + relationshipView.getRelationship().getDestination().getId(); + } + + writer.writeLine(String.format(locale, "%s -> %s [id=%s%s]", + source.getId(), + destination.getId(), + relationshipView.getId(), + clusterConfig + )); + } + } else { + Element source = relationshipView.getRelationship().getSource(); + Element destination = relationshipView.getRelationship().getDestination(); + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + source = relationshipView.getRelationship().getDestination(); + destination = relationshipView.getRelationship().getSource(); + } + + writer.writeLine(String.format(locale, "%s -> %s [id=%s]", + source.getId(), + destination.getId(), + relationshipView.getId() + )); + } + } + + @Override + protected Diagram createDiagram(ModelView view, String definition) { + return new DOTDiagram(view, definition); + } + + private String escape(String s) { + if (StringUtils.isNullOrEmpty(s)) { + return s; + } else { + return s.replaceAll("\"", "\\\\\""); + } + } + + private Element findElementInside(DeploymentNode deploymentNode, ModelView view) { + for (SoftwareSystemInstance softwareSystemInstance : deploymentNode.getSoftwareSystemInstances()) { + if (view.isElementInView(softwareSystemInstance)) { + return softwareSystemInstance; + } + } + + for (ContainerInstance containerInstance : deploymentNode.getContainerInstances()) { + if (view.isElementInView(containerInstance)) { + return containerInstance; + } + } + + for (InfrastructureNode infrastructureNode : deploymentNode.getInfrastructureNodes()) { + if (view.isElementInView(infrastructureNode)) { + return infrastructureNode; + } + } + + if (deploymentNode.hasChildren()) { + for (DeploymentNode child : deploymentNode.getChildren()) { + Element element = findElementInside(child, view); + + if (element != null) { + return element; + } + } + } + + return null; + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayout.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayout.java new file mode 100644 index 000000000..efd3c5c7e --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayout.java @@ -0,0 +1,244 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.Workspace; +import com.structurizr.export.Diagram; +import com.structurizr.view.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.BufferedWriter; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Locale; + +/** + * Applies the graphviz automatic layout to views in a Structurizr workspace. + * + * Note: this class assumes that the "dot" command is available. + */ +public class GraphvizAutomaticLayout { + + private static final Log log = LogFactory.getLog(GraphvizAutomaticLayout.class); + + private static final String DOT_EXECUTABLE = "dot"; + private static final String USE_SVG_OUTPUT_FORMAT_OPTION = "-Tsvg"; + private static final String AUTOMATICALLY_GENERATE_OUTPUT_FILE_OPTION = "-O"; + private static final String DOT_FILE_EXTENSION = ".dot"; + + private final File path; + + private RankDirection rankDirection = RankDirection.TopBottom; + private double rankSeparation = 300; + private double nodeSeparation = 300; + + private int margin = 400; + private boolean changePaperSize = true; + + private Locale locale = Locale.US; + + public GraphvizAutomaticLayout() { + this(new File(".")); + } + + public GraphvizAutomaticLayout(File path) { + this.path = path; + } + + public void setRankDirection(RankDirection rankDirection) { + this.rankDirection = rankDirection; + } + + public void setRankSeparation(double rankSeparation) { + this.rankSeparation = rankSeparation; + } + + public void setNodeSeparation(double nodeSeparation) { + this.nodeSeparation = nodeSeparation; + } + + public void setMargin(int margin) { + this.margin = margin; + } + + public void setChangePaperSize(boolean changePaperSize) { + this.changePaperSize = changePaperSize; + } + + /** + * Sets the locale used when writing DOT files. + * + * @param locale a Locale instance + */ + public void setLocale(Locale locale) { + this.locale = locale; + } + + private DOTExporter createDOTExporter(AutomaticLayout automaticLayout) { + DOTExporter exporter; + + if (automaticLayout == null) { + // use the configured defaults + exporter = new DOTExporter(rankDirection, rankSeparation, nodeSeparation); + } else { + // use the values from the automatic layout configuration associated with the view + exporter = new DOTExporter( + RankDirection.valueOf(automaticLayout.getRankDirection().name()), + automaticLayout.getRankSeparation(), + automaticLayout.getNodeSeparation() + ); + } + + exporter.setLocale(locale); + + return exporter; + } + + private void writeFile(Diagram diagram) throws Exception { + File file = new File(path, diagram.getKey() + DOT_FILE_EXTENSION); + log.debug("Writing " + file.getAbsolutePath()); + BufferedWriter writer = Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8); + writer.write(diagram.getDefinition()); + writer.flush(); + writer.close(); + + if (!file.exists()) { + log.error(file.getAbsolutePath() + " does not exist"); + } + } + + private SVGReader createSVGReader() { + return new SVGReader(path, margin, changePaperSize); + } + + private void runGraphviz(View view) throws Exception { + ProcessBuilder processBuilder = new ProcessBuilder().inheritIO(); + List command = List.of( + DOT_EXECUTABLE, + new File(path, view.getKey() + DOT_FILE_EXTENSION).getAbsolutePath(), + USE_SVG_OUTPUT_FORMAT_OPTION, + AUTOMATICALLY_GENERATE_OUTPUT_FILE_OPTION + ); + + processBuilder.command(command); + + StringBuilder buf = new StringBuilder(); + for (String s : command) { + buf.append(s); + buf.append(" "); + } + log.debug(buf); + + Process process = processBuilder.start(); + int exitCode = process.waitFor(); + assert exitCode == 0; + + String input = new String(process.getInputStream().readAllBytes()); + String error = new String(process.getErrorStream().readAllBytes()); + + log.debug("stdout: " + input); + log.debug("stderr: " + error); + } + + public void apply(CustomView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(SystemLandscapeView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(SystemContextView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(ContainerView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(ComponentView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(DynamicView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(DeploymentView view) throws Exception { + log.debug("Running Graphviz for view with key " + view.getKey()); + Diagram diagram = createDOTExporter(view.getAutomaticLayout()).export(view); + writeFile(diagram); + runGraphviz(view); + createSVGReader().parseAndApplyLayout(view); + } + + public void apply(Workspace workspace) throws Exception { + for (CustomView view : workspace.getViews().getCustomViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (SystemLandscapeView view : workspace.getViews().getSystemLandscapeViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (SystemContextView view : workspace.getViews().getSystemContextViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (ContainerView view : workspace.getViews().getContainerViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (ComponentView view : workspace.getViews().getComponentViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (DynamicView view : workspace.getViews().getDynamicViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + + for (DeploymentView view : workspace.getViews().getDeploymentViews()) { + if (view.getAutomaticLayout() != null && view.getAutomaticLayout().getImplementation() == AutomaticLayout.Implementation.Graphviz) { + apply(view); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/RankDirection.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/RankDirection.java new file mode 100644 index 000000000..e3c4e366c --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/RankDirection.java @@ -0,0 +1,23 @@ +package com.structurizr.autolayout.graphviz; + +/** + * The various rank directions used by graphviz. + */ +public enum RankDirection { + + TopBottom("TB"), + BottomTop("BT"), + LeftRight("LR"), + RightLeft("RL"); + + private String code; + + RankDirection(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/SVGReader.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/SVGReader.java new file mode 100644 index 000000000..adc73c6d0 --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/SVGReader.java @@ -0,0 +1,203 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; +import com.structurizr.view.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; +import java.io.File; +import java.io.FileInputStream; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Parses an SVG file created by graphviz, extracts the layout information, and applies it to a Structurizr view. + */ +class SVGReader { + + private static final Log log = LogFactory.getLog(GraphvizAutomaticLayout.class); + + private final File path; + private final int margin; + private final boolean changePaperSize; + + SVGReader(File path, int margin, boolean changePaperSize) { + this.path = path; + this.margin = margin; + this.changePaperSize = changePaperSize; + } + + void parseAndApplyLayout(ModelView view) throws Exception { + File file = new File(path, view.getKey() + ".dot.svg"); + log.debug("Reading " + file.getAbsolutePath()); + + if (file.exists()) { + FileInputStream fileIS = new FileInputStream(file); + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(false); + builderFactory.setValidating(false); + builderFactory.setFeature("http://xml.org/sax/features/namespaces", false); + builderFactory.setFeature("http://xml.org/sax/features/validation", false); + builderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false); + builderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + + DocumentBuilder builder = builderFactory.newDocumentBuilder(); + Document xmlDocument = builder.parse(fileIS); + XPath xPath = XPathFactory.newInstance().newXPath(); + NodeList nodeList = (NodeList) xPath.compile("/svg/g[@class=\"graph\"]").evaluate(xmlDocument, XPathConstants.NODESET); + String transform = nodeList.item(0).getAttributes().getNamedItem("transform").getNodeValue(); + String translate = transform.substring(transform.indexOf("translate")); + String numbers = translate.substring(translate.indexOf("(") + 1, translate.indexOf(")")); + int transformX = (int) Double.parseDouble(numbers.split(" ")[0]); + int transformY = (int) Double.parseDouble(numbers.split(" ")[1]); + + int minimumX = Integer.MAX_VALUE; + int minimumY = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumY = Integer.MIN_VALUE; + + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof DeploymentNode) { + // deployment nodes are clusters, so positioned automatically + continue; + } + + String expression = String.format("/svg/g/g[@id=\"%s\"]/polygon", elementView.getId()); + nodeList = (NodeList) xPath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET); + if (nodeList.getLength() == 0) { + continue; + } + + String pointsAsString = nodeList.item(0).getAttributes().getNamedItem("points").getNodeValue(); + String[] points = pointsAsString.split(" "); + String[] coordinates = points[1].split(","); + + double x = Double.parseDouble(coordinates[0]) + transformX; + double y = Double.parseDouble(coordinates[1]) + transformY; + + elementView.setX((int) (x * Constants.DPI_RATIO)); + elementView.setY((int) (y * Constants.DPI_RATIO)); + + minimumX = Math.min(elementView.getX(), minimumX); + minimumY = Math.min(elementView.getY(), minimumY); + + ElementStyle style = StyleUtils.findElementStyle(view, view.getModel().getElement(elementView.getId())); + + maximumX = Math.max(elementView.getX() + style.getWidth(), maximumX); + maximumY = Math.max(elementView.getY() + style.getHeight(), maximumY); + } + + for (RelationshipView relationshipView : view.getRelationships()) { + String expression = String.format("/svg/g/g[@id=\"%s\"]/path", relationshipView.getId()); + nodeList = (NodeList) xPath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET); + if (nodeList.getLength() == 0) { + continue; + } + + String dAsString = nodeList.item(0).getAttributes().getNamedItem("d").getNodeValue(); + String[] d = dAsString.split(" "); + + Set vertices = new LinkedHashSet<>(); + + if (d.length == 3) { + relationshipView.setVertices(vertices); + } else { + for (int i = 1; i < d.length - 2; i++) { + double x = Double.parseDouble(d[i].split(",")[0]) + transformX; + double y = Double.parseDouble(d[i].split(",")[1]) + transformY; + Vertex vertex = new Vertex((int) (x * Constants.DPI_RATIO), (int) (y * Constants.DPI_RATIO)); + vertices.add(vertex); + + minimumX = Math.min(vertex.getX(), minimumX); + minimumY = Math.min(vertex.getY(), minimumY); + maximumX = Math.max(vertex.getX(), maximumX); + maximumY = Math.max(vertex.getY(), maximumY); + } + relationshipView.setVertices(vertices); + } + } + + // also take into account any clusters that might be rendered outside the nodes + String expression = "/svg/g/g[@class=\"cluster\"]/polygon"; + nodeList = (NodeList) xPath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET); + for (int i = 0; i < nodeList.getLength(); i++) { + String[] points = nodeList.item(i).getAttributes().getNamedItem("points").getNodeValue().split(" "); + for (String point : points) { + int x = (int) ((Double.parseDouble(point.split(",")[0]) + transformX) * Constants.DPI_RATIO); + int y = (int) ((Double.parseDouble(point.split(",")[1]) + transformY) * Constants.DPI_RATIO); + + minimumX = Math.min(x, minimumX); + minimumY = Math.min(y, minimumY); + maximumX = Math.max(x, maximumX); + maximumY = Math.max(y, maximumY); + } + } + + int pageWidth = Math.max(margin, maximumX + margin); + int pageHeight = Math.max(margin, maximumY + margin); + + if (changePaperSize) { + view.setPaperSize(null); + view.setDimensions(new Dimensions(pageWidth, pageHeight)); + + PaperSize.Orientation orientation = (pageWidth > pageHeight) ? PaperSize.Orientation.Landscape : PaperSize.Orientation.Portrait; + for (PaperSize paperSize : PaperSize.getOrderedPaperSizes(orientation)) { + if (paperSize.getWidth() > (pageWidth) && paperSize.getHeight() > (pageHeight)) { + view.setPaperSize(paperSize); + break; + } + } + } + + int deltaX = (pageWidth - maximumX + minimumX) / 2; + int deltaY = (pageHeight - maximumY + minimumY) / 2; + + // move everything relative to 0,0 + for (ElementView elementView : view.getElements()) { + elementView.setX(elementView.getX() - minimumX); + elementView.setY(elementView.getY() - minimumY); + } + for (RelationshipView relationshipView : view.getRelationships()) { + for (Vertex vertex : relationshipView.getVertices()) { + vertex.setX(vertex.getX() - minimumX); + vertex.setY(vertex.getY() - minimumY); + } + } + + // and now centre everything + for (ElementView elementView : view.getElements()) { + elementView.setX(elementView.getX() + deltaX); + elementView.setY(elementView.getY() + deltaY); + } + for (RelationshipView relationshipView : view.getRelationships()) { + for (Vertex vertex : relationshipView.getVertices()) { + vertex.setX(vertex.getX() + deltaX); + vertex.setY(vertex.getY() + deltaY); + } + } + + log.debug("Layout applied to view with key " + view.getKey()); + } else { + log.error(file.getAbsolutePath() + " does not exist; layout not applied to view with key " + view.getKey()); + } + } + + private int getElementWidth(ModelView view, String elementId) { + Element element = view.getModel().getElement(elementId); + return view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getWidth(); + } + + private int getElementHeight(ModelView view, String elementId) { + Element element = view.getModel().getElement(elementId); + return view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getHeight(); + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/StyleUtils.java b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/StyleUtils.java new file mode 100644 index 000000000..ef89674f9 --- /dev/null +++ b/structurizr-autolayout/src/main/java/com/structurizr/autolayout/graphviz/StyleUtils.java @@ -0,0 +1,38 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.model.Element; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.Shape; +import com.structurizr.view.View; + +class StyleUtils { + + private static final int DEFAULT_WIDTH = 450; + private static final int DEFAULT_HEIGHT = 300; + + private static final int DEFAULT_WIDTH_PERSON = 400; + private static final int DEFAULT_HEIGHT_PERSON = 400; + + static ElementStyle findElementStyle(View view, Element element) { + ElementStyle style = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + + if (style.getWidth() == null) { + if (style.getShape() == Shape.Person || style.getShape() == Shape.Robot) { + style.setWidth(DEFAULT_WIDTH_PERSON); + } else { + style.setWidth(DEFAULT_WIDTH); + } + } + + if (style.getHeight() == null) { + if (style.getShape() == Shape.Person || style.getShape() == Shape.Robot) { + style.setHeight(DEFAULT_HEIGHT_PERSON); + } else { + style.setHeight(DEFAULT_HEIGHT); + } + } + + return style; + } + +} diff --git a/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/DOTExporterTests.java b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/DOTExporterTests.java new file mode 100644 index 000000000..3bc79c14a --- /dev/null +++ b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/DOTExporterTests.java @@ -0,0 +1,674 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.Workspace; +import com.structurizr.autolayout.graphviz.DOTExporter; +import com.structurizr.autolayout.graphviz.RankDirection; +import com.structurizr.export.Diagram; +import com.structurizr.model.*; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DOTExporterTests { + + @Test + public void test_writeCustomView() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box1 = workspace.getModel().addCustomElement("Box 1"); + CustomElement box2 = workspace.getModel().addCustomElement("Box 2"); + box1.uses(box2, "Uses"); + + CustomView view = workspace.getViews().createCustomView("CustomView", "Title", "Description"); + view.add(box1); + view.add(box2); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box 1"] + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: Box 2"] + + 1 -> 2 [id=3] + + }""", content); + } + + @Test + public void test_writeSystemLandscapeView() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + + 2 -> 3 [id=4] + + }""", content); + } + + @Test + public void test_writeSystemLandscapeViewWithGroupedElements() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + user.setGroup("External"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + softwareSystem.setGroup("Internal"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph "cluster_group_1" { + margin=25 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + } + + subgraph "cluster_group_2" { + margin=25 + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + } + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + + 2 -> 3 [id=4] + + }""", content); + } + + @Test + public void test_writeSystemLandscapeViewWithGroupedElementsAndGroupPadding() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + user.setGroup("External"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + softwareSystem.setGroup("Internal"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", ""); + view.addAllElements(); + view.add(box); + view.addProperty("structurizr.groupPadding", "50"); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph "cluster_group_1" { + margin=50 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + } + + subgraph "cluster_group_2" { + margin=50 + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + } + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + + 2 -> 3 [id=4] + + }""", content); + } + + @Test + public void test_writeSystemLandscapeViewWithNestedGroupedElements() { + Workspace workspace = new Workspace("Name", ""); + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + a.setGroup("Enterprise 1/Department 1/Team 1"); + + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + b.setGroup("Enterprise 1/Department 1/Team 2"); + + SoftwareSystem c = workspace.getModel().addSoftwareSystem("C"); + c.setGroup("Enterprise 1/Department 2"); + + SoftwareSystem d = workspace.getModel().addSoftwareSystem("D"); + d.setGroup("Enterprise 2"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", ""); + view.addAllElements(); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals("digraph {\n" + + " compound=true\n" + + " graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5]\n" + + " node [shape=box,fontsize=5]\n" + + " edge []\n" + + "\n" + + " subgraph \"cluster_group_1\" {\n" + + " margin=25\n" + + " subgraph \"cluster_group_2\" {\n" + + " margin=25\n" + + " subgraph \"cluster_group_3\" {\n" + + " margin=25\n" + + " 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label=\"1: A\"]\n" + + " }\n" + + "\n" + + " subgraph \"cluster_group_4\" {\n" + + " margin=25\n" + + " 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label=\"2: B\"]\n" + + " }\n" + + "\n" + + " }\n" + + "\n" + + " subgraph \"cluster_group_5\" {\n" + + " margin=25\n" + + " 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label=\"3: C\"]\n" + + " }\n" + + "\n" + + " }\n" + + "\n" + + " subgraph \"cluster_group_6\" {\n" + + " margin=25\n" + + " 4 [width=1.500000,height=1.000000,fixedsize=true,id=4,label=\"4: D\"]\n" + + " }\n" + + "\n" + + "\n" + + "}", content); + } + + @Test + public void test_writeSystemLandscapeViewInGermanLocale() { + // ranksep=1.0 was being output as ranksep=1,0 + Locale.setDefault(new Locale("de", "DE")); + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + + 2 -> 3 [id=4] + + }""", content); + } + + @Test + public void test_writeSystemContextView() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + user.uses(softwareSystem, "Uses"); + + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + + 2 -> 3 [id=4] + + }""", content); + } + + + @Test + public void test_writeSystemContextViewWithGroupedElements() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + Person user = workspace.getModel().addPerson("User", ""); + user.setGroup("External"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + softwareSystem.setGroup("Internal"); + user.uses(softwareSystem, "Uses"); + + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph "cluster_group_1" { + margin=25 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: User"] + } + + subgraph "cluster_group_2" { + margin=25 + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Software System"] + } + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + + 2 -> 3 [id=4] + + }""", content); + } + + @Test + public void test_writeContainerViewWithGroupedElementsInASingleSoftwareSystem() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + Container container1 = softwareSystem.addContainer("Container 1"); + container1.setGroup("Group 1"); + Container container2 = softwareSystem.addContainer("Container 2"); + container2.setGroup("Group 2"); + Container container3 = softwareSystem.addContainer("Container 3"); + + container1.uses(container2, "Uses"); + container2.uses(container3, "Uses"); + + ContainerView view = workspace.getViews().createContainerView(softwareSystem, "Containers", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + + subgraph cluster_2 { + margin=25 + subgraph "cluster_group_1" { + margin=25 + 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label="3: Container 1"] + } + + subgraph "cluster_group_2" { + margin=25 + 4 [width=1.500000,height=1.000000,fixedsize=true,id=4,label="4: Container 2"] + } + + 5 [width=1.500000,height=1.000000,fixedsize=true,id=5,label="5: Container 3"] + } + + 3 -> 4 [id=6] + 4 -> 5 [id=7] + + }""", content); + } + + @Test + public void test_writeContainerViewWithGroupedElementsInMultipleSoftwareSystems() { + Workspace workspace = new Workspace("Name", ""); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + container1.setGroup("Group"); + + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + container2.setGroup("Group"); + + container1.uses(container2, "Uses"); + + ContainerView view = workspace.getViews().createContainerView(softwareSystem1, "Containers", ""); + view.add(container1); + view.add(container2); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph cluster_1 { + margin=25 + subgraph "cluster_group_1" { + margin=25 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: Container 1"] + } + + } + + subgraph cluster_3 { + margin=25 + subgraph "cluster_group_2" { + margin=25 + 4 [width=1.500000,height=1.000000,fixedsize=true,id=4,label="4: Container 2"] + } + + } + + 2 -> 4 [id=5] + + }""", content); + } + + @Test + public void test_writeContainerViewWithBoundaryPadding() { + Workspace workspace = new Workspace("Name", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + softwareSystem.addContainer("Container"); + + ContainerView view = workspace.getViews().createContainerView(softwareSystem, "key"); + view.addAllElements(); + workspace.getViews().getConfiguration().addProperty("structurizr.boundaryPadding", "50"); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph cluster_1 { + margin=50 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: Container"] + } + + }""", content); + } + + @Test + public void test_writeComponentViewWithGroupedElements() { + Workspace workspace = new Workspace("Name", ""); + CustomElement box = workspace.getModel().addCustomElement("Box"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + Container container = softwareSystem.addContainer("Container"); + Component component1 = container.addComponent("Component 1", ""); + Component component2 = container.addComponent("Component 2", ""); + component2.setGroup("Group 2"); + Component component3 = container.addComponent("Component 3", ""); + component3.setGroup("Group 3"); + + component1.uses(component2, "Uses"); + component2.uses(component3, "Uses"); + + ComponentView view = workspace.getViews().createComponentView(container, "Components", ""); + view.addAllElements(); + view.add(box); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + 1 [width=1.500000,height=1.000000,fixedsize=true,id=1,label="1: Box"] + + subgraph cluster_2 { + margin=25 + subgraph cluster_3 { + margin=25 + subgraph "cluster_group_1" { + margin=25 + 5 [width=1.500000,height=1.000000,fixedsize=true,id=5,label="5: Component 2"] + } + + subgraph "cluster_group_2" { + margin=25 + 6 [width=1.500000,height=1.000000,fixedsize=true,id=6,label="6: Component 3"] + } + + 4 [width=1.500000,height=1.000000,fixedsize=true,id=4,label="4: Component 1"] + } + + } + + 4 -> 5 [id=7] + 5 -> 6 [id=8] + + }""", content); + } + + @Test + public void test_writeContainerViewWithGroupedElements_WithAndWithoutAGroupSeparator() { + Workspace workspace = new Workspace("Name", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + Container container1 = softwareSystem.addContainer("Container 1"); + container1.setGroup("Group 1"); + Container container2 = softwareSystem.addContainer("Container 2"); + container2.setGroup("Group 2"); + + ContainerView view = workspace.getViews().createContainerView(softwareSystem, "Containers", ""); + view.addAllElements(); + + DOTExporter exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + Diagram diagram = exporter.export(view); + + String content = diagram.getDefinition(); + + String expectedResult = "digraph {\n" + + " compound=true\n" + + " graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5]\n" + + " node [shape=box,fontsize=5]\n" + + " edge []\n" + + "\n" + + " subgraph cluster_1 {\n" + + " margin=25\n" + + " subgraph \"cluster_group_1\" {\n" + + " margin=25\n" + + " 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label=\"2: Container 1\"]\n" + + " }\n" + + "\n" + + " subgraph \"cluster_group_2\" {\n" + + " margin=25\n" + + " 3 [width=1.500000,height=1.000000,fixedsize=true,id=3,label=\"3: Container 2\"]\n" + + " }\n" + + "\n" + + " }\n" + + "\n" + + "}"; + + assertEquals(expectedResult, content); + + // this should be the same + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + exporter = new DOTExporter(RankDirection.TopBottom, 300, 300); + diagram = exporter.export(view); + + content = diagram.getDefinition(); + assertEquals(expectedResult, content); + } + + @Test + public void test_AmazonWebServicesExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("src/test/resources/structurizr-54915-workspace.json")); + DOTExporter exporter = new DOTExporter(RankDirection.LeftRight, 300, 300); + Diagram diagram = exporter.export(workspace.getViews().getDeploymentViews().iterator().next()); + + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=LR,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph cluster_5 { + margin=25 + subgraph cluster_6 { + margin=25 + subgraph cluster_12 { + margin=25 + subgraph cluster_13 { + margin=25 + 14 [width=1.500000,height=1.000000,fixedsize=true,id=14,label="14: Database"] + } + + } + + 7 [width=1.500000,height=1.000000,fixedsize=true,id=7,label="7: Route 53"] + 8 [width=1.500000,height=1.000000,fixedsize=true,id=8,label="8: Elastic Load Balancer"] + subgraph cluster_9 { + margin=25 + subgraph cluster_10 { + margin=25 + 11 [width=1.500000,height=1.000000,fixedsize=true,id=11,label="11: Web Application"] + } + + } + + } + + } + + 11 -> 14 [id=15] + 7 -> 8 [id=16] + 8 -> 11 [id=17] + + }""", diagram.getDefinition()); + } + + @Test + public void test_AmazonWebServicesExampleWithDeploymentNodePadding() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("src/test/resources/structurizr-54915-workspace.json")); + workspace.getViews().getConfiguration().addProperty("structurizr.deploymentNodePadding", "50"); + DOTExporter exporter = new DOTExporter(RankDirection.LeftRight, 300, 300); + Diagram diagram = exporter.export(workspace.getViews().getDeploymentViews().iterator().next()); + + assertEquals(""" + digraph { + compound=true + graph [splines=polyline,rankdir=LR,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph cluster_5 { + margin=50 + subgraph cluster_6 { + margin=50 + subgraph cluster_12 { + margin=50 + subgraph cluster_13 { + margin=50 + 14 [width=1.500000,height=1.000000,fixedsize=true,id=14,label="14: Database"] + } + + } + + 7 [width=1.500000,height=1.000000,fixedsize=true,id=7,label="7: Route 53"] + 8 [width=1.500000,height=1.000000,fixedsize=true,id=8,label="8: Elastic Load Balancer"] + subgraph cluster_9 { + margin=50 + subgraph cluster_10 { + margin=50 + 11 [width=1.500000,height=1.000000,fixedsize=true,id=11,label="11: Web Application"] + } + + } + + } + + } + + 11 -> 14 [id=15] + 7 -> 8 [id=16] + 8 -> 11 [id=17] + + }""", diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayoutTests.java b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayoutTests.java new file mode 100644 index 000000000..c499674a4 --- /dev/null +++ b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/GraphvizAutomaticLayoutTests.java @@ -0,0 +1,57 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.Workspace; +import com.structurizr.autolayout.graphviz.GraphvizAutomaticLayout; +import com.structurizr.model.Person; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.Tags; +import com.structurizr.view.AutomaticLayout; +import com.structurizr.view.Shape; +import com.structurizr.view.SystemContextView; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class GraphvizAutomaticLayoutTests { + + @Test + public void apply_Workspace() throws Exception { + File tempDir = Files.createTempDirectory("graphviz").toFile(); + GraphvizAutomaticLayout graphviz = new GraphvizAutomaticLayout(tempDir); + + Workspace workspace = new Workspace("Name", ""); + Person user = workspace.getModel().addPerson("User"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + user.uses(softwareSystem, "Uses"); + + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", ""); + view.addAllElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle(Tags.PERSON).shape(Shape.Person); + + assertEquals(0, view.getElementView(user).getX()); + assertEquals(0, view.getElementView(user).getY()); + assertEquals(0, view.getElementView(softwareSystem).getX()); + assertEquals(0, view.getElementView(softwareSystem).getY()); + + graphviz.apply(workspace); + + // no change - the view doesn't have automatic layout configured + assertEquals(0, view.getElementView(user).getX()); + assertEquals(0, view.getElementView(user).getY()); + assertEquals(0, view.getElementView(softwareSystem).getX()); + assertEquals(0, view.getElementView(softwareSystem).getY()); + + view.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom); + graphviz.apply(workspace); + + assertEquals(233, view.getElementView(user).getX()); + assertEquals(208, view.getElementView(user).getY()); + assertEquals(208, view.getElementView(softwareSystem).getX()); + assertEquals(908, view.getElementView(softwareSystem).getY()); + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/SVGReaderTests.java b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/SVGReaderTests.java new file mode 100644 index 000000000..c74a59937 --- /dev/null +++ b/structurizr-autolayout/src/test/java/com/structurizr/autolayout/graphviz/SVGReaderTests.java @@ -0,0 +1,63 @@ +package com.structurizr.autolayout.graphviz; + +import com.structurizr.Workspace; +import com.structurizr.autolayout.graphviz.SVGReader; +import com.structurizr.model.Person; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.Tags; +import com.structurizr.view.PaperSize; +import com.structurizr.view.Shape; +import com.structurizr.view.SystemContextView; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class SVGReaderTests { + + private static final File PATH = new File("./src/test/resources/graphviz"); + + @Test + public void test_readView() throws Exception { + Workspace workspace = createWorkspace(); + SystemContextView view = workspace.getViews().getSystemContextViews().iterator().next(); + Person user = workspace.getModel().getPersonWithName("User"); + SoftwareSystem softwareSystem = workspace.getModel().getSoftwareSystemWithName("Software System"); + + assertEquals(0, view.getElementView(user).getX()); + assertEquals(0, view.getElementView(user).getY()); + + assertEquals(0, view.getElementView(softwareSystem).getX()); + assertEquals(0, view.getElementView(softwareSystem).getY()); + + assertNull(view.getPaperSize()); + + SVGReader svgReader = new SVGReader(PATH, 200, true); + svgReader.parseAndApplyLayout(view); + + assertEquals(PaperSize.A6_Portrait, view.getPaperSize()); + + assertEquals(254, view.getElementView(user).getX()); + assertEquals(108, view.getElementView(user).getY()); + + assertEquals(229, view.getElementView(softwareSystem).getX()); + assertEquals(808, view.getElementView(softwareSystem).getY()); + } + + private static Workspace createWorkspace() { + Workspace workspace = new Workspace("Name", ""); + Person user = workspace.getModel().addPerson("User", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + user.uses(softwareSystem, "Uses"); + + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", ""); + view.addAllElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle(Tags.PERSON).shape(Shape.Person); + + return workspace; + } + +} \ No newline at end of file diff --git a/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot b/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot new file mode 100644 index 000000000..3766cb71a --- /dev/null +++ b/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot @@ -0,0 +1,14 @@ +digraph { + graph [splines=polyline,rankdir=TB,ranksep=1.0,nodesep=1.0,fontsize=5] + node [shape=box,fontsize=5] + edge [] + + subgraph cluster_enterprise { + margin=25 + 2 [width=1.500000,height=1.000000,fixedsize=true,id=2,label="2: Software System"] + } + + 1 [width=1.333333,height=1.333333,fixedsize=true,id=1,label="1: User"] + + 1 -> 2 [id=3] +} \ No newline at end of file diff --git a/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot.svg b/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot.svg new file mode 100644 index 000000000..216272c76 --- /dev/null +++ b/structurizr-autolayout/src/test/resources/graphviz/SystemContext.dot.svg @@ -0,0 +1,35 @@ + + + + + + +%3 + + +cluster_enterprise + + + + +2 + +2: Software System + + + +1 + +1: User + + + +1->2 + + + + + diff --git a/structurizr-autolayout/src/test/resources/structurizr-54915-workspace.json b/structurizr-autolayout/src/test/resources/structurizr-54915-workspace.json new file mode 100644 index 000000000..28e62ac94 --- /dev/null +++ b/structurizr-autolayout/src/test/resources/structurizr-54915-workspace.json @@ -0,0 +1,353 @@ +{ + "id": 54915, + "name": "Amazon Web Services Example", + "description": "An example AWS deployment architecture.", + "model": { + "softwareSystems": [ + { + "id": "1", + "tags": "Element,Software System", + "name": "Spring PetClinic", + "description": "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets.", + "location": "Unspecified", + "containers": [ + { + "id": "3", + "tags": "Element,Container,Database", + "name": "Database", + "description": "Stores information regarding the veterinarians, the clients, and their pets.", + "technology": "Relational database schema" + }, + { + "id": "2", + "tags": "Element,Container,Application", + "name": "Web Application", + "description": "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets.", + "relationships": [ + { + "id": "4", + "tags": "Relationship", + "sourceId": "2", + "destinationId": "3", + "description": "Reads from and writes to", + "technology": "MySQL Protocol/SSL" + } + ], + "technology": "Java and Spring Boot" + } + ], + "documentation": {} + } + ], + "deploymentNodes": [ + { + "id": "5", + "tags": "Element,Deployment Node,Amazon Web Services - Cloud", + "name": "Amazon Web Services", + "environment": "Live", + "instances": 1, + "children": [ + { + "id": "6", + "tags": "Element,Deployment Node,Amazon Web Services - Region", + "name": "US-East-1", + "environment": "Live", + "instances": 1, + "children": [ + { + "id": "12", + "tags": "Element,Deployment Node,Amazon Web Services - RDS", + "name": "Amazon RDS", + "environment": "Live", + "instances": 1, + "children": [ + { + "id": "13", + "tags": "Element,Deployment Node,Amazon Web Services - RDS MySQL instance", + "name": "MySQL", + "environment": "Live", + "instances": 1, + "containerInstances": [ + { + "id": "14", + "tags": "Container Instance", + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "3" + } + ], + "children": [], + "softwareSystemInstances": [], + "infrastructureNodes": [] + } + ], + "softwareSystemInstances": [], + "containerInstances": [], + "infrastructureNodes": [] + }, + { + "id": "9", + "tags": "Element,Deployment Node,Amazon Web Services - Auto Scaling", + "name": "Autoscaling group", + "environment": "Live", + "instances": 1, + "children": [ + { + "id": "10", + "tags": "Element,Deployment Node,Amazon Web Services - EC2", + "name": "Amazon EC2", + "environment": "Live", + "instances": 1, + "containerInstances": [ + { + "id": "11", + "tags": "Container Instance", + "relationships": [ + { + "id": "15", + "sourceId": "11", + "destinationId": "14", + "description": "Reads from and writes to", + "technology": "MySQL Protocol/SSL", + "linkedRelationshipId": "4" + } + ], + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "2" + } + ], + "children": [], + "softwareSystemInstances": [], + "infrastructureNodes": [] + } + ], + "softwareSystemInstances": [], + "containerInstances": [], + "infrastructureNodes": [] + } + ], + "infrastructureNodes": [ + { + "id": "8", + "tags": "Element,Infrastructure Node,Amazon Web Services - Elastic Load Balancing", + "name": "Elastic Load Balancer", + "description": "Automatically distributes incoming application traffic.", + "relationships": [ + { + "id": "17", + "tags": "Relationship", + "sourceId": "8", + "destinationId": "11", + "description": "Forwards requests to", + "technology": "HTTPS" + } + ], + "environment": "Live" + }, + { + "id": "7", + "tags": "Element,Infrastructure Node,Amazon Web Services - Route 53", + "name": "Route 53", + "description": "Highly available and scalable cloud DNS service.", + "relationships": [ + { + "id": "16", + "tags": "Relationship", + "sourceId": "7", + "destinationId": "8", + "description": "Forwards requests to", + "technology": "HTTPS" + } + ], + "environment": "Live" + } + ], + "softwareSystemInstances": [], + "containerInstances": [] + } + ], + "softwareSystemInstances": [], + "containerInstances": [], + "infrastructureNodes": [] + } + ], + "customElements": [], + "people": [] + }, + "documentation": { + "sections": [], + "decisions": [], + "images": [] + }, + "views": { + "deploymentViews": [ + { + "softwareSystemId": "1", + "key": "AmazonWebServicesDeployment", + "order": 1, + "paperSize": "A3_Landscape", + "dimensions": { + "width": 3925, + "height": 1816 + }, + "automaticLayout": { + "implementation": "Graphviz", + "rankDirection": "LeftRight", + "rankSeparation": 300, + "nodeSeparation": 300, + "edgeSeparation": 0, + "vertices": false + }, + "environment": "Live", + "animations": [ + { + "order": 1, + "elements": [ + "5", + "6", + "7" + ] + }, + { + "order": 2, + "elements": [ + "8" + ], + "relationships": [ + "16" + ] + }, + { + "order": 3, + "elements": [ + "11", + "9", + "10" + ], + "relationships": [ + "17" + ] + }, + { + "order": 4, + "elements": [ + "12", + "13", + "14" + ], + "relationships": [ + "15" + ] + } + ], + "elements": [ + { + "id": "11", + "x": 1987, + "y": 672 + }, + { + "id": "12", + "x": 175, + "y": 175 + }, + { + "id": "13", + "x": 175, + "y": 175 + }, + { + "id": "14", + "x": 2887, + "y": 672 + }, + { + "id": "5", + "x": 175, + "y": 175 + }, + { + "id": "6", + "x": 175, + "y": 175 + }, + { + "id": "7", + "x": 487, + "y": 672 + }, + { + "id": "8", + "x": 1237, + "y": 672 + }, + { + "id": "9", + "x": 175, + "y": 175 + }, + { + "id": "10", + "x": 175, + "y": 175 + } + ], + "relationships": [ + { + "id": "17" + }, + { + "id": "16" + }, + { + "id": "15" + } + ] + } + ], + "configuration": { + "branding": {}, + "styles": { + "elements": [ + { + "tag": "Element", + "background": "#ffffff", + "shape": "RoundedBox" + }, + { + "tag": "Container", + "background": "#ffffff" + }, + { + "tag": "Application", + "background": "#ffffff" + }, + { + "tag": "Database", + "shape": "Cylinder" + } + ], + "relationships": [] + }, + "themes": [ + "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json" + ], + "terminology": {}, + "lastSavedView": "AmazonWebServicesDeployment" + }, + "customViews": [], + "systemLandscapeViews": [], + "systemContextViews": [], + "containerViews": [], + "componentViews": [], + "dynamicViews": [], + "filteredViews": [] + } +} \ No newline at end of file diff --git a/structurizr-client/README.md b/structurizr-client/README.md new file mode 100644 index 000000000..ea9734085 --- /dev/null +++ b/structurizr-client/README.md @@ -0,0 +1,11 @@ +# structurizr-client + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-client.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-client) + +This library provides an API client for the workspace and admin APIs provided by the Structurizr cloud service and on-premises installation. + +- [Cloud service - Workspace API](https://docs.structurizr.com/cloud/workspace-api) +- [Cloud service - Admin API](https://docs.structurizr.com/cloud/admin-api) +- [On-premises installation - Workspace API](https://docs.structurizr.com/onpremises/workspace-api) +- [On-premises installation - Admin API](https://docs.structurizr.com/onpremises/admin-api) +- [Workspace API client](https://docs.structurizr.com/java/workspace-api) diff --git a/structurizr-client/build.gradle b/structurizr-client/build.gradle index 6c5bd755d..fd7c55622 100644 --- a/structurizr-client/build.gradle +++ b/structurizr-client/build.gradle @@ -1,14 +1,9 @@ dependencies { - implementation project(':structurizr-core') + api project(':structurizr-core') - implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3' + api 'com.fasterxml.jackson.core:jackson-databind:2.20.0' + api 'org.apache.httpcomponents.client5:httpclient5:5.5.1' + api 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' - implementation 'org.apache.httpcomponents.client5:httpclient5:5.1.3' - - implementation 'javax.xml.bind:jaxb-api:2.3.0' - - implementation 'commons-logging:commons-logging:1.2' - - testImplementation 'junit:junit:4.12' } \ No newline at end of file diff --git a/structurizr-client/src/com/structurizr/view/ThemeUtils.java b/structurizr-client/src/com/structurizr/view/ThemeUtils.java deleted file mode 100644 index 045e71579..000000000 --- a/structurizr-client/src/com/structurizr/view/ThemeUtils.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.structurizr.view; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.structurizr.Workspace; -import com.structurizr.io.WorkspaceWriterException; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.io.entity.EntityUtils; - -import java.io.*; -import java.nio.charset.StandardCharsets; - -/** - * Some utility methods for exporting themes to JSON. - */ -public final class ThemeUtils { - - private static final int HTTP_OK_STATUS = 200; - - /** - * Serializes the theme (element and relationship styles) in the specified workspace to a file, as a JSON string. - * - * @param workspace a Workspace object - * @param file a File representing the JSON definition - * @throws Exception if something goes wrong - */ - public static void toJson(Workspace workspace, File file) throws Exception { - if (workspace == null) { - throw new IllegalArgumentException("A workspace must be provided."); - } else if (file == null) { - throw new IllegalArgumentException("The path to a file must be specified."); - } - - OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8); - write(workspace, writer); - } - - /** - * Serializes the theme (element and relationship styles) in the specified workspace to a JSON string. - * - * @param workspace a Workspace instance - * @return a JSON string - * @throws Exception if something goes wrong - */ - public static String toJson(Workspace workspace) throws Exception { - if (workspace == null) { - throw new IllegalArgumentException("A workspace must be provided."); - } - - StringWriter writer = new StringWriter(); - write(workspace, writer); - - return writer.toString(); - } - - /** - * Loads (and inlines) the element and relationship styles from the themes defined in the workspace, into the workspace itself. - * This implementation simply copies the styles from all themes into the workspace. - * - * @param workspace a Workspace object - * @throws Exception if something goes wrong - */ - public static void loadThemes(Workspace workspace) throws Exception { - for (String url : workspace.getViews().getConfiguration().getThemes()) { - CloseableHttpClient httpClient = HttpClients.createSystem(); - HttpGet httpGet = new HttpGet(url); - - CloseableHttpResponse response = httpClient.execute(httpGet); - if (response.getCode() == HTTP_OK_STATUS) { - String json = EntityUtils.toString(response.getEntity()); - - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); - objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - Theme theme = objectMapper.readValue(json, Theme.class); - - workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(url, theme.getElements(), theme.getRelationships()); - } - - httpClient.close(); - } - } - - private static void write(Workspace workspace, Writer writer) throws Exception { - try { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(SerializationFeature.INDENT_OUTPUT); - objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); - objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); - - writer.write(objectMapper.writeValueAsString( - new Theme( - workspace.getName(), - workspace.getDescription(), - workspace.getViews().getConfiguration().getStyles().getElements(), - workspace.getViews().getConfiguration().getStyles().getRelationships() - ))); - } catch (IOException ioe) { - throw new WorkspaceWriterException("Could not write the theme as JSON", ioe); - } - - writer.flush(); - writer.close(); - } - -} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java b/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java new file mode 100644 index 000000000..df6086c1b --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/api/AbstractApiClient.java @@ -0,0 +1,54 @@ +package com.structurizr.api; + +import com.structurizr.util.StringUtils; + +public abstract class AbstractApiClient { + + protected static final String VERSION = Package.getPackage("com.structurizr.api").getImplementationVersion(); + protected static final String STRUCTURIZR_FOR_JAVA_AGENT = "structurizr-java/" + VERSION; + + protected static final String STRUCTURIZR_CLOUD_SERVICE_API_URL = "https://api.structurizr.com"; + protected static final String WORKSPACE_PATH = "/workspace"; + + protected String url; + protected String agent = STRUCTURIZR_FOR_JAVA_AGENT; + + String getUrl() { + return url; + } + + protected void setUrl(String url) { + if (url == null || url.trim().length() == 0) { + throw new IllegalArgumentException("The API URL must not be null or empty."); + } + + if (url.endsWith("/")) { + this.url = url.substring(0, url.length() - 1); + } else { + this.url = url; + } + } + + /** + * Gets the agent string used to identify this client instance. + * + * @return "structurizr-java/{version}", unless overridden + */ + public String getAgent() { + return agent; + } + + /** + * Sets the agent string used to identify this client instance. + * + * @param agent the agent string + */ + public void setAgent(String agent) { + if (StringUtils.isNullOrEmpty(agent)) { + throw new IllegalArgumentException("An agent must be provided."); + } + + this.agent = agent.trim(); + } + +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java b/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java new file mode 100644 index 000000000..55a9c79db --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/api/AdminApiClient.java @@ -0,0 +1,156 @@ +package com.structurizr.api; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.structurizr.util.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.hc.core5.http.HttpStatus; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +/** + * A client for the Structurizr Admin API. + */ +public class AdminApiClient extends AbstractApiClient { + + private static final Log log = LogFactory.getLog(AdminApiClient.class); + + private final String username; + private final String apiKey; + + /** + * Creates a new admin API client. + * + * @param url the URL of your Structurizr instance + * @param username the username (only required for the Structurizr cloud service) + * @param apiKey the API key of your workspace + */ + public AdminApiClient(String url, String username, String apiKey) { + setUrl(url); + + this.username = username; + + if (apiKey == null || apiKey.trim().length() == 0) { + throw new IllegalArgumentException("The API key must not be null or empty."); + } + + this.apiKey = apiKey; + } + + /** + * Gets a list of all workspaces. + * + * @return a List of WorkspaceMetadata objects + * @throws StructurizrClientException if an error occurs + */ + public List getWorkspaces() throws StructurizrClientException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url + WORKSPACE_PATH)) + .header(HttpHeaders.AUTHORIZATION, createAuthorizationHeader()) + .header(HttpHeaders.USER_AGENT, agent) + .build(); + HttpClient client = HttpClient.newHttpClient(); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String json = response.body(); + + if (response.statusCode() == HttpStatus.SC_OK) { + Workspaces workspaces = objectMapper.readValue(response.body(), Workspaces.class); + return workspaces.getWorkspaces(); + } else { + ApiResponse apiResponse = ApiResponse.parse(json); + throw new StructurizrClientException(apiResponse.getMessage()); + } + } catch (Exception e) { + log.error(e); + throw new StructurizrClientException(e); + } + } + + /** + * Creates a new workspace. + * + * @return a WorkspaceMetadata object representing the new workspace + * @throws StructurizrClientException if an error occurs + */ + public WorkspaceMetadata createWorkspace() throws StructurizrClientException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url + WORKSPACE_PATH)) + .POST(HttpRequest.BodyPublishers.noBody()) + .header(HttpHeaders.AUTHORIZATION, createAuthorizationHeader()) + .header(HttpHeaders.USER_AGENT, agent) + .build(); + HttpClient client = HttpClient.newHttpClient(); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String json = response.body(); + + if (response.statusCode() == HttpStatus.SC_OK) { + return objectMapper.readValue(json, WorkspaceMetadata.class); + } else { + ApiResponse apiResponse = ApiResponse.parse(json); + throw new StructurizrClientException(apiResponse.getMessage()); + } + } catch (Exception e) { + log.error(e); + throw new StructurizrClientException(e); + } + } + + /** + * Deletes a workspace. + * + * @param workspaceId the ID of the workspace to delete + * @throws StructurizrClientException if an error occurs + */ + public void deleteWorkspace(int workspaceId) throws StructurizrClientException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url + WORKSPACE_PATH + "/" + workspaceId)) + .DELETE() + .header(HttpHeaders.AUTHORIZATION, createAuthorizationHeader()) + .header(HttpHeaders.USER_AGENT, agent) + .build(); + HttpClient client = HttpClient.newHttpClient(); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String json = response.body(); + + if (response.statusCode() == HttpStatus.SC_OK) { + ApiResponse apiResponse = ApiResponse.parse(json); + log.debug(apiResponse.getMessage()); + } else { + ApiResponse apiResponse = ApiResponse.parse(json); + throw new StructurizrClientException(apiResponse.getMessage()); + } + } catch (Exception e) { + log.error(e); + throw new StructurizrClientException(e); + } + } + + private String createAuthorizationHeader() { + if (StringUtils.isNullOrEmpty(username)) { + return apiKey; + } else { + return username + ":" + apiKey; + } + } + +} \ No newline at end of file diff --git a/structurizr-client/src/com/structurizr/api/ApiResponse.java b/structurizr-client/src/main/java/com/structurizr/api/ApiResponse.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/ApiResponse.java rename to structurizr-client/src/main/java/com/structurizr/api/ApiResponse.java diff --git a/structurizr-client/src/com/structurizr/api/HashBasedMessageAuthenticationCode.java b/structurizr-client/src/main/java/com/structurizr/api/HashBasedMessageAuthenticationCode.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/HashBasedMessageAuthenticationCode.java rename to structurizr-client/src/main/java/com/structurizr/api/HashBasedMessageAuthenticationCode.java diff --git a/structurizr-client/src/com/structurizr/api/HmacAuthorizationHeader.java b/structurizr-client/src/main/java/com/structurizr/api/HmacAuthorizationHeader.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/HmacAuthorizationHeader.java rename to structurizr-client/src/main/java/com/structurizr/api/HmacAuthorizationHeader.java diff --git a/structurizr-client/src/com/structurizr/api/HmacContent.java b/structurizr-client/src/main/java/com/structurizr/api/HmacContent.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/HmacContent.java rename to structurizr-client/src/main/java/com/structurizr/api/HmacContent.java diff --git a/structurizr-client/src/com/structurizr/api/HttpHeaders.java b/structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/HttpHeaders.java rename to structurizr-client/src/main/java/com/structurizr/api/HttpHeaders.java diff --git a/structurizr-client/src/com/structurizr/api/Md5Digest.java b/structurizr-client/src/main/java/com/structurizr/api/Md5Digest.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/Md5Digest.java rename to structurizr-client/src/main/java/com/structurizr/api/Md5Digest.java diff --git a/structurizr-client/src/com/structurizr/api/StructurizrClientException.java b/structurizr-client/src/main/java/com/structurizr/api/StructurizrClientException.java similarity index 100% rename from structurizr-client/src/com/structurizr/api/StructurizrClientException.java rename to structurizr-client/src/main/java/com/structurizr/api/StructurizrClientException.java diff --git a/structurizr-client/src/com/structurizr/api/StructurizrClient.java b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceApiClient.java similarity index 70% rename from structurizr-client/src/com/structurizr/api/StructurizrClient.java rename to structurizr-client/src/main/java/com/structurizr/api/WorkspaceApiClient.java index 1368170b6..f45d0bdb6 100644 --- a/structurizr-client/src/com/structurizr/api/StructurizrClient.java +++ b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceApiClient.java @@ -22,47 +22,31 @@ import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.StringReader; -import java.io.StringWriter; +import java.io.*; import java.net.InetAddress; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.Date; -import java.util.Properties; /** - * A client for the Structurizr API (https://api.structurizr.com) - * that allows you to get and put Structurizr workspaces in a JSON format. + * A client for the Structurizr workspace API that allows you to get and put Structurizr workspaces in a JSON format. */ -public final class StructurizrClient { +public class WorkspaceApiClient extends AbstractApiClient { - private static final Log log = LogFactory.getLog(StructurizrClient.class); + private static final Log log = LogFactory.getLog(WorkspaceApiClient.class); + private static final String MAIN_BRANCH = "main"; - private static final String VERSION = Package.getPackage("com.structurizr.api").getImplementationVersion(); - private static final String STRUCTURIZR_FOR_JAVA_AGENT = "structurizr-java/" + VERSION; - - private static final String STRUCTURIZR_CLOUD_API_URL = "https://api.structurizr.com"; - - private static final String STRUCTURIZR_API_URL = "structurizr.api.url"; - private static final String STRUCTURIZR_API_KEY = "structurizr.api.key"; - private static final String STRUCTURIZR_API_SECRET = "structurizr.api.secret"; - - private static final String WORKSPACE_PATH = "/workspace/"; - - private String agent = STRUCTURIZR_FOR_JAVA_AGENT; private String user; - private String url; private String apiKey; private String apiSecret; + private String branch = ""; private EncryptionStrategy encryptionStrategy; @@ -70,43 +54,17 @@ public final class StructurizrClient { private boolean mergeFromRemote = true; private File workspaceArchiveLocation = new File("."); - /** - * Creates a new Structurizr client based upon configuration in a structurizr.properties file - * on the classpath with the following name-value pairs: - * - structurizr.api.url - * - structurizr.api.key - * - structurizr.api.secret - * - * @throws StructurizrClientException if something goes wrong - */ - public StructurizrClient() throws StructurizrClientException { - try (InputStream in = - StructurizrClient.class.getClassLoader().getResourceAsStream("structurizr.properties")) { - Properties properties = new Properties(); - if (in != null) { - properties.load(in); - - setUrl(properties.getProperty(STRUCTURIZR_API_URL)); - setApiKey(properties.getProperty(STRUCTURIZR_API_KEY)); - setApiSecret(properties.getProperty(STRUCTURIZR_API_SECRET)); - } else { - throw new StructurizrClientException("Could not find a structurizr.properties file on the classpath."); - } - } catch (IOException e) { - log.error(e); - throw new StructurizrClientException(e); - } + protected WorkspaceApiClient() { } /** - * Creates a new Structurizr API client with the specified API key and secret, - * for the default API URL (https://api.structurizr.com). + * Creates a new Structurizr API client with the specified API key and secret, for the Structurizr cloud service. * * @param apiKey the API key of your workspace * @param apiSecret the API secret of your workspace */ - public StructurizrClient(String apiKey, String apiSecret) { - this(STRUCTURIZR_CLOUD_API_URL, apiKey, apiSecret); + public WorkspaceApiClient(String apiKey, String apiSecret) { + this(STRUCTURIZR_CLOUD_SERVICE_API_URL, apiKey, apiSecret); } /** @@ -116,7 +74,7 @@ public StructurizrClient(String apiKey, String apiSecret) { * @param apiKey the API key of your workspace * @param apiSecret the API secret of your workspace */ - public StructurizrClient(String url, String apiKey, String apiSecret) { + public WorkspaceApiClient(String url, String apiKey, String apiSecret) { setUrl(url); setApiKey(apiKey); setApiSecret(apiSecret); @@ -131,54 +89,11 @@ public void setIdGenerator(IdGenerator idGenerator) { this.idGenerator = idGenerator; } - /** - * Gets the agent string used to identify this client instance. - * - * @return "structurizr-java/{version}", unless overridden - */ - public String getAgent() { - return agent; - } - - /** - * Sets the agent string used to identify this client instance. - * - * @param agent the agent string - */ - public void setAgent(String agent) { - if (StringUtils.isNullOrEmpty(agent)) { - throw new IllegalArgumentException("An agent must be provided."); - } - - this.agent = agent.trim(); - } - - /** - * Gets the API URL that this client is for. - * - * @return the API URL, as a String - */ - public String getUrl() { - return this.url; - } - - private void setUrl(String url) { - if (url == null || url.trim().length() == 0) { - throw new IllegalArgumentException("The API URL must not be null or empty."); - } - - if (url.endsWith("/")) { - this.url = url.substring(0, url.length() - 1); - } else { - this.url = url; - } - } - String getApiKey() { return apiKey; } - private void setApiKey(String apiKey) { + protected void setApiKey(String apiKey) { if (apiKey == null || apiKey.trim().length() == 0) { throw new IllegalArgumentException("The API key must not be null or empty."); } @@ -190,7 +105,7 @@ String getApiSecret() { return apiSecret; } - private void setApiSecret(String apiSecret) { + protected void setApiSecret(String apiSecret) { if (apiSecret == null || apiSecret.trim().length() == 0) { throw new IllegalArgumentException("The API secret must not be null or empty."); } @@ -198,6 +113,14 @@ private void setApiSecret(String apiSecret) { this.apiSecret = apiSecret; } + public String getBranch() { + return branch; + } + + public void setBranch(String branch) { + this.branch = branch; + } + /** * Gets the location where a copy of the workspace is archived when it is retrieved from the server. * @@ -269,21 +192,20 @@ private boolean manageLockForWorkspace(long workspaceId, boolean lock) throws St if (lock) { log.info("Locking workspace with ID " + workspaceId); - httpRequest = new HttpPut(url + WORKSPACE_PATH + workspaceId + "/lock?user=" + getUser() + "&agent=" + agent); + httpRequest = new HttpPut(url + WORKSPACE_PATH + "/" + workspaceId + "/lock?user=" + getUser() + "&agent=" + agent); } else { log.info("Unlocking workspace with ID " + workspaceId); - httpRequest = new HttpDelete(url + WORKSPACE_PATH + workspaceId + "/lock?user=" + getUser() + "&agent=" + agent); + httpRequest = new HttpDelete(url + WORKSPACE_PATH + "/" + workspaceId + "/lock?user=" + getUser() + "&agent=" + agent); } addHeaders(httpRequest, "", ""); debugRequest(httpRequest, null); try (CloseableHttpResponse response = httpClient.execute(httpRequest)) { - debugResponse(response); + String json = EntityUtils.toString(response.getEntity()); + debugResponse(response, json); - String responseText = EntityUtils.toString(response.getEntity()); - ApiResponse apiResponse = ApiResponse.parse(responseText); - log.info(responseText); + ApiResponse apiResponse = ApiResponse.parse(json); if (response.getCode() == HttpStatus.SC_OK) { return apiResponse.isSuccess(); @@ -305,44 +227,69 @@ private boolean manageLockForWorkspace(long workspaceId, boolean lock) throws St * @throws StructurizrClientException if there are problems related to the network, authorization, JSON deserialization, etc */ public Workspace getWorkspace(long workspaceId) throws StructurizrClientException { + String json = getWorkspaceAsJson(workspaceId); + + try { + if (encryptionStrategy == null) { + if (json.contains("\"encryptionStrategy\"") && json.contains("\"ciphertext\"")) { + log.warn("The JSON may contain a client-side encrypted workspace, but no passphrase has been specified."); + } + + JsonReader jsonReader = new JsonReader(); + jsonReader.setIdGenerator(idGenerator); + return jsonReader.read(new StringReader(json)); + } else { + EncryptedWorkspace encryptedWorkspace = new EncryptedJsonReader().read(new StringReader(json)); + + if (encryptedWorkspace.getEncryptionStrategy() != null) { + encryptedWorkspace.getEncryptionStrategy().setPassphrase(encryptionStrategy.getPassphrase()); + return encryptedWorkspace.getWorkspace(); + } else { + // this workspace isn't encrypted, even though the client has an encryption strategy set + JsonReader jsonReader = new JsonReader(); + jsonReader.setIdGenerator(idGenerator); + return jsonReader.read(new StringReader(json)); + } + } + } catch (Exception e) { + log.error(e); + throw new StructurizrClientException(e); + } + } + + /** + * Gets the workspace with the given ID, as a JSON string. + * + * @param workspaceId the workspace ID + * @return a JSON string + * @throws StructurizrClientException if there are problems related to the network, authorization, JSON deserialization, etc + */ + public String getWorkspaceAsJson(long workspaceId) throws StructurizrClientException { if (workspaceId <= 0) { throw new IllegalArgumentException("The workspace ID must be a positive integer."); } try (CloseableHttpClient httpClient = HttpClients.createSystem()) { log.info("Getting workspace with ID " + workspaceId); - HttpGet httpGet = new HttpGet(url + WORKSPACE_PATH + workspaceId); + + HttpGet httpGet; + if (StringUtils.isNullOrEmpty(branch) || branch.equalsIgnoreCase(MAIN_BRANCH)) { + httpGet = new HttpGet(url + WORKSPACE_PATH + "/" + workspaceId); + } else { + httpGet = new HttpGet(url + WORKSPACE_PATH + "/" + workspaceId + "/branch/" + branch); + } + addHeaders(httpGet, "", ""); debugRequest(httpGet, null); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { - debugResponse(response); - String json = EntityUtils.toString(response.getEntity()); + debugResponse(response, json); + if (response.getCode() == HttpStatus.SC_OK) { archiveWorkspace(workspaceId, json); - if (encryptionStrategy == null) { - if (json.contains("\"encryptionStrategy\"") && json.contains("\"ciphertext\"")) { - log.warn("The JSON may contain a client-side encrypted workspace, but no passphrase has been specified."); - } - - JsonReader jsonReader = new JsonReader(); - jsonReader.setIdGenerator(idGenerator); - return jsonReader.read(new StringReader(json)); - } else { - EncryptedWorkspace encryptedWorkspace = new EncryptedJsonReader().read(new StringReader(json)); - - if (encryptedWorkspace.getEncryptionStrategy() != null) { - encryptedWorkspace.getEncryptionStrategy().setPassphrase(encryptionStrategy.getPassphrase()); - return encryptedWorkspace.getWorkspace(); - } else { - // this workspace isn't encrypted, even though the client has an encryption strategy set - JsonReader jsonReader = new JsonReader(); - jsonReader.setIdGenerator(idGenerator); - return jsonReader.read(new StringReader(json)); - } - } + return json; } else { ApiResponse apiResponse = ApiResponse.parse(json); throw new StructurizrClientException(apiResponse.getMessage()); @@ -383,9 +330,12 @@ public void putWorkspace(long workspaceId, Workspace workspace) throws Structuri workspace.setLastModifiedAgent(agent); workspace.setLastModifiedUser(getUser()); - workspace.countAndLogWarnings(); - - HttpPut httpPut = new HttpPut(url + WORKSPACE_PATH + workspaceId); + HttpPut httpPut; + if (StringUtils.isNullOrEmpty(branch) || branch.equalsIgnoreCase(MAIN_BRANCH)) { + httpPut = new HttpPut(url + WORKSPACE_PATH + "/" + workspaceId); + } else { + httpPut = new HttpPut(url + WORKSPACE_PATH + "/" + workspaceId + "/branch/" + branch); + } StringWriter stringWriter = new StringWriter(); if (encryptionStrategy == null) { @@ -407,10 +357,9 @@ public void putWorkspace(long workspaceId, Workspace workspace) throws Structuri log.info("Putting workspace with ID " + workspaceId); try (CloseableHttpResponse response = httpClient.execute(httpPut)) { String json = EntityUtils.toString(response.getEntity()); - if (response.getCode() == HttpStatus.SC_OK) { - debugResponse(response); - log.info(json); - } else { + debugResponse(response, json); + + if (response.getCode() != HttpStatus.SC_OK) { ApiResponse apiResponse = ApiResponse.parse(json); throw new StructurizrClientException(apiResponse.getMessage()); } @@ -423,19 +372,29 @@ public void putWorkspace(long workspaceId, Workspace workspace) throws Structuri private void debugRequest(HttpUriRequestBase httpRequest, String content) { if (log.isDebugEnabled()) { - log.debug(httpRequest.getMethod() + " " + httpRequest.getPath()); + log.debug("Request"); + log.debug("HTTP method: " + httpRequest.getMethod()); + log.debug("Path: " + httpRequest.getPath()); Header[] headers = httpRequest.getHeaders(); for (Header header : headers) { - log.debug(header.getName() + ": " + header.getValue()); + log.debug("Header: " + header.getName() + "=" + header.getValue()); } if (content != null) { + log.debug("---Start content---"); log.debug(content); + log.debug("---End content---"); } } } - private void debugResponse(CloseableHttpResponse response) { - log.debug(response.getCode()); + private void debugResponse(CloseableHttpResponse response, String content) { + log.debug("Response"); + log.debug("HTTP status code: " + response.getCode()); + if (content != null) { + log.debug("---Start content---"); + log.debug(content); + log.debug("---End content---"); + } } private void addHeaders(HttpUriRequestBase httpRequest, String content, String contentType) throws Exception { @@ -451,7 +410,7 @@ private void addHeaders(HttpUriRequestBase httpRequest, String content, String c httpRequest.addHeader(HttpHeaders.NONCE, nonce); if (httpMethod.equals("PUT")) { - httpRequest.addHeader(HttpHeaders.CONTENT_MD5, Base64.getEncoder().encodeToString(contentMd5.getBytes("UTF-8"))); + httpRequest.addHeader(HttpHeaders.CONTENT_MD5, Base64.getEncoder().encodeToString(contentMd5.getBytes(StandardCharsets.UTF_8))); httpRequest.addHeader(HttpHeaders.CONTENT_TYPE, contentType); } } @@ -484,7 +443,7 @@ private void debugArchivedWorkspaceLocation(File archiveFile) { private String createArchiveFileName(long workspaceId) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); - return "structurizr-" + workspaceId + "-" + sdf.format(new Date()) + ".json"; + return "structurizr-" + workspaceId + "-" + (StringUtils.isNullOrEmpty(branch) ? "" : (branch + "-")) + sdf.format(new Date()) + ".json"; } public void setUser(String user) { diff --git a/structurizr-client/src/main/java/com/structurizr/api/WorkspaceMetadata.java b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceMetadata.java new file mode 100644 index 000000000..c6c5e1bba --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceMetadata.java @@ -0,0 +1,102 @@ +package com.structurizr.api; + +public class WorkspaceMetadata { + + private int id; + private String name; + private String description; + private String apiKey; + private String apiSecret; + + private String privateUrl; + private String publicUrl; + private String shareableUrl; + + private String[] branches; + + private WorkspaceUsers users; + + WorkspaceMetadata() { + } + + public int getId() { + return id; + } + + void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + void setDescription(String description) { + this.description = description; + } + + public String getApiKey() { + return apiKey; + } + + void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getApiSecret() { + return apiSecret; + } + + void setApiSecret(String apiSecret) { + this.apiSecret = apiSecret; + } + + public String getPrivateUrl() { + return privateUrl; + } + + void setPrivateUrl(String privateUrl) { + this.privateUrl = privateUrl; + } + + public String getPublicUrl() { + return publicUrl; + } + + void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } + + public String getShareableUrl() { + return shareableUrl; + } + + void setShareableUrl(String shareableUrl) { + this.shareableUrl = shareableUrl; + } + + public String[] getBranches() { + return branches; + } + + void setBranches(String[] branches) { + this.branches = branches; + } + + public WorkspaceUsers getUsers() { + return users; + } + + void setUsers(WorkspaceUsers users) { + this.users = users; + } + +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/WorkspaceUsers.java b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceUsers.java new file mode 100644 index 000000000..1c94eb959 --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/api/WorkspaceUsers.java @@ -0,0 +1,42 @@ +package com.structurizr.api; + +public class WorkspaceUsers { + + private String owner; + private String[] admin; + private String[] write; + private String[] read; + + public String getOwner() { + return owner; + } + + void setOwner(String owner) { + this.owner = owner; + } + + public String[] getAdmin() { + return admin; + } + + void setAdmin(String[] admin) { + this.admin = admin; + } + + public String[] getWrite() { + return write; + } + + void setWrite(String[] write) { + this.write = write; + } + + public String[] getRead() { + return read; + } + + void setRead(String[] read) { + this.read = read; + } + +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/api/Workspaces.java b/structurizr-client/src/main/java/com/structurizr/api/Workspaces.java new file mode 100644 index 000000000..bf578ec89 --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/api/Workspaces.java @@ -0,0 +1,28 @@ +package com.structurizr.api; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +class Workspaces { + + private List workspaces; + + Workspaces() { + } + + List getWorkspaces() { + return new ArrayList<>(workspaces); + } + + void setWorkspaces(List workspaces) { + if (workspaces == null) { + this.workspaces = new ArrayList<>(); + } else { + this.workspaces = workspaces; + } + + this.workspaces.sort(Comparator.comparingInt(WorkspaceMetadata::getId)); + } + +} \ No newline at end of file diff --git a/structurizr-client/src/com/structurizr/encryption/AesEncryptionStrategy.java b/structurizr-client/src/main/java/com/structurizr/encryption/AesEncryptionStrategy.java similarity index 97% rename from structurizr-client/src/com/structurizr/encryption/AesEncryptionStrategy.java rename to structurizr-client/src/main/java/com/structurizr/encryption/AesEncryptionStrategy.java index 2279304e2..4f43b7862 100644 --- a/structurizr-client/src/com/structurizr/encryption/AesEncryptionStrategy.java +++ b/structurizr-client/src/main/java/com/structurizr/encryption/AesEncryptionStrategy.java @@ -7,6 +7,7 @@ import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; +import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; @@ -79,7 +80,7 @@ public String decrypt(String ciphertext) throws Exception { cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(DatatypeConverter.parseHexBinary(iv))); byte[] unencrypted = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); - return new String(unencrypted, "UTF-8"); + return new String(unencrypted, StandardCharsets.UTF_8); } private SecretKey createSecretKey() throws NoSuchAlgorithmException, InvalidKeySpecException { diff --git a/structurizr-client/src/com/structurizr/encryption/EncryptedWorkspace.java b/structurizr-client/src/main/java/com/structurizr/encryption/EncryptedWorkspace.java similarity index 98% rename from structurizr-client/src/com/structurizr/encryption/EncryptedWorkspace.java rename to structurizr-client/src/main/java/com/structurizr/encryption/EncryptedWorkspace.java index ba89d409e..7a62ebb94 100644 --- a/structurizr-client/src/com/structurizr/encryption/EncryptedWorkspace.java +++ b/structurizr-client/src/main/java/com/structurizr/encryption/EncryptedWorkspace.java @@ -55,7 +55,6 @@ private void init(Workspace workspace, String plaintext, EncryptionStrategy encr setName(workspace.getName()); setDescription(workspace.getDescription()); setVersion(workspace.getVersion()); - setRevision(workspace.getRevision()); setLastModifiedUser(workspace.getLastModifiedUser()); setLastModifiedAgent(workspace.getLastModifiedAgent()); diff --git a/structurizr-client/src/com/structurizr/encryption/EncryptionLocation.java b/structurizr-client/src/main/java/com/structurizr/encryption/EncryptionLocation.java similarity index 100% rename from structurizr-client/src/com/structurizr/encryption/EncryptionLocation.java rename to structurizr-client/src/main/java/com/structurizr/encryption/EncryptionLocation.java diff --git a/structurizr-client/src/com/structurizr/encryption/EncryptionStrategy.java b/structurizr-client/src/main/java/com/structurizr/encryption/EncryptionStrategy.java similarity index 100% rename from structurizr-client/src/com/structurizr/encryption/EncryptionStrategy.java rename to structurizr-client/src/main/java/com/structurizr/encryption/EncryptionStrategy.java diff --git a/structurizr-client/src/main/java/com/structurizr/http/HttpClient.java b/structurizr-client/src/main/java/com/structurizr/http/HttpClient.java new file mode 100644 index 000000000..b82a75a65 --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/http/HttpClient.java @@ -0,0 +1,129 @@ +package com.structurizr.http; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.core5.http.io.entity.EntityUtils; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Wrapper for the HTTPClient in Apache HttpComponents, with optional caching and allowed URLs (via regexes). + */ +public class HttpClient { + + public static final String CONTENT_TYPE_IMAGE_PNG = "image/png"; + + private static final int HTTP_OK_STATUS = 200; + + private int timeout = 10000; // milliseconds + private final Set allowedUrlRegexes = new HashSet<>(); + + private final Map contentCache = new HashMap<>(); + + public HttpClient() { + } + + /** + * Sets the timeout in milliseconds. + * + * @param timeoutInMilliseconds the timeout in milliseconds + */ + public void setTimeout(int timeoutInMilliseconds) { + if (timeoutInMilliseconds < 0) { + throw new IllegalArgumentException("Timeout must be a positive integer"); + } + + this.timeout = timeoutInMilliseconds; + } + + /** + * HTTP GET of a URL, without caching. + * + * @param url the URL, as a String + * @return a RemoteContent object representing the response + */ + public RemoteContent get(String url) { + return get(url, false); + } + + /** + * HTTP GET of a URL. + * + * @param url the URL, as a String + * @param cache true if the result should be cached, false otherwise + * @return a RemoteContent object representing the response + */ + public RemoteContent get(String url, boolean cache) { + if (!isAllowed(url)) { + throw new HttpClientException("Access to " + url + " is not permitted"); + } + + RemoteContent remoteContent = contentCache.get(url); + if (remoteContent == null) { + ConnectionConfig connectionConfig = ConnectionConfig.custom() + .setConnectTimeout(timeout, TimeUnit.MILLISECONDS) + .setSocketTimeout(timeout, TimeUnit.MILLISECONDS) + .build(); + + BasicHttpClientConnectionManager cm = new BasicHttpClientConnectionManager(); + cm.setConnectionConfig(connectionConfig); + + try (CloseableHttpClient httpClient = HttpClientBuilder.create() + .useSystemProperties() + .setConnectionManager(cm) + .build()) { + + HttpGet httpGet = new HttpGet(url); + CloseableHttpResponse response = httpClient.execute(httpGet); + + int httpStatus = response.getCode(); + if (httpStatus == HTTP_OK_STATUS) { + String contentType = response.getEntity().getContentType(); + if (CONTENT_TYPE_IMAGE_PNG.equals(contentType)) { + remoteContent = new RemoteContent(EntityUtils.toByteArray(response.getEntity()), contentType); + } else { + remoteContent = new RemoteContent(EntityUtils.toString(response.getEntity()), contentType); + } + + if (cache) { + contentCache.put(url, remoteContent); + } + } else { + throw new HttpClientException("The content from " + url + " could not be loaded: HTTP status=" + httpStatus); + } + } catch (Exception ioe) { + throw new HttpClientException("The content from " + url + " could not be loaded: " + ioe.getMessage()); + } + } + + return remoteContent; + } + + /** + * Adds an allowed URL regex. + * + * @param regex the regex to allow + */ + public void allow(String regex) { + allowedUrlRegexes.add(regex); + } + + private boolean isAllowed(String url) { + for (String regex : allowedUrlRegexes) { + if (url.matches(regex)) { + return true; + } + } + + return false; + } + +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/http/HttpClientException.java b/structurizr-client/src/main/java/com/structurizr/http/HttpClientException.java new file mode 100644 index 000000000..d298f3223 --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/http/HttpClientException.java @@ -0,0 +1,9 @@ +package com.structurizr.http; + +public class HttpClientException extends RuntimeException { + + public HttpClientException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/structurizr-client/src/main/java/com/structurizr/http/RemoteContent.java b/structurizr-client/src/main/java/com/structurizr/http/RemoteContent.java new file mode 100644 index 000000000..337668bee --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/http/RemoteContent.java @@ -0,0 +1,39 @@ +package com.structurizr.http; + +/** + * Wrapper for remote content loaded via HTTP. + */ +public final class RemoteContent { + + public static final String CONTENT_TYPE_JSON = "application/json"; + public static final String CONTENT_TYPE_PLAIN_TEXT = "text/plain"; + + private final String content; + private final byte[] bytes; + private final String contentType; + + RemoteContent(String content, String contentType) { + this.content = content; + this.bytes = null; + this.contentType = contentType; + } + + RemoteContent(byte[] content, String contentType) { + this.content = null; + this.bytes = content; + this.contentType = contentType; + } + + public String getContentAsString() { + return content; + } + + public byte[] getContentAsBytes() { + return bytes; + } + + public String getContentType() { + return contentType; + } + +} \ No newline at end of file diff --git a/structurizr-client/src/com/structurizr/io/WorkspaceReader.java b/structurizr-client/src/main/java/com/structurizr/io/WorkspaceReader.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/WorkspaceReader.java rename to structurizr-client/src/main/java/com/structurizr/io/WorkspaceReader.java diff --git a/structurizr-client/src/com/structurizr/io/WorkspaceReaderException.java b/structurizr-client/src/main/java/com/structurizr/io/WorkspaceReaderException.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/WorkspaceReaderException.java rename to structurizr-client/src/main/java/com/structurizr/io/WorkspaceReaderException.java diff --git a/structurizr-client/src/com/structurizr/io/WorkspaceWriter.java b/structurizr-client/src/main/java/com/structurizr/io/WorkspaceWriter.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/WorkspaceWriter.java rename to structurizr-client/src/main/java/com/structurizr/io/WorkspaceWriter.java diff --git a/structurizr-client/src/com/structurizr/io/WorkspaceWriterException.java b/structurizr-client/src/main/java/com/structurizr/io/WorkspaceWriterException.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/WorkspaceWriterException.java rename to structurizr-client/src/main/java/com/structurizr/io/WorkspaceWriterException.java diff --git a/structurizr-client/src/com/structurizr/io/json/AbstractJsonReader.java b/structurizr-client/src/main/java/com/structurizr/io/json/AbstractJsonReader.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/json/AbstractJsonReader.java rename to structurizr-client/src/main/java/com/structurizr/io/json/AbstractJsonReader.java diff --git a/structurizr-client/src/com/structurizr/io/json/AbstractJsonWriter.java b/structurizr-client/src/main/java/com/structurizr/io/json/AbstractJsonWriter.java similarity index 78% rename from structurizr-client/src/com/structurizr/io/json/AbstractJsonWriter.java rename to structurizr-client/src/main/java/com/structurizr/io/json/AbstractJsonWriter.java index 689039c0e..104b4155e 100644 --- a/structurizr-client/src/com/structurizr/io/json/AbstractJsonWriter.java +++ b/structurizr-client/src/main/java/com/structurizr/io/json/AbstractJsonWriter.java @@ -1,8 +1,10 @@ package com.structurizr.io.json; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.text.SimpleDateFormat; import java.util.TimeZone; @@ -12,7 +14,9 @@ class AbstractJsonWriter { private static final String ISO_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; ObjectMapper createObjectMapper(boolean indentOutput) { - ObjectMapper objectMapper = new ObjectMapper(); + ObjectMapper objectMapper = JsonMapper + .builder() + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY).build(); if (indentOutput) { objectMapper.enable(SerializationFeature.INDENT_OUTPUT); diff --git a/structurizr-client/src/com/structurizr/io/json/EncryptedJsonReader.java b/structurizr-client/src/main/java/com/structurizr/io/json/EncryptedJsonReader.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/json/EncryptedJsonReader.java rename to structurizr-client/src/main/java/com/structurizr/io/json/EncryptedJsonReader.java diff --git a/structurizr-client/src/com/structurizr/io/json/EncryptedJsonWriter.java b/structurizr-client/src/main/java/com/structurizr/io/json/EncryptedJsonWriter.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/json/EncryptedJsonWriter.java rename to structurizr-client/src/main/java/com/structurizr/io/json/EncryptedJsonWriter.java diff --git a/structurizr-client/src/com/structurizr/io/json/JsonReader.java b/structurizr-client/src/main/java/com/structurizr/io/json/JsonReader.java similarity index 96% rename from structurizr-client/src/com/structurizr/io/json/JsonReader.java rename to structurizr-client/src/main/java/com/structurizr/io/json/JsonReader.java index c85ee6665..3cca175a8 100644 --- a/structurizr-client/src/com/structurizr/io/json/JsonReader.java +++ b/structurizr-client/src/main/java/com/structurizr/io/json/JsonReader.java @@ -5,7 +5,6 @@ import com.structurizr.io.WorkspaceReader; import com.structurizr.io.WorkspaceReaderException; import com.structurizr.model.IdGenerator; -import com.structurizr.model.SequentialIntegerIdGeneratorStrategy; import java.io.IOException; import java.io.Reader; diff --git a/structurizr-client/src/com/structurizr/io/json/JsonWriter.java b/structurizr-client/src/main/java/com/structurizr/io/json/JsonWriter.java similarity index 100% rename from structurizr-client/src/com/structurizr/io/json/JsonWriter.java rename to structurizr-client/src/main/java/com/structurizr/io/json/JsonWriter.java diff --git a/structurizr-client/src/com/structurizr/util/WorkspaceUtils.java b/structurizr-client/src/main/java/com/structurizr/util/WorkspaceUtils.java similarity index 100% rename from structurizr-client/src/com/structurizr/util/WorkspaceUtils.java rename to structurizr-client/src/main/java/com/structurizr/util/WorkspaceUtils.java diff --git a/structurizr-client/src/main/java/com/structurizr/view/ThemeUtils.java b/structurizr-client/src/main/java/com/structurizr/view/ThemeUtils.java new file mode 100644 index 000000000..869c7bd5f --- /dev/null +++ b/structurizr-client/src/main/java/com/structurizr/view/ThemeUtils.java @@ -0,0 +1,182 @@ +package com.structurizr.view; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.http.RemoteContent; +import com.structurizr.io.WorkspaceWriterException; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.util.Url; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +/** + * Some utility methods for exporting themes to JSON. + */ +public final class ThemeUtils { + + private static final int DEFAULT_TIMEOUT_IN_MILLISECONDS = 10000; + + /** + * Serializes the theme (element and relationship styles) in the specified workspace to a file, as a JSON string. + * + * @param workspace a Workspace object + * @param file a File representing the JSON definition + * @throws Exception if something goes wrong + */ + public static void toJson(Workspace workspace, File file) throws Exception { + if (workspace == null) { + throw new IllegalArgumentException("A workspace must be provided."); + } else if (file == null) { + throw new IllegalArgumentException("The path to a file must be specified."); + } + + OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8); + write(workspace, writer); + } + + /** + * Serializes the theme (element and relationship styles) in the specified workspace to a JSON string. + * + * @param workspace a Workspace instance + * @return a JSON string + * @throws Exception if something goes wrong + */ + public static String toJson(Workspace workspace) throws Exception { + if (workspace == null) { + throw new IllegalArgumentException("A workspace must be provided."); + } + + StringWriter writer = new StringWriter(); + write(workspace, writer); + + return writer.toString(); + } + + /** + * Loads the element and relationship styles from the themes defined in the workspace, into the workspace itself. + * This implementation simply copies the styles from all themes into the workspace. + * This uses a default timeout value of 10000ms. + * + * @param workspace a Workspace object + * @throws Exception if something goes wrong + */ + public static void loadThemes(Workspace workspace) throws Exception { + loadThemes(workspace, DEFAULT_TIMEOUT_IN_MILLISECONDS); + } + + /** + * Loads the element and relationship styles from the themes defined in the workspace, into the workspace itself. + * This implementation simply copies the styles from all themes into the workspace. + * + * @param workspace a Workspace object + * @param timeoutInMilliseconds the timeout in milliseconds + * @throws Exception if something goes wrong + */ + public static void loadThemes(Workspace workspace, int timeoutInMilliseconds) throws Exception { + HttpClient httpClient = new HttpClient(); + httpClient.setTimeout(timeoutInMilliseconds); + + loadThemes(workspace, httpClient); + } + + public static void loadThemes(Workspace workspace, HttpClient httpClient) throws Exception { + for (String themeLocation : workspace.getViews().getConfiguration().getThemes()) { + if (Url.isUrl(themeLocation)) { + RemoteContent remoteContent = httpClient.get(themeLocation); + if (remoteContent.getContentType().startsWith(RemoteContent.CONTENT_TYPE_JSON) || remoteContent.getContentType().startsWith(RemoteContent.CONTENT_TYPE_PLAIN_TEXT)) { + Theme theme = fromJson(remoteContent.getContentAsString()); + String baseUrl = themeLocation.substring(0, themeLocation.lastIndexOf('/') + 1); + + for (ElementStyle elementStyle : theme.getElements()) { + String icon = elementStyle.getIcon(); + if (!StringUtils.isNullOrEmpty(icon)) { + if (Url.isHttpUrl(icon) || Url.isHttpsUrl(icon)) { + // okay, image served over HTTP or HTTPS + } else if (icon.startsWith("data:image")) { + // also okay, data URI + } else { + // convert the relative icon filename into a full URL + elementStyle.setIcon(baseUrl + icon); + } + } + } + + workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(theme); + } else { + throw new RuntimeException(String.format("%s - expected content type of %s, actual content type is %s", themeLocation, RemoteContent.CONTENT_TYPE_JSON, remoteContent.getContentType())); + } + } + } + } + + /** + * Inlines the element and relationship styles from the specified file, adding the styles into the workspace + * and overriding any properties already set. + * + * @param workspace the Workspace to load the theme into + * @param file a File object representing a theme (a JSON file) + * @throws Exception if something goes wrong + */ + public static void inlineTheme(Workspace workspace, File file) throws Exception { + String json = Files.readString(file.toPath()); + Theme theme = fromJson(json); + + for (ElementStyle elementStyle : theme.getElements()) { + String icon = elementStyle.getIcon(); + if (!StringUtils.isNullOrEmpty(icon)) { + if (icon.startsWith("http")) { + // okay, image served over HTTP + } else if (icon.startsWith("data:image")) { + // also okay, data URI + } else { + // convert the relative icon filename into a data URI + elementStyle.setIcon(ImageUtils.getImageAsDataUri(new File(file.getParentFile(), icon))); + } + } + } + + workspace.getViews().getConfiguration().getStyles().inlineTheme(theme); + } + + private static Theme fromJson(String json) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + return objectMapper.readValue(json, Theme.class); + } + + private static void write(Workspace workspace, Writer writer) throws Exception { + try { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + + Theme theme = new Theme( + workspace.getName(), + workspace.getDescription(), + workspace.getViews().getConfiguration().getStyles().getElements(), + workspace.getViews().getConfiguration().getStyles().getRelationships() + ); + theme.setFont(workspace.getViews().getConfiguration().getBranding().getFont()); + theme.setLogo(workspace.getViews().getConfiguration().getBranding().getLogo()); + + writer.write(objectMapper.writeValueAsString(theme)); + } catch (IOException ioe) { + throw new WorkspaceWriterException("Could not write the theme as JSON", ioe); + } + + writer.flush(); + writer.close(); + } + +} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/api/ApiResponseTests.java b/structurizr-client/src/test/java/com/structurizr/api/ApiResponseTests.java similarity index 54% rename from structurizr-client/test/unit/com/structurizr/api/ApiResponseTests.java rename to structurizr-client/src/test/java/com/structurizr/api/ApiResponseTests.java index b0f088049..d3546f95a 100644 --- a/structurizr-client/test/unit/com/structurizr/api/ApiResponseTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/ApiResponseTests.java @@ -1,13 +1,13 @@ package com.structurizr.api; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class ApiResponseTests { @Test - public void test_parse_createsAnApiErrorObjectWithTheSpecifiedErrorMessage() throws Exception { + void parse_createsAnApiErrorObjectWithTheSpecifiedErrorMessage() throws Exception { ApiResponse apiResponse = ApiResponse.parse("{\"message\": \"Hello\"}"); assertEquals("Hello", apiResponse.getMessage()); } diff --git a/structurizr-client/src/test/java/com/structurizr/api/BackwardsCompatibilityTests.java b/structurizr-client/src/test/java/com/structurizr/api/BackwardsCompatibilityTests.java new file mode 100644 index 000000000..58933c9c0 --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/api/BackwardsCompatibilityTests.java @@ -0,0 +1,41 @@ +package com.structurizr.api; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.util.WorkspaceUtils; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BackwardsCompatibilityTests { + + private static final File PATH_TO_WORKSPACE_FILES = new File("./src/test/resources/backwardsCompatibility"); + + @Test + void test() throws Exception { + for (File file : PATH_TO_WORKSPACE_FILES.listFiles(f -> f.getName().endsWith(".json"))) { + WorkspaceUtils.loadWorkspaceFromJson(file); + } + } + + @Test + void documentation() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getDocumentation().addSection(new Section(Format.Markdown, "## Heading 1")); + + assertEquals(""" + {"configuration":{},"description":"Description","documentation":{"sections":[{"content":"## Heading 1","format":"Markdown","order":1,"title":""}]},"id":0,"model":{},"name":"Name","views":{"configuration":{"branding":{},"styles":{},"terminology":{}}}}""", WorkspaceUtils.toJson(workspace, false)); + } + + @Test + void viewsWithoutOrderProperties() throws Exception { + File file = new File(PATH_TO_WORKSPACE_FILES, "views-without-order.json"); + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(file); + + assertEquals(2, workspace.getViews().getSystemLandscapeViews().size()); + } + +} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java b/structurizr-client/src/test/java/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java similarity index 78% rename from structurizr-client/test/unit/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java rename to structurizr-client/src/test/java/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java index 4ac054a91..539d7a733 100644 --- a/structurizr-client/test/unit/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/HashBasedMessageAuthenticationCodeTests.java @@ -1,15 +1,15 @@ package com.structurizr.api; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class HashBasedMessageAuthenticationCodeTests { private HashBasedMessageAuthenticationCode code; @Test - public void test_generate() throws Exception { + void generate() throws Exception { // this example is taken from http://en.wikipedia.org/wiki/Hash-based_message_authentication_code code = new HashBasedMessageAuthenticationCode("key"); assertEquals("f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", code.generate("The quick brown fox jumps over the lazy dog")); diff --git a/structurizr-client/test/unit/com/structurizr/api/HmacAuthorizationHeaderTests.java b/structurizr-client/src/test/java/com/structurizr/api/HmacAuthorizationHeaderTests.java similarity index 84% rename from structurizr-client/test/unit/com/structurizr/api/HmacAuthorizationHeaderTests.java rename to structurizr-client/src/test/java/com/structurizr/api/HmacAuthorizationHeaderTests.java index abf23d95c..86c79a6c6 100644 --- a/structurizr-client/test/unit/com/structurizr/api/HmacAuthorizationHeaderTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/HmacAuthorizationHeaderTests.java @@ -1,21 +1,21 @@ package com.structurizr.api; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class HmacAuthorizationHeaderTests { private HmacAuthorizationHeader header; @Test - public void test_format() { + void format() { header = new HmacAuthorizationHeader("apiKey", "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"); assertEquals("apiKey:ZjdiYzgzZjQzMDUzODQyNGIxMzI5OGU2YWE2ZmIxNDNlZjRkNTlhMTQ5NDYxNzU5OTc0NzlkYmMyZDFhM2NkOA==", header.format()); } @Test - public void test_parse() { + void parse() { header = HmacAuthorizationHeader.parse("apiKey:ZjdiYzgzZjQzMDUzODQyNGIxMzI5OGU2YWE2ZmIxNDNlZjRkNTlhMTQ5NDYxNzU5OTc0NzlkYmMyZDFhM2NkOA=="); assertEquals("apiKey", header.getApiKey()); assertEquals("f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", header.getHmac()); diff --git a/structurizr-client/test/unit/com/structurizr/api/HmacContentTests.java b/structurizr-client/src/test/java/com/structurizr/api/HmacContentTests.java similarity index 59% rename from structurizr-client/test/unit/com/structurizr/api/HmacContentTests.java rename to structurizr-client/src/test/java/com/structurizr/api/HmacContentTests.java index 7397d0ed0..ba81a6b96 100644 --- a/structurizr-client/test/unit/com/structurizr/api/HmacContentTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/HmacContentTests.java @@ -1,19 +1,19 @@ package com.structurizr.api; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class HmacContentTests { @Test - public void test_toString_WhenThereAreNoStrings() { + void toString_WhenThereAreNoStrings() { assertEquals("", new HmacContent().toString()); } @Test - public void test_toString_WhenThereAreSomeStrings() { + void toString_WhenThereAreSomeStrings() { assertEquals("String1\nString2\nString3\n", new HmacContent("String1", "String2", "String3").toString()); } diff --git a/structurizr-client/test/unit/com/structurizr/api/Md5DigestTests.java b/structurizr-client/src/test/java/com/structurizr/api/Md5DigestTests.java similarity index 64% rename from structurizr-client/test/unit/com/structurizr/api/Md5DigestTests.java rename to structurizr-client/src/test/java/com/structurizr/api/Md5DigestTests.java index 6cf3d737f..220270830 100644 --- a/structurizr-client/test/unit/com/structurizr/api/Md5DigestTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/Md5DigestTests.java @@ -1,20 +1,20 @@ package com.structurizr.api; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class Md5DigestTests { private Md5Digest md5 = new Md5Digest(); @Test - public void test_generate_TreatsNullAsEmptyContent() throws Exception { + void generate_TreatsNullAsEmptyContent() throws Exception { assertEquals(md5.generate(null), md5.generate("")); } @Test - public void test_generate() throws Exception { + void generate() throws Exception { assertEquals("ed076287532e86365e841e92bfc50d8c", md5.generate("Hello World!")); assertEquals("d41d8cd98f00b204e9800998ecf8427e", md5.generate("")); } diff --git a/structurizr-client/test/integration/com/structurizr/api/StructurizrClientIntegrationTests.java b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientIntegrationTests.java similarity index 72% rename from structurizr-client/test/integration/com/structurizr/api/StructurizrClientIntegrationTests.java rename to structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientIntegrationTests.java index d0c16ef78..3b4628c6c 100644 --- a/structurizr-client/test/integration/com/structurizr/api/StructurizrClientIntegrationTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientIntegrationTests.java @@ -8,32 +8,33 @@ import com.structurizr.model.Person; import com.structurizr.model.SoftwareSystem; import com.structurizr.view.SystemContextView; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import java.io.File; import java.io.FileReader; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; -public class StructurizrClientIntegrationTests { +public class WorkspaceApiClientIntegrationTests { - private StructurizrClient structurizrClient; - private File workspaceArchiveLocation = new File(System.getProperty("java.io.tmpdir"), "structurizr"); + private WorkspaceApiClient client; + private final File workspaceArchiveLocation = new File(System.getProperty("java.io.tmpdir"), "structurizr"); - @Before - public void setUp() { - structurizrClient = new StructurizrClient("81ace434-94a1-486f-a786-37bbeaa44e08", "a8673e21-7b6f-4f52-be65-adb7248be86b"); - structurizrClient.setWorkspaceArchiveLocation(workspaceArchiveLocation); + @BeforeEach + void setUp() { + client = new WorkspaceApiClient("81ace434-94a1-486f-a786-37bbeaa44e08", "a8673e21-7b6f-4f52-be65-adb7248be86b"); + client.setWorkspaceArchiveLocation(workspaceArchiveLocation); workspaceArchiveLocation.mkdirs(); clearWorkspaceArchive(); assertEquals(0, workspaceArchiveLocation.listFiles().length); - structurizrClient.setMergeFromRemote(false); + client.setMergeFromRemote(false); } - @After - public void tearDown() { + @AfterEach + void tearDown() { clearWorkspaceArchive(); workspaceArchiveLocation.delete(); } @@ -51,7 +52,8 @@ private File getArchivedWorkspace() { } @Test - public void test_putAndGetWorkspace_WithoutEncryption() throws Exception { + @Tag("IntegrationTest") + void putAndGetWorkspace_WithoutEncryption() throws Exception { Workspace workspace = new Workspace("Structurizr client library tests - without encryption", "A test workspace for the Structurizr client library"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); Person person = workspace.getModel().addPerson("Person", "Description"); @@ -59,9 +61,9 @@ public void test_putAndGetWorkspace_WithoutEncryption() throws Exception { SystemContextView systemContextView = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", "Description"); systemContextView.addAllElements(); - structurizrClient.putWorkspace(20081, workspace); + client.putWorkspace(20081, workspace); - workspace = structurizrClient.getWorkspace(20081); + workspace = client.getWorkspace(20081); assertNotNull(workspace.getModel().getSoftwareSystemWithName("Software System")); assertNotNull(workspace.getModel().getPersonWithName("Person")); assertEquals(1, workspace.getModel().getRelationships().size()); @@ -77,8 +79,9 @@ public void test_putAndGetWorkspace_WithoutEncryption() throws Exception { } @Test - public void test_putAndGetWorkspace_WithEncryption() throws Exception { - structurizrClient.setEncryptionStrategy(new AesEncryptionStrategy("password")); + @Tag("IntegrationTest") + void putAndGetWorkspace_WithEncryption() throws Exception { + client.setEncryptionStrategy(new AesEncryptionStrategy("password")); Workspace workspace = new Workspace("Structurizr client library tests - with encryption", "A test workspace for the Structurizr client library"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); Person person = workspace.getModel().addPerson("Person", "Description"); @@ -86,9 +89,9 @@ public void test_putAndGetWorkspace_WithEncryption() throws Exception { SystemContextView systemContextView = workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", "Description"); systemContextView.addAllElements(); - structurizrClient.putWorkspace(20081, workspace); + client.putWorkspace(20081, workspace); - workspace = structurizrClient.getWorkspace(20081); + workspace = client.getWorkspace(20081); assertNotNull(workspace.getModel().getSoftwareSystemWithName("Software System")); assertNotNull(workspace.getModel().getPersonWithName("Person")); assertEquals(1, workspace.getModel().getRelationships().size()); @@ -104,16 +107,18 @@ public void test_putAndGetWorkspace_WithEncryption() throws Exception { } @Test - public void test_lockWorkspace() throws Exception { - structurizrClient.unlockWorkspace(20081); - assertTrue(structurizrClient.lockWorkspace(20081)); + @Tag("IntegrationTest") + void lockWorkspace() throws Exception { + client.unlockWorkspace(20081); + assertTrue(client.lockWorkspace(20081)); } @Test - public void test_unlockWorkspace() throws Exception { - structurizrClient.lockWorkspace(20081); - assertTrue(structurizrClient.unlockWorkspace(20081)); + @Tag("IntegrationTest") + void unlockWorkspace() throws Exception { + client.lockWorkspace(20081); + assertTrue(client.unlockWorkspace(20081)); } } \ No newline at end of file diff --git a/structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientTests.java b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientTests.java new file mode 100644 index 000000000..0e2aeca51 --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceApiClientTests.java @@ -0,0 +1,166 @@ +package com.structurizr.api; + +import com.structurizr.Workspace; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class WorkspaceApiClientTests { + + private WorkspaceApiClient client; + + @Test + void construction_WithTwoParameters() { + client = new WorkspaceApiClient("key", "secret"); + assertEquals("https://api.structurizr.com", client.getUrl()); + assertEquals("key", client.getApiKey()); + assertEquals("secret", client.getApiSecret()); + } + + @Test + void construction_WithThreeParameters() { + client = new WorkspaceApiClient("https://localhost", "key", "secret"); + assertEquals("https://localhost", client.getUrl()); + assertEquals("key", client.getApiKey()); + assertEquals("secret", client.getApiSecret()); + } + + @Test + void construction_WithThreeParameters_TruncatesTheApiUrl_WhenTheApiUrlHasATrailingSlashCharacter() { + client = new WorkspaceApiClient("https://localhost/", "key", "secret"); + assertEquals("https://localhost", client.getUrl()); + assertEquals("key", client.getApiKey()); + assertEquals("secret", client.getApiSecret()); + } + + @Test + void construction_ThrowsAnException_WhenANullApiKeyIsUsed() { + try { + client = new WorkspaceApiClient(null, "secret"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API key must not be null or empty.", iae.getMessage()); + } + } + + @Test + void construction_ThrowsAnException_WhenAnEmptyApiKeyIsUsed() { + try { + client = new WorkspaceApiClient(" ", "secret"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API key must not be null or empty.", iae.getMessage()); + } + } + + @Test + void construction_ThrowsAnException_WhenANullApiSecretIsUsed() { + try { + client = new WorkspaceApiClient("key", null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API secret must not be null or empty.", iae.getMessage()); + } + } + + @Test + void construction_ThrowsAnException_WhenAnEmptyApiSecretIsUsed() { + try { + client = new WorkspaceApiClient("key", " "); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API secret must not be null or empty.", iae.getMessage()); + } + } + + @Test + void construction_ThrowsAnException_WhenANullApiUrlIsUsed() { + try { + client = new WorkspaceApiClient(null, "key", "secret"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API URL must not be null or empty.", iae.getMessage()); + } + } + + @Test + void construction_ThrowsAnException_WhenAnEmptyApiUrlIsUsed() { + try { + client = new WorkspaceApiClient(" ", "key", "secret"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The API URL must not be null or empty.", iae.getMessage()); + } + } + + @Test + void getWorkspace_ThrowsAnException_WhenTheWorkspaceIdIsNotValid() throws Exception { + try { + client = new WorkspaceApiClient("key", "secret"); + client.getWorkspace(0); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The workspace ID must be a positive integer.", iae.getMessage()); + } + } + + @Test + void putWorkspace_ThrowsAnException_WhenTheWorkspaceIdIsNotValid() throws Exception { + try { + client = new WorkspaceApiClient("key", "secret"); + client.putWorkspace(0, new Workspace("Name", "Description")); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The workspace ID must be a positive integer.", iae.getMessage()); + } + } + + @Test + void putWorkspace_ThrowsAnException_WhenANullWorkspaceIsSpecified() throws Exception { + try { + client = new WorkspaceApiClient("key", "secret"); + client.putWorkspace(1234, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("The workspace must not be null.", iae.getMessage()); + } + } + + @Test + void getAgent() { + client = new WorkspaceApiClient("key", "secret"); + assertTrue(client.getAgent().startsWith("structurizr-java/")); + } + + @Test + void setAgent() { + client = new WorkspaceApiClient("key", "secret"); + client.setAgent("new_agent"); + assertEquals("new_agent", client.getAgent()); + } + + @Test + void setAgent_ThrowsAnException_WhenPassedNull() { + client = new WorkspaceApiClient("key", "secret"); + + try { + client.setAgent(null); + fail(); + } catch (Exception e) { + assertEquals("An agent must be provided.", e.getMessage()); + } + } + + @Test + void setAgent_ThrowsAnException_WhenPassedAnEmptyString() { + client = new WorkspaceApiClient("key", "secret"); + + try { + client.setAgent(" "); + fail(); + } catch (Exception e) { + assertEquals("An agent must be provided.", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-client/test/integration/com/structurizr/api/WorkspaceRulesValidationTests.java b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceRulesValidationTests.java similarity index 69% rename from structurizr-client/test/integration/com/structurizr/api/WorkspaceRulesValidationTests.java rename to structurizr-client/src/test/java/com/structurizr/api/WorkspaceRulesValidationTests.java index 88f8b9c08..6ff8ebe84 100644 --- a/structurizr-client/test/integration/com/structurizr/api/WorkspaceRulesValidationTests.java +++ b/structurizr-client/src/test/java/com/structurizr/api/WorkspaceRulesValidationTests.java @@ -2,40 +2,18 @@ import com.structurizr.WorkspaceValidationException; import com.structurizr.util.WorkspaceUtils; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.File; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class WorkspaceRulesValidationTests { - private static final File PATH_TO_WORKSPACE_FILES = new File("test/integration/workspaceValidation"); + private static final File PATH_TO_WORKSPACE_FILES = new File("./src/test/resources/workspaceValidation"); @Test - public void test_exceptionThrown_WhenElementIdsAreNotUnique() throws Exception { - try { - WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ElementIdsAreNotUnique.json")); - fail(); - } catch (WorkspaceValidationException we) { - assertTrue(we.getMessage().startsWith("The element SoftwareSystem://Software System ")); - assertTrue(we.getMessage().endsWith(" has a non-unique ID of 1.")); - } - } - - @Test - public void test_exceptionThrown_WhenRelationshipIdsAreNotUnique() throws Exception { - try { - WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "RelationshipIdsAreNotUnique.json")); - fail(); - } catch (WorkspaceValidationException we) { - assertTrue(we.getMessage().startsWith("The relationship {1 | User | null} ---[Uses ")); - assertTrue(we.getMessage().endsWith("]---> {2 | Software System | null} has a non-unique ID of 3.")); - } - } - - @Test - public void test_exceptionThrown_WhenViewKeysAreNotUnique() throws Exception { + void exceptionThrown_WhenViewKeysAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ViewKeysAreNotUnique.json")); fail(); @@ -45,7 +23,7 @@ public void test_exceptionThrown_WhenViewKeysAreNotUnique() throws Exception { } @Test - public void test_exceptionThrown_WhenPeopleAndSoftwareSystemNamesAreNotUnique() throws Exception { + void exceptionThrown_WhenPeopleAndSoftwareSystemNamesAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "PeopleAndSoftwareSystemNamesAreNotUnique.json")); fail(); @@ -55,7 +33,7 @@ public void test_exceptionThrown_WhenPeopleAndSoftwareSystemNamesAreNotUnique() } @Test - public void test_exceptionThrown_WhenContainerNamesAreNotUnique() throws Exception { + void exceptionThrown_WhenContainerNamesAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ContainerNamesAreNotUnique.json")); fail(); @@ -65,7 +43,7 @@ public void test_exceptionThrown_WhenContainerNamesAreNotUnique() throws Excepti } @Test - public void test_exceptionThrown_WhenComponentNamesAreNotUnique() throws Exception { + void exceptionThrown_WhenComponentNamesAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ComponentNamesAreNotUnique.json")); fail(); @@ -75,7 +53,7 @@ public void test_exceptionThrown_WhenComponentNamesAreNotUnique() throws Excepti } @Test - public void test_exceptionThrown_WhenTopLevelDeploymentNodeNamesAreNotUnique() throws Exception { + void exceptionThrown_WhenTopLevelDeploymentNodeNamesAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "TopLevelDeploymentNodeNamesAreNotUnique.json")); fail(); @@ -85,12 +63,12 @@ public void test_exceptionThrown_WhenTopLevelDeploymentNodeNamesAreNotUnique() t } @Test - public void test_exceptionNotThrown_WhenTopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments() throws Exception { + void exceptionNotThrown_WhenTopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments() throws Exception { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "TopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments.json")); } @Test - public void test_exceptionThrown_WhenChildDeploymentNodeNamesAreNotUnique() throws Exception { + void exceptionThrown_WhenChildDeploymentNodeNamesAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ChildDeploymentNodeNamesAreNotUnique.json")); fail(); @@ -100,7 +78,7 @@ public void test_exceptionThrown_WhenChildDeploymentNodeNamesAreNotUnique() thro } @Test - public void test_exceptionThrown_WhenRelationshipDescriptionsAreNotUnique() throws Exception { + void exceptionThrown_WhenRelationshipDescriptionsAreNotUnique() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "RelationshipDescriptionsAreNotUnique.json")); fail(); @@ -110,7 +88,7 @@ public void test_exceptionThrown_WhenRelationshipDescriptionsAreNotUnique() thro } @Test - public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenSoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "SoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel.json")); fail(); @@ -120,7 +98,7 @@ public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithSystemContextVi } @Test - public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenSoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "SoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel.json")); fail(); @@ -130,7 +108,7 @@ public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithContainerViewIs } @Test - public void test_exceptionThrown_WhenContainerAssociatedWithComponentViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenContainerAssociatedWithComponentViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ContainerAssociatedWithComponentViewIsMissingFromTheModel.json")); fail(); @@ -140,7 +118,7 @@ public void test_exceptionThrown_WhenContainerAssociatedWithComponentViewIsMissi } @Test - public void test_exceptionThrown_WhenElementAssociatedWithDynamicViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenElementAssociatedWithDynamicViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ElementAssociatedWithDynamicViewIsMissingFromTheModel.json")); fail(); @@ -150,7 +128,7 @@ public void test_exceptionThrown_WhenElementAssociatedWithDynamicViewIsMissingFr } @Test - public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenSoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "SoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel.json")); fail(); @@ -160,7 +138,7 @@ public void test_exceptionThrown_WhenSoftwareSystemAssociatedWithDeploymentViewI } @Test - public void test_exceptionThrown_WhenViewAssociatedWithFilteredViewIsMissingFromTheWorkspace() throws Exception { + void exceptionThrown_WhenViewAssociatedWithFilteredViewIsMissingFromTheWorkspace() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ViewAssociatedWithFilteredViewIsMissingFromTheWorkspace.json")); fail(); @@ -170,7 +148,7 @@ public void test_exceptionThrown_WhenViewAssociatedWithFilteredViewIsMissingFrom } @Test - public void test_exceptionThrown_WhenElementReferencedByViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenElementReferencedByViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "ElementReferencedByViewIsMissingFromTheModel.json")); fail(); @@ -180,7 +158,7 @@ public void test_exceptionThrown_WhenElementReferencedByViewIsMissingFromTheMode } @Test - public void test_exceptionThrown_WhenRelationshipReferencedByViewIsMissingFromTheModel() throws Exception { + void exceptionThrown_WhenRelationshipReferencedByViewIsMissingFromTheModel() throws Exception { try { WorkspaceUtils.loadWorkspaceFromJson(new File(PATH_TO_WORKSPACE_FILES, "RelationshipReferencedByViewIsMissingFromTheModel.json")); fail(); diff --git a/structurizr-client/src/test/java/com/structurizr/encryption/AesEncryptionStrategyTests.java b/structurizr-client/src/test/java/com/structurizr/encryption/AesEncryptionStrategyTests.java new file mode 100644 index 000000000..33171b4b3 --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/encryption/AesEncryptionStrategyTests.java @@ -0,0 +1,100 @@ +package com.structurizr.encryption; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +public class AesEncryptionStrategyTests { + + @Test + void encrypt_EncryptsPlaintext() throws Exception { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "06DC30A48ADEEE72D98E33C2CEAEAD3E", "ED124530AF64A5CAD8EF463CF5628434", "password"); + String ciphertext = strategy.encrypt("Hello world"); + + assertEquals("A/DzjV17WVS6ZAKsLOaC/Q==", ciphertext); + } + + @Test + void decrypt_decryptsTheCiphertext_WhenTheSameStrategyInstanceIsUsed() throws Exception { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + assertEquals("Hello world", strategy.decrypt(ciphertext)); + } + + @Test + void decrypt_decryptsTheCiphertext_WhenTheSameConfigurationIsUsed() throws Exception { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "password"); + assertEquals("Hello world", strategy.decrypt(ciphertext)); + } + + @Test + void decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectKeySizeIsUsed() throws Exception { + try { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(256, strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "password"); + assertNotEquals("Hello world", strategy.decrypt(ciphertext)); + } catch (Exception e) { + // this is okay + } + } + + @Test + void decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectIterationCountIsUsed() throws Exception { + try { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(strategy.getKeySize(), 2000, strategy.getSalt(), strategy.getIv(), "password"); + assertNotEquals("Hello world", strategy.decrypt(ciphertext)); + } catch (Exception e) { + // this is okay + } + } + + @Test + void decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectSaltIsUsed() throws Exception { + try { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), "133D30C2A658B3081279A97FD3B1F7CDE10C4FB61D39EEA8", strategy.getIv(), "password"); + assertNotEquals("Hello world", strategy.decrypt(ciphertext)); + } catch (Exception e) { + // this is okay + } + } + + @Test + void decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectIvIsUsed() throws Exception { + try { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), "1DED89E4FB15F61DC6433E3BADA4A891", "password"); + assertNotEquals("Hello world", strategy.decrypt(ciphertext)); + } catch (Exception e) { + // this is okay + } + } + + @Test + void decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectPassphraseIsUsed() throws Exception { + try { + AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); + String ciphertext = strategy.encrypt("Hello world"); + + strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "The Wrong Password"); + assertNotEquals("Hello world", strategy.decrypt(ciphertext)); + } catch (Exception e) { + // this is okay + } + } + +} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/encryption/EncryptedWorkspaceTests.java b/structurizr-client/src/test/java/com/structurizr/encryption/EncryptedWorkspaceTests.java similarity index 87% rename from structurizr-client/test/unit/com/structurizr/encryption/EncryptedWorkspaceTests.java rename to structurizr-client/src/test/java/com/structurizr/encryption/EncryptedWorkspaceTests.java index 1d88a6ff1..f52794996 100644 --- a/structurizr-client/test/unit/com/structurizr/encryption/EncryptedWorkspaceTests.java +++ b/structurizr-client/src/test/java/com/structurizr/encryption/EncryptedWorkspaceTests.java @@ -3,14 +3,12 @@ import com.structurizr.Workspace; import com.structurizr.configuration.Role; import com.structurizr.io.json.JsonWriter; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import java.io.StringWriter; -import static junit.framework.TestCase.assertSame; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.*; public class EncryptedWorkspaceTests { @@ -18,7 +16,7 @@ public class EncryptedWorkspaceTests { private Workspace workspace; private EncryptionStrategy encryptionStrategy; - @Before + @BeforeEach public void setUp() throws Exception { workspace = new Workspace("Name", "Description"); workspace.setVersion("1.2.3"); @@ -31,7 +29,7 @@ public void setUp() throws Exception { } @Test - public void test_construction_WhenTwoParametersAreSpecified() throws Exception { + void construction_WhenTwoParametersAreSpecified() throws Exception { encryptedWorkspace = new EncryptedWorkspace(workspace, encryptionStrategy); assertEquals("Name", encryptedWorkspace.getName()); @@ -41,7 +39,8 @@ public void test_construction_WhenTwoParametersAreSpecified() throws Exception { assertEquals("structurizr-java", encryptedWorkspace.getLastModifiedAgent()); assertEquals(1234, encryptedWorkspace.getId()); assertEquals("user@domain.com", encryptedWorkspace.getConfiguration().getUsers().iterator().next().getUsername()); - assertNull(workspace.getConfiguration()); + assertNotNull(workspace.getConfiguration()); + assertTrue(workspace.getConfiguration().getUsers().isEmpty()); assertSame(workspace, encryptedWorkspace.getWorkspace()); assertSame(encryptionStrategy, encryptedWorkspace.getEncryptionStrategy()); @@ -55,7 +54,7 @@ public void test_construction_WhenTwoParametersAreSpecified() throws Exception { } @Test - public void test_construction_WhenThreeParametersAreSpecified() throws Exception { + void construction_WhenThreeParametersAreSpecified() throws Exception { JsonWriter jsonWriter = new JsonWriter(false); StringWriter stringWriter = new StringWriter(); jsonWriter.write(workspace, stringWriter); @@ -77,7 +76,7 @@ public void test_construction_WhenThreeParametersAreSpecified() throws Exception } @Test - public void test_getPlaintext_ReturnsTheDecryptedVersionOfTheCiphertext() throws Exception { + void getPlaintext_ReturnsTheDecryptedVersionOfTheCiphertext() throws Exception { encryptedWorkspace = new EncryptedWorkspace(workspace, encryptionStrategy); String cipherText = encryptedWorkspace.getCiphertext(); @@ -89,7 +88,7 @@ public void test_getPlaintext_ReturnsTheDecryptedVersionOfTheCiphertext() throws } @Test - public void test_getWorkspace_ReturnsTheWorkspace_WhenACipherextIsSpecified() throws Exception { + void getWorkspace_ReturnsTheWorkspace_WhenACipherextIsSpecified() throws Exception { JsonWriter jsonWriter = new JsonWriter(false); StringWriter stringWriter = new StringWriter(); jsonWriter.write(workspace, stringWriter); diff --git a/structurizr-client/test/unit/com/structurizr/encryption/MockEncryptionStrategy.java b/structurizr-client/src/test/java/com/structurizr/encryption/MockEncryptionStrategy.java similarity index 100% rename from structurizr-client/test/unit/com/structurizr/encryption/MockEncryptionStrategy.java rename to structurizr-client/src/test/java/com/structurizr/encryption/MockEncryptionStrategy.java diff --git a/structurizr-client/src/test/java/com/structurizr/http/HttpClientTests.java b/structurizr-client/src/test/java/com/structurizr/http/HttpClientTests.java new file mode 100644 index 000000000..a98d05db7 --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/http/HttpClientTests.java @@ -0,0 +1,50 @@ +package com.structurizr.http; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class HttpClientTests { + + @Test + @Tag("IntegrationTest") + void get_WhenNoAllowedUrlsAreConfigured() { + HttpClient httpClient = new HttpClient(); + + try { + httpClient.get("https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json"); + } catch (Exception e) { + assertEquals("Access to https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json is not permitted", e.getMessage()); + } + } + + @Test + @Tag("IntegrationTest") + void get_WithAllowedUrl() { + HttpClient httpClient = new HttpClient(); + httpClient.allow("https://static.structurizr.com/themes/amazon-web-services.*"); + + httpClient.get("https://static.structurizr.com/themes/amazon-web-services-2023.01.31/icons.json"); + + try { + httpClient.get("https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json"); + } catch (Exception e) { + assertEquals("Access to https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json is not permitted", e.getMessage()); + } + } + + @Test + @Tag("IntegrationTest") + void get_WithDisallowedUrl() { + HttpClient httpClient = new HttpClient(); + httpClient.allow("https://static.structurizr.com/.*"); + + try { + httpClient.get("https://example.com"); + } catch (Exception e) { + assertEquals("Access to https://example.com is not permitted", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonTests.java b/structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonTests.java similarity index 91% rename from structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonTests.java rename to structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonTests.java index 4b38c5638..c598cd06c 100644 --- a/structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonTests.java +++ b/structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonTests.java @@ -3,17 +3,17 @@ import com.structurizr.Workspace; import com.structurizr.encryption.AesEncryptionStrategy; import com.structurizr.encryption.EncryptedWorkspace; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.StringReader; import java.io.StringWriter; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class EncryptedJsonTests { @Test - public void test_write_and_read() throws Exception { + void write_and_read() throws Exception { final Workspace workspace1 = new Workspace("Name", "Description"); // output the model as JSON diff --git a/structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonWriterTests.java b/structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonWriterTests.java similarity index 75% rename from structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonWriterTests.java rename to structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonWriterTests.java index 9a9efae49..74b40614c 100644 --- a/structurizr-client/test/unit/com/structurizr/io/json/EncryptedJsonWriterTests.java +++ b/structurizr-client/src/test/java/com/structurizr/io/json/EncryptedJsonWriterTests.java @@ -3,17 +3,17 @@ import com.structurizr.Workspace; import com.structurizr.encryption.AesEncryptionStrategy; import com.structurizr.encryption.EncryptedWorkspace; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.StringWriter; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; public class EncryptedJsonWriterTests { @Test - public void test_write_ThrowsAnIllegalArgumentException_WhenANullEncryptedWorkspaceIsSpecified() throws Exception { + void write_ThrowsAnIllegalArgumentException_WhenANullEncryptedWorkspaceIsSpecified() throws Exception { try { EncryptedJsonWriter writer = new EncryptedJsonWriter(true); writer.write(null, new StringWriter()); @@ -24,7 +24,7 @@ public void test_write_ThrowsAnIllegalArgumentException_WhenANullEncryptedWorksp } @Test - public void test_write_ThrowsAnIllegalArgumentException_WhenANullWriterIsSpecified() throws Exception { + void write_ThrowsAnIllegalArgumentException_WhenANullWriterIsSpecified() throws Exception { try { EncryptedJsonWriter writer = new EncryptedJsonWriter(true); Workspace workspace = new Workspace("Name", "Description"); diff --git a/structurizr-client/test/unit/com/structurizr/io/json/JsonTests.java b/structurizr-client/src/test/java/com/structurizr/io/json/JsonTests.java similarity index 88% rename from structurizr-client/test/unit/com/structurizr/io/json/JsonTests.java rename to structurizr-client/src/test/java/com/structurizr/io/json/JsonTests.java index baab4d7fc..f394c1ace 100644 --- a/structurizr-client/test/unit/com/structurizr/io/json/JsonTests.java +++ b/structurizr-client/src/test/java/com/structurizr/io/json/JsonTests.java @@ -2,19 +2,19 @@ import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.StringReader; import java.io.StringWriter; import java.util.UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; public class JsonTests { @Test - public void test_write_and_read() throws Exception { + void write_and_read() throws Exception { final Workspace workspace1 = new Workspace("Name", "Description"); // output the model as JSON @@ -31,7 +31,7 @@ public void test_write_and_read() throws Exception { } @Test - public void test_backwardsCompatibilityOfRenamingEnterpriseContextViewsToSystemLandscapeViews() throws Exception { + void backwardsCompatibilityOfRenamingEnterpriseContextViewsToSystemLandscapeViews() throws Exception { Workspace workspace = new Workspace("Name", "Description"); workspace.getViews().createSystemLandscapeView("key", "description"); @@ -48,7 +48,7 @@ public void test_backwardsCompatibilityOfRenamingEnterpriseContextViewsToSystemL } @Test - public void test_write_and_read_withCustomIdGenerator() throws Exception { + void write_and_read_withCustomIdGenerator() throws Exception { Workspace workspace1 = new Workspace("Name", "Description"); workspace1.getModel().setIdGenerator(new CustomIdGenerator()); Person user = workspace1.getModel().addPerson("User"); diff --git a/structurizr-client/test/unit/com/structurizr/io/json/JsonWriterTests.java b/structurizr-client/src/test/java/com/structurizr/io/json/JsonWriterTests.java similarity index 68% rename from structurizr-client/test/unit/com/structurizr/io/json/JsonWriterTests.java rename to structurizr-client/src/test/java/com/structurizr/io/json/JsonWriterTests.java index 48049ebbc..4db62acb8 100644 --- a/structurizr-client/test/unit/com/structurizr/io/json/JsonWriterTests.java +++ b/structurizr-client/src/test/java/com/structurizr/io/json/JsonWriterTests.java @@ -1,17 +1,17 @@ package com.structurizr.io.json; import com.structurizr.Workspace; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.StringWriter; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; public class JsonWriterTests { @Test - public void test_write_ThrowsAnIllegalArgumentException_WhenANullWorkspaceIsSpecified() throws Exception { + void write_ThrowsAnIllegalArgumentException_WhenANullWorkspaceIsSpecified() throws Exception { try { JsonWriter writer = new JsonWriter(true); writer.write(null, new StringWriter()); @@ -22,7 +22,7 @@ public void test_write_ThrowsAnIllegalArgumentException_WhenANullWorkspaceIsSpec } @Test - public void test_write_ThrowsAnIllegalArgumentException_WhenANullWriterIsSpecified() throws Exception { + void write_ThrowsAnIllegalArgumentException_WhenANullWriterIsSpecified() throws Exception { try { JsonWriter writer = new JsonWriter(true); Workspace workspace = new Workspace("Name", "Description"); diff --git a/structurizr-client/src/test/java/com/structurizr/util/ThemeUtilsTests.java b/structurizr-client/src/test/java/com/structurizr/util/ThemeUtilsTests.java new file mode 100644 index 000000000..03c6cfbcf --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/util/ThemeUtilsTests.java @@ -0,0 +1,35 @@ +package com.structurizr.util; + +import com.structurizr.Workspace; +import com.structurizr.view.ThemeUtils; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ThemeUtilsTests { + + @Test + void inlineTheme() throws Exception { + File themeFile = new File("src/test/resources/theme.json"); + + try { + Workspace theme = new Workspace("Theme", ""); + theme.getViews().getConfiguration().getStyles().addElementStyle("Tag").background("#ff0000").icon("logo.png"); + theme.getViews().getConfiguration().getStyles().addRelationshipStyle("Tag").color("#00ff00"); + ThemeUtils.toJson(theme, themeFile); + } catch (Exception e) { + throw new RuntimeException(e); + } + + Workspace workspace = new Workspace("Name", "Description"); + ThemeUtils.inlineTheme(workspace, themeFile); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("#ff0000", workspace.getViews().getConfiguration().getStyles().getElementStyle("Tag").getBackground()); + assertEquals("data:image/png;base64,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", workspace.getViews().getConfiguration().getStyles().getElementStyle("Tag").getIcon()); + assertEquals("#00ff00", workspace.getViews().getConfiguration().getStyles().getRelationshipStyle("Tag").getColor()); + } + +} \ No newline at end of file diff --git a/structurizr-client/src/test/java/com/structurizr/util/WorkspaceUtilsTests.java b/structurizr-client/src/test/java/com/structurizr/util/WorkspaceUtilsTests.java new file mode 100644 index 000000000..e1258825a --- /dev/null +++ b/structurizr-client/src/test/java/com/structurizr/util/WorkspaceUtilsTests.java @@ -0,0 +1,174 @@ +package com.structurizr.util; + +import com.structurizr.Workspace; +import com.structurizr.model.Model; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class WorkspaceUtilsTests { + + @Test + void loadWorkspaceFromJson_ThrowsAnException_WhenANullFileIsSpecified() { + try { + WorkspaceUtils.loadWorkspaceFromJson(null); + fail(); + } catch (Exception e) { + assertEquals("The path to a JSON file must be specified.", e.getMessage()); + } + } + + @Test + void loadWorkspaceFromJson_ThrowsAnException_WhenTheFileDoesNotExist() { + try { + WorkspaceUtils.loadWorkspaceFromJson(new File("test/unit/com/structurizr/util/other-workspace.json")); + fail(); + } catch (Exception e) { + assertEquals("The specified JSON file does not exist.", e.getMessage()); + } + } + + @Test + void saveWorkspaceToJson_ThrowsAnException_WhenANullWorkspaceIsSpecified() { + try { + WorkspaceUtils.saveWorkspaceToJson(null, null); + fail(); + } catch (Exception e) { + assertEquals("A workspace must be provided.", e.getMessage()); + } + } + + @Test + void saveWorkspaceToJson_ThrowsAnException_WhenANullFileIsSpecified() { + try { + WorkspaceUtils.saveWorkspaceToJson(new Workspace("Name", "Description"), null); + fail(); + } catch (Exception e) { + assertEquals("The path to a JSON file must be specified.", e.getMessage()); + } + } + + @Test + void saveWorkspaceToJson_and_loadWorkspaceFromJson() throws Exception { + File file = new File("build/workspace-utils.json"); + Workspace workspace = new Workspace("Name", "Description"); + WorkspaceUtils.saveWorkspaceToJson(workspace, file); + + workspace = WorkspaceUtils.loadWorkspaceFromJson(file); + assertEquals("Name", workspace.getName()); + } + + @Test + void toJson_ThrowsAnException_WhenANullWorkspaceIsProvided() throws Exception { + try { + WorkspaceUtils.toJson(null, true); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A workspace must be provided.", iae.getMessage()); + } + } + + @Test + void toJson() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + String indentedOutput = WorkspaceUtils.toJson(workspace, true); + String unindentedOutput = WorkspaceUtils.toJson(workspace, false); + + assertEquals(""" + { + "configuration" : { }, + "description" : "Description", + "documentation" : { }, + "id" : 0, + "model" : { }, + "name" : "Name", + "views" : { + "configuration" : { + "branding" : { }, + "styles" : { }, + "terminology" : { } + } + } + }""", indentedOutput); + + assertEquals(""" + {"configuration":{},"description":"Description","documentation":{},"id":0,"model":{},"name":"Name","views":{"configuration":{"branding":{},"styles":{},"terminology":{}}}}""", unindentedOutput); + } + + @Test + void fromJson_ThrowsAnException_WhenANullJsonStringIsProvided() throws Exception { + try { + WorkspaceUtils.fromJson(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A JSON string must be provided.", iae.getMessage()); + } + } + + @Test + void fromJson_ThrowsAnException_WhenAnEmptyJsonStringIsProvided() throws Exception { + try { + WorkspaceUtils.fromJson(" "); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A JSON string must be provided.", iae.getMessage()); + } + } + + @Test + void fromJson() throws Exception { + Workspace workspace = WorkspaceUtils.fromJson("{\"id\":0,\"name\":\"Name\",\"description\":\"Description\",\"model\":{},\"documentation\":{},\"views\":{\"configuration\":{\"branding\":{},\"styles\":{},\"terminology\":{}}}}"); + assertEquals("Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + } + + @Test + void elementNamesAreCaseSensitive() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + Model model = workspace.getModel(); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Name"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("NAME"); + SoftwareSystem softwareSystem3 = model.addSoftwareSystem("name"); + + assertEquals(3, model.getSoftwareSystems().size()); + + WorkspaceUtils.fromJson(WorkspaceUtils.toJson(workspace, false)); // no exception thrown + } + + @Test + void relationshipDescriptionsAreCaseSensitive() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + Model model = workspace.getModel(); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("1"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("2"); + + softwareSystem1.uses(softwareSystem2, "Uses"); + softwareSystem1.uses(softwareSystem2, "USES"); + softwareSystem1.uses(softwareSystem2, "uses"); + + assertEquals(3, softwareSystem1.getRelationships().size()); + + WorkspaceUtils.fromJson(WorkspaceUtils.toJson(workspace, false)); // no exception thrown + } + + @Test + void toJson_IsDeterministic() throws Exception { + File file = new File("./src/test/resources/structurizr-36141-workspace.json"); + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(file); + + final String expectedJson = WorkspaceUtils.toJson(workspace, true); + + // serialize and deserialize many times ... the JSON should remain the same + for (int i = 0; i < 100; i++) { + String actualJson = WorkspaceUtils.toJson(workspace, true); + assertEquals(expectedJson, actualJson); + workspace = WorkspaceUtils.fromJson(actualJson); + } + + } + +} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/view/ThemeUtilsTests.java b/structurizr-client/src/test/java/com/structurizr/view/ThemeUtilsTests.java similarity index 56% rename from structurizr-client/test/unit/com/structurizr/view/ThemeUtilsTests.java rename to structurizr-client/src/test/java/com/structurizr/view/ThemeUtilsTests.java index 119d10390..9d61853f2 100644 --- a/structurizr-client/test/unit/com/structurizr/view/ThemeUtilsTests.java +++ b/structurizr-client/src/test/java/com/structurizr/view/ThemeUtilsTests.java @@ -1,21 +1,22 @@ package com.structurizr.view; import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; import com.structurizr.model.Relationship; import com.structurizr.model.SoftwareSystem; import com.structurizr.model.Tags; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Collection; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ThemeUtilsTests { @Test - public void test_loadThemes_DoesNothingWhenNoThemesAreDefined() throws Exception { + void loadThemes_DoesNothingWhenNoThemesAreDefined() throws Exception { Workspace workspace = new Workspace("Name", "Description"); ThemeUtils.loadThemes(workspace); @@ -24,13 +25,39 @@ public void test_loadThemes_DoesNothingWhenNoThemesAreDefined() throws Exception } @Test - public void test_loadThemes_LoadsThemesWhenThemesAreDefined() throws Exception { + @Tag("IntegrationTest") + void loadThemes_LoadsThemesWhenThemesAreDefined_AndContentTypeIsApplicationJson() throws Exception { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); softwareSystem.addTags("Amazon Web Services - Alexa For Business"); workspace.getViews().getConfiguration().setThemes("https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json"); - ThemeUtils.loadThemes(workspace); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + // there should still be zero styles in the workspace + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getElements().size()); + + // but we should be able to find a style included in the theme + ElementStyle style = workspace.getViews().getConfiguration().getStyles().findElementStyle(softwareSystem); + assertNotNull(style); + assertEquals("#d6242d", style.getStroke()); + assertEquals("#d6242d", style.getColor()); + assertEquals("https://static.structurizr.com/themes/amazon-web-services-2020.04.30/alexa-for-business.png", style.getIcon()); + } + + @Test + @Tag("IntegrationTest") + void loadThemes_LoadsThemesWhenThemesAreDefined_AndContentTypeIsPlainText() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + softwareSystem.addTags("Amazon Web Services - Alexa For Business"); + workspace.getViews().getConfiguration().setThemes("https://raw.githubusercontent.com/structurizr/themes/refs/heads/master/amazon-web-services-2020.04.30/theme.json"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); // there should still be zero styles in the workspace assertEquals(0, workspace.getViews().getConfiguration().getStyles().getElements().size()); @@ -40,11 +67,11 @@ public void test_loadThemes_LoadsThemesWhenThemesAreDefined() throws Exception { assertNotNull(style); assertEquals("#d6242d", style.getStroke()); assertEquals("#d6242d", style.getColor()); - assertNotNull(style.getIcon()); + assertEquals("https://raw.githubusercontent.com/structurizr/themes/refs/heads/master/amazon-web-services-2020.04.30/alexa-for-business.png", style.getIcon()); } @Test - public void test_toJson() throws Exception { + void toJson() throws Exception { Workspace workspace = new Workspace("Name", "Description"); assertEquals("{\n" + " \"name\" : \"Name\",\n" + @@ -53,6 +80,8 @@ public void test_toJson() throws Exception { workspace.getViews().getConfiguration().getStyles().addElementStyle(Tags.ELEMENT).background("#ff0000"); workspace.getViews().getConfiguration().getStyles().addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); + workspace.getViews().getConfiguration().getBranding().setLogo("https://structurizr.com/static/img/structurizr-logo.png"); + workspace.getViews().getConfiguration().getBranding().setFont(new Font("Open Sans", "https://fonts.googleapis.com/css?family=Open+Sans:400,700")); assertEquals("{\n" + " \"name\" : \"Name\",\n" + " \"description\" : \"Description\",\n" + @@ -63,12 +92,17 @@ public void test_toJson() throws Exception { " \"relationships\" : [ {\n" + " \"tag\" : \"Relationship\",\n" + " \"color\" : \"#ff0000\"\n" + - " } ]\n" + + " } ],\n" + + " \"logo\" : \"https://structurizr.com/static/img/structurizr-logo.png\",\n" + + " \"font\" : {\n" + + " \"name\" : \"Open Sans\",\n" + + " \"url\" : \"https://fonts.googleapis.com/css?family=Open+Sans:400,700\"\n" + + " }\n" + "}", ThemeUtils.toJson(workspace)); } @Test - public void test_findElementStyle_WithThemes() { + void findElementStyle_WithThemes() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); workspace.getViews().getConfiguration().getStyles().addElementStyle("Element").shape(Shape.RoundedBox); @@ -77,17 +111,17 @@ public void test_findElementStyle_WithThemes() { Collection elementStyles = new ArrayList<>(); Collection relationshipStyles = new ArrayList<>(); elementStyles.add(new ElementStyle("Element").shape(Shape.Box).background("#000000").color("#ffffff")); - workspace.getViews().getConfiguration().getStyles().addStylesFromTheme("url1", elementStyles, relationshipStyles); + workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(new Theme(elementStyles, relationshipStyles)); // theme 2 elementStyles = new ArrayList<>(); relationshipStyles = new ArrayList<>(); elementStyles.add(new ElementStyle("Element").background("#ff0000")); - workspace.getViews().getConfiguration().getStyles().addStylesFromTheme("url2", elementStyles, relationshipStyles); + workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(new Theme(elementStyles, relationshipStyles)); ElementStyle style = workspace.getViews().getConfiguration().getStyles().findElementStyle(softwareSystem); - assertEquals(Integer.valueOf(450), style.getWidth()); - assertEquals(Integer.valueOf(300), style.getHeight()); + assertNull(style.getWidth()); + assertNull(style.getHeight()); assertEquals("#ff0000", style.getBackground()); // from theme 2 assertEquals("#ffffff", style.getColor()); // from theme 1 assertEquals(Integer.valueOf(24), style.getFontSize()); @@ -101,7 +135,7 @@ public void test_findElementStyle_WithThemes() { } @Test - public void test_findRelationshipStyle_WithThemes() { + void findRelationshipStyle_WithThemes() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); Relationship relationship = softwareSystem.uses(softwareSystem, "Uses"); @@ -111,18 +145,18 @@ public void test_findRelationshipStyle_WithThemes() { Collection elementStyles = new ArrayList<>(); Collection relationshipStyles = new ArrayList<>(); relationshipStyles.add(new RelationshipStyle("Relationship").color("#ff0000").thickness(4)); - workspace.getViews().getConfiguration().getStyles().addStylesFromTheme("url1", elementStyles, relationshipStyles); + workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(new Theme(elementStyles, relationshipStyles)); // theme 2 elementStyles = new ArrayList<>(); relationshipStyles = new ArrayList<>(); relationshipStyles.add(new RelationshipStyle("Relationship").color("#0000ff")); - workspace.getViews().getConfiguration().getStyles().addStylesFromTheme("url2", elementStyles, relationshipStyles); + workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(new Theme(elementStyles, relationshipStyles)); RelationshipStyle style = workspace.getViews().getConfiguration().getStyles().findRelationshipStyle(relationship); assertEquals(Integer.valueOf(4), style.getThickness()); // from theme 1 assertEquals("#0000ff", style.getColor()); // from theme 2 - Assert.assertFalse(style.getDashed()); // from workspace + assertFalse(style.getDashed()); // from workspace assertEquals(Routing.Direct, style.getRouting()); assertEquals(Integer.valueOf(24), style.getFontSize()); assertEquals(Integer.valueOf(200), style.getWidth()); @@ -130,4 +164,27 @@ public void test_findRelationshipStyle_WithThemes() { assertEquals(Integer.valueOf(100), style.getOpacity()); } + @Test + @Tag("IntegrationTest") + void loadThemes_ReplacesRelativeIconReferences() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + softwareSystem.addTags("Amazon Web Services - Alexa For Business"); + workspace.getViews().getConfiguration().setThemes("https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + // there should still be zero styles in the workspace + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getElements().size()); + + // but we should be able to find a style included in the theme + ElementStyle style = workspace.getViews().getConfiguration().getStyles().findElementStyle(softwareSystem); + assertNotNull(style); + assertEquals("#d6242d", style.getStroke()); + assertEquals("#d6242d", style.getColor()); + assertEquals("https://static.structurizr.com/themes/amazon-web-services-2020.04.30/alexa-for-business.png", style.getIcon()); + } + } \ No newline at end of file diff --git a/structurizr-client/test/integration/backwardsCompatibility/structurizr-31-workspace.json b/structurizr-client/src/test/resources/backwardsCompatibility/structurizr-31-workspace.json similarity index 100% rename from structurizr-client/test/integration/backwardsCompatibility/structurizr-31-workspace.json rename to structurizr-client/src/test/resources/backwardsCompatibility/structurizr-31-workspace.json diff --git a/structurizr-client/test/integration/backwardsCompatibility/structurizr-36141-workspace.json b/structurizr-client/src/test/resources/backwardsCompatibility/structurizr-36141-workspace.json similarity index 100% rename from structurizr-client/test/integration/backwardsCompatibility/structurizr-36141-workspace.json rename to structurizr-client/src/test/resources/backwardsCompatibility/structurizr-36141-workspace.json diff --git a/structurizr-client/test/integration/backwardsCompatibility/structurizr-39459-workspace.json b/structurizr-client/src/test/resources/backwardsCompatibility/structurizr-39459-workspace.json similarity index 100% rename from structurizr-client/test/integration/backwardsCompatibility/structurizr-39459-workspace.json rename to structurizr-client/src/test/resources/backwardsCompatibility/structurizr-39459-workspace.json diff --git a/structurizr-client/src/test/resources/backwardsCompatibility/views-without-order.json b/structurizr-client/src/test/resources/backwardsCompatibility/views-without-order.json new file mode 100644 index 000000000..c84331f31 --- /dev/null +++ b/structurizr-client/src/test/resources/backwardsCompatibility/views-without-order.json @@ -0,0 +1,27 @@ +{ + "description" : "Description", + "id" : 0, + "model" : { + "people" : [ { + "id" : "1", + "name" : "User", + "tags" : "Element,Person" + } ] + }, + "name" : "Name", + "views" : { + "systemLandscapeViews" : [ { + "elements" : [ { + "id" : "1" + } ], + "enterpriseBoundaryVisible" : true, + "key" : "SystemLandscape-001" + }, { + "elements" : [ { + "id" : "1" + } ], + "enterpriseBoundaryVisible" : true, + "key" : "SystemLandscape-002" + } ] + } +} \ No newline at end of file diff --git a/structurizr-client/src/test/resources/logo.png b/structurizr-client/src/test/resources/logo.png new file mode 100644 index 000000000..763d19bf5 Binary files /dev/null and b/structurizr-client/src/test/resources/logo.png differ diff --git a/structurizr-client/src/test/resources/structurizr-36141-workspace.json b/structurizr-client/src/test/resources/structurizr-36141-workspace.json new file mode 100644 index 000000000..e3f877256 --- /dev/null +++ b/structurizr-client/src/test/resources/structurizr-36141-workspace.json @@ -0,0 +1,1876 @@ +{ + "id": 36141, + "name": "Big Bank plc - Internet Banking System", + "description": "The software architecture of the Big Bank plc Internet Banking System.", + "revision": 71, + "lastModifiedDate": "2023-11-11T09:04:13Z", + "lastModifiedUser": "", + "lastModifiedAgent": "structurizr-cloud/diagram-editor/543191c0-d8a4-4bfd-ab86-95f219ce6988", + "properties": { + "structurizr.dsl": "Ly8gaHR0cHM6Ly9zdHJ1Y3R1cml6ci5jb20vc2hhcmUvMzYxNDEKCndvcmtzcGFjZSBleHRlbmRzIC4uL21vZGVsLmRzbCB7CiAgICBuYW1lICJCaWcgQmFuayBwbGMgLSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbSIKICAgIGRlc2NyaXB0aW9uICJUaGUgc29mdHdhcmUgYXJjaGl0ZWN0dXJlIG9mIHRoZSBCaWcgQmFuayBwbGMgSW50ZXJuZXQgQmFua2luZyBTeXN0ZW0uIgoKICAgIG1vZGVsIHsKICAgICAgICAhcmVmIGludGVybmV0YmFua2luZ3N5c3RlbSB7CiAgICAgICAgICAgIHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiA9IGNvbnRhaW5lciAiU2luZ2xlLVBhZ2UgQXBwbGljYXRpb24iICJQcm92aWRlcyBhbGwgb2YgdGhlIEludGVybmV0IGJhbmtpbmcgZnVuY3Rpb25hbGl0eSB0byBjdXN0b21lcnMgdmlhIHRoZWlyIHdlYiBicm93c2VyLiIgIkphdmFTY3JpcHQgYW5kIEFuZ3VsYXIiICJXZWIgQnJvd3NlciIKICAgICAgICAgICAgbW9iaWxlQXBwID0gY29udGFpbmVyICJNb2JpbGUgQXBwIiAiUHJvdmlkZXMgYSBsaW1pdGVkIHN1YnNldCBvZiB0aGUgSW50ZXJuZXQgYmFua2luZyBmdW5jdGlvbmFsaXR5IHRvIGN1c3RvbWVycyB2aWEgdGhlaXIgbW9iaWxlIGRldmljZS4iICJYYW1hcmluIiAiTW9iaWxlIEFwcCIKICAgICAgICAgICAgd2ViQXBwbGljYXRpb24gPSBjb250YWluZXIgIldlYiBBcHBsaWNhdGlvbiIgIkRlbGl2ZXJzIHRoZSBzdGF0aWMgY29udGVudCBhbmQgdGhlIEludGVybmV0IGJhbmtpbmcgc2luZ2xlIHBhZ2UgYXBwbGljYXRpb24uIiAiSmF2YSBhbmQgU3ByaW5nIE1WQyIKICAgICAgICAgICAgYXBpQXBwbGljYXRpb24gPSBjb250YWluZXIgIkFQSSBBcHBsaWNhdGlvbiIgIlByb3ZpZGVzIEludGVybmV0IGJhbmtpbmcgZnVuY3Rpb25hbGl0eSB2aWEgYSBKU09OL0hUVFBTIEFQSS4iICJKYXZhIGFuZCBTcHJpbmcgTVZDIiB7CiAgICAgICAgICAgICAgICBzaWduaW5Db250cm9sbGVyID0gY29tcG9uZW50ICJTaWduIEluIENvbnRyb2xsZXIiICJBbGxvd3MgdXNlcnMgdG8gc2lnbiBpbiB0byB0aGUgSW50ZXJuZXQgQmFua2luZyBTeXN0ZW0uIiAiU3ByaW5nIE1WQyBSZXN0IENvbnRyb2xsZXIiCiAgICAgICAgICAgICAgICBhY2NvdW50c1N1bW1hcnlDb250cm9sbGVyID0gY29tcG9uZW50ICJBY2NvdW50cyBTdW1tYXJ5IENvbnRyb2xsZXIiICJQcm92aWRlcyBjdXN0b21lcnMgd2l0aCBhIHN1bW1hcnkgb2YgdGhlaXIgYmFuayBhY2NvdW50cy4iICJTcHJpbmcgTVZDIFJlc3QgQ29udHJvbGxlciIKICAgICAgICAgICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyID0gY29tcG9uZW50ICJSZXNldCBQYXNzd29yZCBDb250cm9sbGVyIiAiQWxsb3dzIHVzZXJzIHRvIHJlc2V0IHRoZWlyIHBhc3N3b3JkcyB3aXRoIGEgc2luZ2xlIHVzZSBVUkwuIiAiU3ByaW5nIE1WQyBSZXN0IENvbnRyb2xsZXIiCiAgICAgICAgICAgICAgICBzZWN1cml0eUNvbXBvbmVudCA9IGNvbXBvbmVudCAiU2VjdXJpdHkgQ29tcG9uZW50IiAiUHJvdmlkZXMgZnVuY3Rpb25hbGl0eSByZWxhdGVkIHRvIHNpZ25pbmcgaW4sIGNoYW5naW5nIHBhc3N3b3JkcywgZXRjLiIgIlNwcmluZyBCZWFuIgogICAgICAgICAgICAgICAgbWFpbmZyYW1lQmFua2luZ1N5c3RlbUZhY2FkZSA9IGNvbXBvbmVudCAiTWFpbmZyYW1lIEJhbmtpbmcgU3lzdGVtIEZhY2FkZSIgIkEgZmFjYWRlIG9udG8gdGhlIG1haW5mcmFtZSBiYW5raW5nIHN5c3RlbS4iICJTcHJpbmcgQmVhbiIKICAgICAgICAgICAgICAgIGVtYWlsQ29tcG9uZW50ID0gY29tcG9uZW50ICJFLW1haWwgQ29tcG9uZW50IiAiU2VuZHMgZS1tYWlscyB0byB1c2Vycy4iICJTcHJpbmcgQmVhbiIKICAgICAgICAgICAgfQogICAgICAgICAgICBkYXRhYmFzZSA9IGNvbnRhaW5lciAiRGF0YWJhc2UiICJTdG9yZXMgdXNlciByZWdpc3RyYXRpb24gaW5mb3JtYXRpb24sIGhhc2hlZCBhdXRoZW50aWNhdGlvbiBjcmVkZW50aWFscywgYWNjZXNzIGxvZ3MsIGV0Yy4iICJPcmFjbGUgRGF0YWJhc2UgU2NoZW1hIiAiRGF0YWJhc2UiCiAgICAgICAgfQoKICAgICAgICAjIHJlbGF0aW9uc2hpcHMgdG8vZnJvbSBjb250YWluZXJzCiAgICAgICAgY3VzdG9tZXIgLT4gd2ViQXBwbGljYXRpb24gIlZpc2l0cyBiaWdiYW5rLmNvbS9pYiB1c2luZyIgIkhUVFBTIgogICAgICAgIGN1c3RvbWVyIC0+IHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiAiVmlld3MgYWNjb3VudCBiYWxhbmNlcywgYW5kIG1ha2VzIHBheW1lbnRzIHVzaW5nIgogICAgICAgIGN1c3RvbWVyIC0+IG1vYmlsZUFwcCAiVmlld3MgYWNjb3VudCBiYWxhbmNlcywgYW5kIG1ha2VzIHBheW1lbnRzIHVzaW5nIgogICAgICAgIHdlYkFwcGxpY2F0aW9uIC0+IHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiAiRGVsaXZlcnMgdG8gdGhlIGN1c3RvbWVyJ3Mgd2ViIGJyb3dzZXIiCgogICAgICAgICMgcmVsYXRpb25zaGlwcyB0by9mcm9tIGNvbXBvbmVudHMKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gc2lnbmluQ29udHJvbGxlciAiTWFrZXMgQVBJIGNhbGxzIHRvIiAiSlNPTi9IVFRQUyIKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gYWNjb3VudHNTdW1tYXJ5Q29udHJvbGxlciAiTWFrZXMgQVBJIGNhbGxzIHRvIiAiSlNPTi9IVFRQUyIKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gcmVzZXRQYXNzd29yZENvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IHNpZ25pbkNvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IGFjY291bnRzU3VtbWFyeUNvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IHJlc2V0UGFzc3dvcmRDb250cm9sbGVyICJNYWtlcyBBUEkgY2FsbHMgdG8iICJKU09OL0hUVFBTIgogICAgICAgIHNpZ25pbkNvbnRyb2xsZXIgLT4gc2VjdXJpdHlDb21wb25lbnQgIlVzZXMiCiAgICAgICAgYWNjb3VudHNTdW1tYXJ5Q29udHJvbGxlciAtPiBtYWluZnJhbWVCYW5raW5nU3lzdGVtRmFjYWRlICJVc2VzIgogICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyIC0+IHNlY3VyaXR5Q29tcG9uZW50ICJVc2VzIgogICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyIC0+IGVtYWlsQ29tcG9uZW50ICJVc2VzIgogICAgICAgIHNlY3VyaXR5Q29tcG9uZW50IC0+IGRhdGFiYXNlICJSZWFkcyBmcm9tIGFuZCB3cml0ZXMgdG8iICJTUUwvVENQIgogICAgICAgIG1haW5mcmFtZUJhbmtpbmdTeXN0ZW1GYWNhZGUgLT4gbWFpbmZyYW1lICJNYWtlcyBBUEkgY2FsbHMgdG8iICJYTUwvSFRUUFMiCiAgICAgICAgZW1haWxDb21wb25lbnQgLT4gZW1haWwgIlNlbmRzIGUtbWFpbCB1c2luZyIKCiAgICAgICAgZGVwbG95bWVudEVudmlyb25tZW50ICJEZXZlbG9wbWVudCIgewogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRGV2ZWxvcGVyIExhcHRvcCIgIiIgIk1pY3Jvc29mdCBXaW5kb3dzIDEwIG9yIEFwcGxlIG1hY09TIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiV2ViIEJyb3dzZXIiICIiICJDaHJvbWUsIEZpcmVmb3gsIFNhZmFyaSwgb3IgRWRnZSIgewogICAgICAgICAgICAgICAgICAgIGRldmVsb3BlclNpbmdsZVBhZ2VBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2Ugc2luZ2xlUGFnZUFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRG9ja2VyIENvbnRhaW5lciAtIFdlYiBTZXJ2ZXIiICIiICJEb2NrZXIiIHsKICAgICAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQXBhY2hlIFRvbWNhdCIgIiIgIkFwYWNoZSBUb21jYXQgOC54IiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRldmVsb3BlcldlYkFwcGxpY2F0aW9uSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSB3ZWJBcHBsaWNhdGlvbgogICAgICAgICAgICAgICAgICAgICAgICBkZXZlbG9wZXJBcGlBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgYXBpQXBwbGljYXRpb24KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRG9ja2VyIENvbnRhaW5lciAtIERhdGFiYXNlIFNlcnZlciIgIiIgIkRvY2tlciIgewogICAgICAgICAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJEYXRhYmFzZSBTZXJ2ZXIiICIiICJPcmFjbGUgMTJjIiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRldmVsb3BlckRhdGFiYXNlSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSBkYXRhYmFzZQogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQmlnIEJhbmsgcGxjIiAiIiAiQmlnIEJhbmsgcGxjIGRhdGEgY2VudGVyIiAiIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay1kZXYwMDEiICIiICIiICIiIHsKICAgICAgICAgICAgICAgICAgICBzb2Z0d2FyZVN5c3RlbUluc3RhbmNlIG1haW5mcmFtZQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICBkZXBsb3ltZW50RW52aXJvbm1lbnQgIkxpdmUiIHsKICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkN1c3RvbWVyJ3MgbW9iaWxlIGRldmljZSIgIiIgIkFwcGxlIGlPUyBvciBBbmRyb2lkIiB7CiAgICAgICAgICAgICAgICBsaXZlTW9iaWxlQXBwSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSBtb2JpbGVBcHAKICAgICAgICAgICAgfQogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQ3VzdG9tZXIncyBjb21wdXRlciIgIiIgIk1pY3Jvc29mdCBXaW5kb3dzIG9yIEFwcGxlIG1hY09TIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiV2ViIEJyb3dzZXIiICIiICJDaHJvbWUsIEZpcmVmb3gsIFNhZmFyaSwgb3IgRWRnZSIgewogICAgICAgICAgICAgICAgICAgIGxpdmVTaW5nbGVQYWdlQXBwbGljYXRpb25JbnN0YW5jZSA9IGNvbnRhaW5lckluc3RhbmNlIHNpbmdsZVBhZ2VBcHBsaWNhdGlvbgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQmlnIEJhbmsgcGxjIiAiIiAiQmlnIEJhbmsgcGxjIGRhdGEgY2VudGVyIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay13ZWIqKioiICIiICJVYnVudHUgMTYuMDQgTFRTIiAiIiA0IHsKICAgICAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQXBhY2hlIFRvbWNhdCIgIiIgIkFwYWNoZSBUb21jYXQgOC54IiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGxpdmVXZWJBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2Ugd2ViQXBwbGljYXRpb24KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay1hcGkqKioiICIiICJVYnVudHUgMTYuMDQgTFRTIiAiIiA4IHsKICAgICAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQXBhY2hlIFRvbWNhdCIgIiIgIkFwYWNoZSBUb21jYXQgOC54IiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGxpdmVBcGlBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgYXBpQXBwbGljYXRpb24KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgImJpZ2JhbmstZGIwMSIgIiIgIlVidW50dSAxNi4wNCBMVFMiIHsKICAgICAgICAgICAgICAgICAgICBwcmltYXJ5RGF0YWJhc2VTZXJ2ZXIgPSBkZXBsb3ltZW50Tm9kZSAiT3JhY2xlIC0gUHJpbWFyeSIgIiIgIk9yYWNsZSAxMmMiIHsKICAgICAgICAgICAgICAgICAgICAgICAgbGl2ZVByaW1hcnlEYXRhYmFzZUluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgZGF0YWJhc2UKICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay1kYjAyIiAiIiAiVWJ1bnR1IDE2LjA0IExUUyIgIkZhaWxvdmVyIiB7CiAgICAgICAgICAgICAgICAgICAgc2Vjb25kYXJ5RGF0YWJhc2VTZXJ2ZXIgPSBkZXBsb3ltZW50Tm9kZSAiT3JhY2xlIC0gU2Vjb25kYXJ5IiAiIiAiT3JhY2xlIDEyYyIgIkZhaWxvdmVyIiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGxpdmVTZWNvbmRhcnlEYXRhYmFzZUluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgZGF0YWJhc2UgIkZhaWxvdmVyIgogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJiaWdiYW5rLXByb2QwMDEiICIiICIiICIiIHsKICAgICAgICAgICAgICAgICAgICBzb2Z0d2FyZVN5c3RlbUluc3RhbmNlIG1haW5mcmFtZQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBwcmltYXJ5RGF0YWJhc2VTZXJ2ZXIgLT4gc2Vjb25kYXJ5RGF0YWJhc2VTZXJ2ZXIgIlJlcGxpY2F0ZXMgZGF0YSB0byIKICAgICAgICB9CiAgICB9CgogICAgdmlld3MgewogICAgICAgIHN5c3RlbWNvbnRleHQgaW50ZXJuZXRCYW5raW5nU3lzdGVtICJTeXN0ZW1Db250ZXh0IiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhbmltYXRpb24gewogICAgICAgICAgICAgICAgaW50ZXJuZXRCYW5raW5nU3lzdGVtCiAgICAgICAgICAgICAgICBjdXN0b21lcgogICAgICAgICAgICAgICAgbWFpbmZyYW1lCiAgICAgICAgICAgICAgICBlbWFpbAogICAgICAgICAgICB9CgogICAgICAgICAgICBkZXNjcmlwdGlvbiAiVGhlIHN5c3RlbSBjb250ZXh0IGRpYWdyYW0gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgICAgIHByb3BlcnRpZXMgewogICAgICAgICAgICAgICAgc3RydWN0dXJpenIuZ3JvdXBzIGZhbHNlCiAgICAgICAgICAgIH0KICAgICAgICB9CgogICAgICAgIGNvbnRhaW5lciBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gIkNvbnRhaW5lcnMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgICAgIGFuaW1hdGlvbiB7CiAgICAgICAgICAgICAgICBjdXN0b21lciBtYWluZnJhbWUgZW1haWwKICAgICAgICAgICAgICAgIHdlYkFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24KICAgICAgICAgICAgICAgIG1vYmlsZUFwcAogICAgICAgICAgICAgICAgYXBpQXBwbGljYXRpb24KICAgICAgICAgICAgICAgIGRhdGFiYXNlCiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGRlc2NyaXB0aW9uICJUaGUgY29udGFpbmVyIGRpYWdyYW0gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgYXBpQXBwbGljYXRpb24gIkNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgICAgIGFuaW1hdGlvbiB7CiAgICAgICAgICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gbW9iaWxlQXBwIGRhdGFiYXNlIGVtYWlsIG1haW5mcmFtZQogICAgICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciBzZWN1cml0eUNvbXBvbmVudAogICAgICAgICAgICAgICAgYWNjb3VudHNTdW1tYXJ5Q29udHJvbGxlciBtYWluZnJhbWVCYW5raW5nU3lzdGVtRmFjYWRlCiAgICAgICAgICAgICAgICByZXNldFBhc3N3b3JkQ29udHJvbGxlciBlbWFpbENvbXBvbmVudAogICAgICAgICAgICB9CgogICAgICAgICAgICBkZXNjcmlwdGlvbiAiVGhlIGNvbXBvbmVudCBkaWFncmFtIGZvciB0aGUgQVBJIEFwcGxpY2F0aW9uLiIKICAgICAgICB9CgogICAgICAgIGltYWdlIG1haW5mcmFtZUJhbmtpbmdTeXN0ZW1GYWNhZGUgIk1haW5mcmFtZUJhbmtpbmdTeXN0ZW1GYWNhZGUiIHsKICAgICAgICAgICAgaW1hZ2UgbWFpbmZyYW1lLWJhbmtpbmctc3lzdGVtLWZhY2FkZS5wbmcKICAgICAgICAgICAgdGl0bGUgIkNsYXNzIGRpYWdyYW0gZm9yIHRoZSBNYWluZnJhbWUgQmFua2luZyBTeXN0ZW0gRmFjYWRlIGNvbXBvbmVudCIKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgYXBpQXBwbGljYXRpb24gIlNpZ25JbiIgewogICAgICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gc2lnbmluQ29udHJvbGxlciAiU3VibWl0cyBjcmVkZW50aWFscyB0byIKICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciAtPiBzZWN1cml0eUNvbXBvbmVudCAiVmFsaWRhdGVzIGNyZWRlbnRpYWxzIHVzaW5nIgogICAgICAgICAgICBzZWN1cml0eUNvbXBvbmVudCAtPiBkYXRhYmFzZSAic2VsZWN0ICogZnJvbSB1c2VycyB3aGVyZSB1c2VybmFtZSA9ID8iCiAgICAgICAgICAgIGRhdGFiYXNlIC0+IHNlY3VyaXR5Q29tcG9uZW50ICJSZXR1cm5zIHVzZXIgZGF0YSB0byIKICAgICAgICAgICAgc2VjdXJpdHlDb21wb25lbnQgLT4gc2lnbmluQ29udHJvbGxlciAiUmV0dXJucyB0cnVlIGlmIHRoZSBoYXNoZWQgcGFzc3dvcmQgbWF0Y2hlcyIKICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciAtPiBzaW5nbGVQYWdlQXBwbGljYXRpb24gIlNlbmRzIGJhY2sgYW4gYXV0aGVudGljYXRpb24gdG9rZW4gdG8iCgogICAgICAgICAgICBkZXNjcmlwdGlvbiAiU3VtbWFyaXNlcyBob3cgdGhlIHNpZ24gaW4gZmVhdHVyZSB3b3JrcyBpbiB0aGUgc2luZ2xlLXBhZ2UgYXBwbGljYXRpb24uIgogICAgICAgIH0KCiAgICAgICAgZGVwbG95bWVudCBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gIkRldmVsb3BtZW50IiAiRGV2ZWxvcG1lbnREZXBsb3ltZW50IiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhbmltYXRpb24gewogICAgICAgICAgICAgICAgZGV2ZWxvcGVyU2luZ2xlUGFnZUFwcGxpY2F0aW9uSW5zdGFuY2UKICAgICAgICAgICAgICAgIGRldmVsb3BlcldlYkFwcGxpY2F0aW9uSW5zdGFuY2UgZGV2ZWxvcGVyQXBpQXBwbGljYXRpb25JbnN0YW5jZQogICAgICAgICAgICAgICAgZGV2ZWxvcGVyRGF0YWJhc2VJbnN0YW5jZQogICAgICAgICAgICB9CgogICAgICAgICAgICBkZXNjcmlwdGlvbiAiQW4gZXhhbXBsZSBkZXZlbG9wbWVudCBkZXBsb3ltZW50IHNjZW5hcmlvIGZvciB0aGUgSW50ZXJuZXQgQmFua2luZyBTeXN0ZW0uIgogICAgICAgIH0KCiAgICAgICAgZGVwbG95bWVudCBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gIkxpdmUiICJMaXZlRGVwbG95bWVudCIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICAgICAgYW5pbWF0aW9uIHsKICAgICAgICAgICAgICAgIGxpdmVTaW5nbGVQYWdlQXBwbGljYXRpb25JbnN0YW5jZQogICAgICAgICAgICAgICAgbGl2ZU1vYmlsZUFwcEluc3RhbmNlCiAgICAgICAgICAgICAgICBsaXZlV2ViQXBwbGljYXRpb25JbnN0YW5jZSBsaXZlQXBpQXBwbGljYXRpb25JbnN0YW5jZQogICAgICAgICAgICAgICAgbGl2ZVByaW1hcnlEYXRhYmFzZUluc3RhbmNlCiAgICAgICAgICAgICAgICBsaXZlU2Vjb25kYXJ5RGF0YWJhc2VJbnN0YW5jZQogICAgICAgICAgICB9CgogICAgICAgICAgICBkZXNjcmlwdGlvbiAiQW4gZXhhbXBsZSBsaXZlIGRlcGxveW1lbnQgc2NlbmFyaW8gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJQZXJzb24iIHsKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgICAgIGZvbnRTaXplIDIyCiAgICAgICAgICAgICAgICBzaGFwZSBQZXJzb24KICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDdXN0b21lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjMDg0MjdiCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiQmFuayBTdGFmZiIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjOTk5OTk5CiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiU29mdHdhcmUgU3lzdGVtIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICMxMTY4YmQKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJFeGlzdGluZyBTeXN0ZW0iIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzk5OTk5OQogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbnRhaW5lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjNDM4ZGQ1CiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiV2ViIEJyb3dzZXIiIHsKICAgICAgICAgICAgICAgIHNoYXBlIFdlYkJyb3dzZXIKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJNb2JpbGUgQXBwIiB7CiAgICAgICAgICAgICAgICBzaGFwZSBNb2JpbGVEZXZpY2VMYW5kc2NhcGUKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJEYXRhYmFzZSIgewogICAgICAgICAgICAgICAgc2hhcGUgQ3lsaW5kZXIKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDb21wb25lbnQiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzg1YmJmMAogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkZhaWxvdmVyIiB7CiAgICAgICAgICAgICAgICBvcGFjaXR5IDI1CiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICB9Cgp9Cg==" + }, + "model": { + "people": [ + { + "id": "3", + "tags": "Element,Person,Bank Staff", + "properties": { + "structurizr.dsl.identifier": "backoffice" + }, + "name": "Back Office Staff", + "description": "Administration and support staff within the bank.", + "relationships": [ + { + "id": "16", + "tags": "Relationship", + "sourceId": "3", + "destinationId": "4", + "description": "Uses" + } + ], + "group": "Big Bank plc", + "location": "Unspecified" + }, + { + "id": "2", + "tags": "Element,Person,Bank Staff", + "properties": { + "structurizr.dsl.identifier": "supportstaff" + }, + "name": "Customer Service Staff", + "description": "Customer service staff within the bank.", + "relationships": [ + { + "id": "13", + "tags": "Relationship", + "sourceId": "2", + "destinationId": "4", + "description": "Uses" + } + ], + "group": "Big Bank plc", + "location": "Unspecified" + }, + { + "id": "1", + "tags": "Element,Person,Customer", + "properties": { + "structurizr.dsl.identifier": "customer" + }, + "name": "Personal Banking Customer", + "description": "A customer of the bank, with personal bank accounts.", + "relationships": [ + { + "id": "12", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "2", + "description": "Asks questions to", + "technology": "Telephone" + }, + { + "id": "30", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "18", + "description": "Views account balances, and makes payments using" + }, + { + "id": "8", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "7", + "description": "Views account balances, and makes payments using" + }, + { + "id": "28", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "19", + "description": "Visits bigbank.com/ib using", + "technology": "HTTPS" + }, + { + "id": "29", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "17", + "description": "Views account balances, and makes payments using" + }, + { + "id": "14", + "tags": "Relationship", + "sourceId": "1", + "destinationId": "6", + "description": "Withdraws cash using" + } + ], + "location": "Unspecified" + } + ], + "softwareSystems": [ + { + "id": "4", + "tags": "Element,Software System,Existing System", + "properties": { + "structurizr.dsl.identifier": "mainframe" + }, + "name": "Mainframe Banking System", + "description": "Stores all of the core banking information about customers, accounts, transactions, etc.", + "group": "Big Bank plc", + "location": "Unspecified", + "documentation": {} + }, + { + "id": "5", + "tags": "Element,Software System,Existing System", + "properties": { + "structurizr.dsl.identifier": "email" + }, + "name": "E-mail System", + "description": "The internal Microsoft Exchange e-mail system.", + "relationships": [ + { + "id": "11", + "tags": "Relationship", + "sourceId": "5", + "destinationId": "1", + "description": "Sends e-mails to" + } + ], + "group": "Big Bank plc", + "location": "Unspecified", + "documentation": {} + }, + { + "id": "7", + "tags": "Element,Software System", + "properties": { + "structurizr.dsl.identifier": "internetbankingsystem" + }, + "name": "Internet Banking System", + "description": "Allows customers to view information about their bank accounts, and make payments.", + "relationships": [ + { + "id": "10", + "tags": "Relationship", + "sourceId": "7", + "destinationId": "5", + "description": "Sends e-mail using" + }, + { + "id": "9", + "tags": "Relationship", + "sourceId": "7", + "destinationId": "4", + "description": "Gets account information from, and makes payments using" + } + ], + "group": "Big Bank plc", + "location": "Unspecified", + "containers": [ + { + "id": "20", + "tags": "Element,Container", + "properties": { + "structurizr.dsl.identifier": "apiapplication" + }, + "name": "API Application", + "description": "Provides Internet banking functionality via a JSON/HTTPS API.", + "relationships": [ + { + "id": "47", + "sourceId": "20", + "destinationId": "4", + "description": "Makes API calls to", + "technology": "XML/HTTPS", + "linkedRelationshipId": "46" + }, + { + "id": "45", + "sourceId": "20", + "destinationId": "27", + "description": "Reads from and writes to", + "technology": "SQL/TCP", + "linkedRelationshipId": "44" + }, + { + "id": "49", + "sourceId": "20", + "destinationId": "5", + "description": "Sends e-mail using", + "linkedRelationshipId": "48" + } + ], + "technology": "Java and Spring MVC", + "components": [ + { + "id": "24", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "securitycomponent" + }, + "name": "Security Component", + "description": "Provides functionality related to signing in, changing passwords, etc.", + "relationships": [ + { + "id": "44", + "tags": "Relationship", + "sourceId": "24", + "destinationId": "27", + "description": "Reads from and writes to", + "technology": "SQL/TCP" + } + ], + "technology": "Spring Bean", + "documentation": {} + }, + { + "id": "25", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "mainframebankingsystemfacade" + }, + "name": "Mainframe Banking System Facade", + "description": "A facade onto the mainframe banking system.", + "relationships": [ + { + "id": "46", + "tags": "Relationship", + "sourceId": "25", + "destinationId": "4", + "description": "Makes API calls to", + "technology": "XML/HTTPS" + } + ], + "technology": "Spring Bean", + "documentation": {} + }, + { + "id": "22", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "accountssummarycontroller" + }, + "name": "Accounts Summary Controller", + "description": "Provides customers with a summary of their bank accounts.", + "relationships": [ + { + "id": "41", + "tags": "Relationship", + "sourceId": "22", + "destinationId": "25", + "description": "Uses" + } + ], + "technology": "Spring MVC Rest Controller", + "documentation": {} + }, + { + "id": "21", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "signincontroller" + }, + "name": "Sign In Controller", + "description": "Allows users to sign in to the Internet Banking System.", + "relationships": [ + { + "id": "40", + "tags": "Relationship", + "sourceId": "21", + "destinationId": "24", + "description": "Uses" + } + ], + "technology": "Spring MVC Rest Controller", + "documentation": {} + }, + { + "id": "23", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "resetpasswordcontroller" + }, + "name": "Reset Password Controller", + "description": "Allows users to reset their passwords with a single use URL.", + "relationships": [ + { + "id": "42", + "tags": "Relationship", + "sourceId": "23", + "destinationId": "24", + "description": "Uses" + }, + { + "id": "43", + "tags": "Relationship", + "sourceId": "23", + "destinationId": "26", + "description": "Uses" + } + ], + "technology": "Spring MVC Rest Controller", + "documentation": {} + }, + { + "id": "26", + "tags": "Element,Component", + "properties": { + "structurizr.dsl.identifier": "emailcomponent" + }, + "name": "E-mail Component", + "description": "Sends e-mails to users.", + "relationships": [ + { + "id": "48", + "tags": "Relationship", + "sourceId": "26", + "destinationId": "5", + "description": "Sends e-mail using" + } + ], + "technology": "Spring Bean", + "documentation": {} + } + ], + "documentation": {} + }, + { + "id": "18", + "tags": "Element,Container,Mobile App", + "properties": { + "structurizr.dsl.identifier": "mobileapp" + }, + "name": "Mobile App", + "description": "Provides a limited subset of the Internet banking functionality to customers via their mobile device.", + "relationships": [ + { + "id": "37", + "sourceId": "18", + "destinationId": "20", + "description": "Makes API calls to", + "technology": "JSON/HTTPS", + "linkedRelationshipId": "36" + }, + { + "id": "39", + "tags": "Relationship", + "sourceId": "18", + "destinationId": "23", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + }, + { + "id": "36", + "tags": "Relationship", + "sourceId": "18", + "destinationId": "21", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + }, + { + "id": "38", + "tags": "Relationship", + "sourceId": "18", + "destinationId": "22", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + } + ], + "technology": "Xamarin", + "documentation": {} + }, + { + "id": "17", + "tags": "Element,Container,Web Browser", + "properties": { + "structurizr.dsl.identifier": "singlepageapplication" + }, + "name": "Single-Page Application", + "description": "Provides all of the Internet banking functionality to customers via their web browser.", + "relationships": [ + { + "id": "33", + "sourceId": "17", + "destinationId": "20", + "description": "Makes API calls to", + "technology": "JSON/HTTPS", + "linkedRelationshipId": "32" + }, + { + "id": "34", + "tags": "Relationship", + "sourceId": "17", + "destinationId": "22", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + }, + { + "id": "32", + "tags": "Relationship", + "sourceId": "17", + "destinationId": "21", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + }, + { + "id": "35", + "tags": "Relationship", + "sourceId": "17", + "destinationId": "23", + "description": "Makes API calls to", + "technology": "JSON/HTTPS" + } + ], + "technology": "JavaScript and Angular", + "documentation": {} + }, + { + "id": "27", + "tags": "Element,Container,Database", + "properties": { + "structurizr.dsl.identifier": "database" + }, + "name": "Database", + "description": "Stores user registration information, hashed authentication credentials, access logs, etc.", + "technology": "Oracle Database Schema", + "documentation": {} + }, + { + "id": "19", + "tags": "Element,Container", + "properties": { + "structurizr.dsl.identifier": "webapplication" + }, + "name": "Web Application", + "description": "Delivers the static content and the Internet banking single page application.", + "relationships": [ + { + "id": "31", + "tags": "Relationship", + "sourceId": "19", + "destinationId": "17", + "description": "Delivers to the customer's web browser" + } + ], + "technology": "Java and Spring MVC", + "documentation": {} + } + ], + "documentation": {} + }, + { + "id": "6", + "tags": "Element,Software System,Existing System", + "properties": { + "structurizr.dsl.identifier": "atm" + }, + "name": "ATM", + "description": "Allows customers to withdraw cash.", + "relationships": [ + { + "id": "15", + "tags": "Relationship", + "sourceId": "6", + "destinationId": "4", + "description": "Uses" + } + ], + "group": "Big Bank plc", + "location": "Unspecified", + "documentation": {} + } + ], + "deploymentNodes": [ + { + "id": "50", + "tags": "Element,Deployment Node", + "name": "Developer Laptop", + "environment": "Development", + "technology": "Microsoft Windows 10 or Apple macOS", + "instances": "1", + "children": [ + { + "id": "59", + "tags": "Element,Deployment Node", + "name": "Docker Container - Database Server", + "environment": "Development", + "technology": "Docker", + "instances": "1", + "children": [ + { + "id": "60", + "tags": "Element,Deployment Node", + "name": "Database Server", + "environment": "Development", + "technology": "Oracle 12c", + "instances": "1", + "containerInstances": [ + { + "id": "61", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "developerdatabaseinstance" + }, + "environment": "Development", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "27" + } + ] + } + ] + }, + { + "id": "53", + "tags": "Element,Deployment Node", + "name": "Docker Container - Web Server", + "environment": "Development", + "technology": "Docker", + "instances": "1", + "children": [ + { + "id": "54", + "tags": "Element,Deployment Node", + "name": "Apache Tomcat", + "environment": "Development", + "technology": "Apache Tomcat 8.x", + "instances": "1", + "containerInstances": [ + { + "id": "55", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "developerwebapplicationinstance" + }, + "relationships": [ + { + "id": "56", + "sourceId": "55", + "destinationId": "52", + "description": "Delivers to the customer's web browser", + "linkedRelationshipId": "31" + } + ], + "environment": "Development", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "19" + }, + { + "id": "57", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "developerapiapplicationinstance" + }, + "relationships": [ + { + "id": "62", + "sourceId": "57", + "destinationId": "61", + "description": "Reads from and writes to", + "technology": "SQL/TCP", + "linkedRelationshipId": "45" + }, + { + "id": "66", + "sourceId": "57", + "destinationId": "65", + "description": "Makes API calls to", + "technology": "XML/HTTPS", + "linkedRelationshipId": "47" + } + ], + "environment": "Development", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "20" + } + ] + } + ] + }, + { + "id": "51", + "tags": "Element,Deployment Node", + "name": "Web Browser", + "environment": "Development", + "technology": "Chrome, Firefox, Safari, or Edge", + "instances": "1", + "containerInstances": [ + { + "id": "52", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "developersinglepageapplicationinstance" + }, + "relationships": [ + { + "id": "58", + "sourceId": "52", + "destinationId": "57", + "description": "Makes API calls to", + "technology": "JSON/HTTPS", + "linkedRelationshipId": "33" + } + ], + "environment": "Development", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "17" + } + ] + } + ] + }, + { + "id": "67", + "tags": "Element,Deployment Node", + "name": "Customer's mobile device", + "environment": "Live", + "technology": "Apple iOS or Android", + "instances": "1", + "containerInstances": [ + { + "id": "68", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "livemobileappinstance" + }, + "relationships": [ + { + "id": "81", + "sourceId": "68", + "destinationId": "79", + "description": "Makes API calls to", + "technology": "JSON/HTTPS", + "linkedRelationshipId": "37" + } + ], + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "18" + } + ] + }, + { + "id": "72", + "tags": "Element,Deployment Node", + "name": "Big Bank plc", + "environment": "Live", + "technology": "Big Bank plc data center", + "instances": "1", + "children": [ + { + "id": "73", + "tags": "Element,Deployment Node", + "name": "bigbank-web***", + "environment": "Live", + "technology": "Ubuntu 16.04 LTS", + "instances": "4", + "children": [ + { + "id": "74", + "tags": "Element,Deployment Node", + "name": "Apache Tomcat", + "environment": "Live", + "technology": "Apache Tomcat 8.x", + "instances": "1", + "containerInstances": [ + { + "id": "75", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "livewebapplicationinstance" + }, + "relationships": [ + { + "id": "76", + "sourceId": "75", + "destinationId": "71", + "description": "Delivers to the customer's web browser", + "linkedRelationshipId": "31" + } + ], + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "19" + } + ] + } + ] + }, + { + "id": "82", + "tags": "Element,Deployment Node", + "name": "bigbank-db01", + "environment": "Live", + "technology": "Ubuntu 16.04 LTS", + "instances": "1", + "children": [ + { + "id": "83", + "tags": "Element,Deployment Node", + "properties": { + "structurizr.dsl.identifier": "primarydatabaseserver" + }, + "name": "Oracle - Primary", + "relationships": [ + { + "id": "93", + "tags": "Relationship", + "sourceId": "83", + "destinationId": "87", + "description": "Replicates data to" + } + ], + "environment": "Live", + "technology": "Oracle 12c", + "instances": "1", + "containerInstances": [ + { + "id": "84", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "liveprimarydatabaseinstance" + }, + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "27" + } + ] + } + ] + }, + { + "id": "90", + "tags": "Element,Deployment Node", + "name": "bigbank-prod001", + "environment": "Live", + "instances": "1", + "softwareSystemInstances": [ + { + "id": "91", + "tags": "Software System Instance", + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "softwareSystemId": "4" + } + ] + }, + { + "id": "77", + "tags": "Element,Deployment Node", + "name": "bigbank-api***", + "environment": "Live", + "technology": "Ubuntu 16.04 LTS", + "instances": "8", + "children": [ + { + "id": "78", + "tags": "Element,Deployment Node", + "name": "Apache Tomcat", + "environment": "Live", + "technology": "Apache Tomcat 8.x", + "instances": "1", + "containerInstances": [ + { + "id": "79", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "liveapiapplicationinstance" + }, + "relationships": [ + { + "id": "85", + "sourceId": "79", + "destinationId": "84", + "description": "Reads from and writes to", + "technology": "SQL/TCP", + "linkedRelationshipId": "45" + }, + { + "id": "89", + "sourceId": "79", + "destinationId": "88", + "description": "Reads from and writes to", + "technology": "SQL/TCP", + "linkedRelationshipId": "45" + }, + { + "id": "92", + "sourceId": "79", + "destinationId": "91", + "description": "Makes API calls to", + "technology": "XML/HTTPS", + "linkedRelationshipId": "47" + } + ], + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "20" + } + ] + } + ] + }, + { + "id": "86", + "tags": "Element,Deployment Node,Failover", + "name": "bigbank-db02", + "environment": "Live", + "technology": "Ubuntu 16.04 LTS", + "instances": "1", + "children": [ + { + "id": "87", + "tags": "Element,Deployment Node,Failover", + "properties": { + "structurizr.dsl.identifier": "secondarydatabaseserver" + }, + "name": "Oracle - Secondary", + "environment": "Live", + "technology": "Oracle 12c", + "instances": "1", + "containerInstances": [ + { + "id": "88", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "livesecondarydatabaseinstance" + }, + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "27" + } + ] + } + ] + } + ] + }, + { + "id": "69", + "tags": "Element,Deployment Node", + "name": "Customer's computer", + "environment": "Live", + "technology": "Microsoft Windows or Apple macOS", + "instances": "1", + "children": [ + { + "id": "70", + "tags": "Element,Deployment Node", + "name": "Web Browser", + "environment": "Live", + "technology": "Chrome, Firefox, Safari, or Edge", + "instances": "1", + "containerInstances": [ + { + "id": "71", + "tags": "Container Instance", + "properties": { + "structurizr.dsl.identifier": "livesinglepageapplicationinstance" + }, + "relationships": [ + { + "id": "80", + "sourceId": "71", + "destinationId": "79", + "description": "Makes API calls to", + "technology": "JSON/HTTPS", + "linkedRelationshipId": "33" + } + ], + "environment": "Live", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "containerId": "17" + } + ] + } + ] + }, + { + "id": "63", + "tags": "Element,Deployment Node", + "name": "Big Bank plc", + "environment": "Development", + "technology": "Big Bank plc data center", + "instances": "1", + "children": [ + { + "id": "64", + "tags": "Element,Deployment Node", + "name": "bigbank-dev001", + "environment": "Development", + "instances": "1", + "softwareSystemInstances": [ + { + "id": "65", + "tags": "Software System Instance", + "environment": "Development", + "deploymentGroups": [ + "Default" + ], + "instanceId": 1, + "softwareSystemId": "4" + } + ] + } + ] + } + ] + }, + "documentation": {}, + "views": { + "systemContextViews": [ + { + "key": "SystemContext", + "order": 1, + "description": "The system context diagram for the Internet Banking System - diagram created with Structurizr.", + "properties": { + "structurizr.groups": "false" + }, + "softwareSystemId": "7", + "paperSize": "A5_Landscape", + "dimensions": { + "width": 2480, + "height": 1748 + }, + "animations": [ + { + "order": 1, + "elements": [ + "7" + ] + }, + { + "order": 2, + "elements": [ + "1" + ], + "relationships": [ + "8" + ] + }, + { + "order": 3, + "elements": [ + "4" + ], + "relationships": [ + "9" + ] + }, + { + "order": 4, + "elements": [ + "5" + ], + "relationships": [ + "11", + "10" + ] + } + ], + "enterpriseBoundaryVisible": true, + "elements": [ + { + "id": "1", + "x": 632, + "y": 64 + }, + { + "id": "4", + "x": 607, + "y": 1154 + }, + { + "id": "5", + "x": 1422, + "y": 659 + }, + { + "id": "7", + "x": 607, + "y": 659 + } + ], + "relationships": [ + { + "id": "11" + }, + { + "id": "8" + }, + { + "id": "10" + }, + { + "id": "9" + } + ] + } + ], + "containerViews": [ + { + "key": "Containers", + "order": 2, + "description": "The container diagram for the Internet Banking System - diagram created with Structurizr.", + "softwareSystemId": "7", + "paperSize": "A5_Landscape", + "dimensions": { + "width": 2480, + "height": 1748 + }, + "animations": [ + { + "order": 1, + "elements": [ + "1", + "4", + "5" + ], + "relationships": [ + "11" + ] + }, + { + "order": 2, + "elements": [ + "19" + ], + "relationships": [ + "28" + ] + }, + { + "order": 3, + "elements": [ + "17" + ], + "relationships": [ + "29", + "31" + ] + }, + { + "order": 4, + "elements": [ + "18" + ], + "relationships": [ + "30" + ] + }, + { + "order": 5, + "elements": [ + "20" + ], + "relationships": [ + "33", + "47", + "37", + "49" + ] + }, + { + "order": 6, + "elements": [ + "27" + ], + "relationships": [ + "45" + ] + } + ], + "externalSoftwareSystemBoundariesVisible": true, + "elements": [ + { + "id": "1", + "x": 1056, + "y": 39 + }, + { + "id": "4", + "x": 2012, + "y": 1189 + }, + { + "id": "27", + "x": 37, + "y": 1189 + }, + { + "id": "5", + "x": 2012, + "y": 679 + }, + { + "id": "17", + "x": 780, + "y": 679 + }, + { + "id": "18", + "x": 1283, + "y": 679 + }, + { + "id": "19", + "x": 37, + "y": 679 + }, + { + "id": "20", + "x": 1031, + "y": 1189 + } + ], + "relationships": [ + { + "id": "29" + }, + { + "id": "28" + }, + { + "id": "37" + }, + { + "id": "11" + }, + { + "id": "33" + }, + { + "id": "45" + }, + { + "id": "31" + }, + { + "id": "30" + }, + { + "id": "47" + }, + { + "id": "49" + } + ] + } + ], + "componentViews": [ + { + "key": "Components", + "order": 3, + "description": "The component diagram for the API Application - diagram created with Structurizr.", + "paperSize": "A5_Landscape", + "dimensions": { + "width": 2480, + "height": 1748 + }, + "animations": [ + { + "order": 1, + "elements": [ + "4", + "27", + "5", + "17", + "18" + ] + }, + { + "order": 2, + "elements": [ + "24", + "21" + ], + "relationships": [ + "44", + "36", + "40", + "32" + ] + }, + { + "order": 3, + "elements": [ + "22", + "25" + ], + "relationships": [ + "34", + "46", + "38", + "41" + ] + }, + { + "order": 4, + "elements": [ + "23", + "26" + ], + "relationships": [ + "35", + "48", + "39", + "42", + "43" + ] + } + ], + "containerId": "20", + "externalContainerBoundariesVisible": true, + "elements": [ + { + "id": "22", + "x": 1925, + "y": 436 + }, + { + "id": "23", + "x": 1015, + "y": 436 + }, + { + "id": "24", + "x": 105, + "y": 812 + }, + { + "id": "25", + "x": 1925, + "y": 812 + }, + { + "id": "26", + "x": 1015, + "y": 812 + }, + { + "id": "4", + "x": 1925, + "y": 1292 + }, + { + "id": "27", + "x": 105, + "y": 1292 + }, + { + "id": "5", + "x": 1015, + "y": 1292 + }, + { + "id": "17", + "x": 560, + "y": 10 + }, + { + "id": "18", + "x": 1470, + "y": 11 + }, + { + "id": "21", + "x": 105, + "y": 436 + } + ], + "relationships": [ + { + "id": "40", + "position": 50 + }, + { + "id": "41", + "position": 50 + }, + { + "id": "42" + }, + { + "id": "43" + }, + { + "id": "32", + "position": 35 + }, + { + "id": "36", + "position": 85 + }, + { + "id": "35", + "position": 45 + }, + { + "id": "34", + "position": 85 + }, + { + "id": "44", + "position": 55 + }, + { + "id": "46" + }, + { + "id": "48", + "position": 60 + }, + { + "id": "38", + "position": 40 + }, + { + "id": "39", + "position": 40 + } + ] + } + ], + "dynamicViews": [ + { + "key": "SignIn", + "order": 5, + "description": "Summarises how the sign in feature works in the single-page application - diagram created with Structurizr.", + "paperSize": "A5_Landscape", + "elementId": "20", + "externalBoundariesVisible": true, + "relationships": [ + { + "id": "32", + "description": "Submits credentials to", + "order": "1", + "response": false, + "vertices": [ + { + "x": 1238, + "y": 236 + } + ], + "routing": "Curved", + "position": 50 + }, + { + "id": "40", + "description": "Validates credentials using", + "order": "2", + "response": false, + "vertices": [ + { + "x": 2065, + "y": 845 + } + ], + "routing": "Curved" + }, + { + "id": "44", + "description": "select * from users where username = ?", + "order": "3", + "response": false, + "vertices": [ + { + "x": 1218, + "y": 1416 + } + ], + "routing": "Curved" + }, + { + "id": "44", + "description": "Returns user data to", + "order": "4", + "response": true, + "vertices": [ + { + "x": 1240, + "y": 1220 + } + ], + "routing": "Curved" + }, + { + "id": "40", + "description": "Returns true if the hashed password matches", + "order": "5", + "response": true, + "vertices": [ + { + "x": 1828, + "y": 841 + } + ], + "routing": "Curved" + }, + { + "id": "32", + "description": "Sends back an authentication token to", + "order": "6", + "response": true, + "vertices": [ + { + "x": 1210, + "y": 450 + } + ], + "routing": "Curved" + } + ], + "elements": [ + { + "id": "24", + "x": 1720, + "y": 1182 + }, + { + "id": "27", + "x": 290, + "y": 1182 + }, + { + "id": "17", + "x": 290, + "y": 192 + }, + { + "id": "21", + "x": 1720, + "y": 192 + } + ] + } + ], + "deploymentViews": [ + { + "key": "LiveDeployment", + "order": 7, + "description": "An example live deployment scenario for the Internet Banking System - diagram created with Structurizr.", + "softwareSystemId": "7", + "paperSize": "A4_Landscape", + "dimensions": { + "width": 3508, + "height": 2480 + }, + "environment": "Live", + "animations": [ + { + "order": 1, + "elements": [ + "69", + "70", + "71" + ] + }, + { + "order": 2, + "elements": [ + "67", + "68" + ] + }, + { + "order": 3, + "elements": [ + "77", + "78", + "79", + "72", + "73", + "74", + "75" + ], + "relationships": [ + "80", + "81", + "76" + ] + }, + { + "order": 4, + "elements": [ + "82", + "83", + "84" + ], + "relationships": [ + "85" + ] + }, + { + "order": 5, + "elements": [ + "88", + "86", + "87" + ], + "relationships": [ + "89", + "93" + ] + } + ], + "elements": [ + { + "id": "88", + "x": 2584, + "y": 189 + }, + { + "id": "77", + "x": 0, + "y": 0 + }, + { + "id": "67", + "x": 0, + "y": 0 + }, + { + "id": "78", + "x": 0, + "y": 0 + }, + { + "id": "68", + "x": 424, + "y": 961 + }, + { + "id": "79", + "x": 1504, + "y": 961 + }, + { + "id": "69", + "x": 0, + "y": 0 + }, + { + "id": "90", + "x": 0, + "y": 0 + }, + { + "id": "91", + "x": 2584, + "y": 1734 + }, + { + "id": "70", + "x": 0, + "y": 0 + }, + { + "id": "71", + "x": 424, + "y": 189 + }, + { + "id": "82", + "x": 0, + "y": 0 + }, + { + "id": "83", + "x": 0, + "y": 0 + }, + { + "id": "72", + "x": 0, + "y": 0 + }, + { + "id": "84", + "x": 2584, + "y": 961 + }, + { + "id": "73", + "x": 0, + "y": 0 + }, + { + "id": "74", + "x": 0, + "y": 0 + }, + { + "id": "86", + "x": 0, + "y": 0 + }, + { + "id": "75", + "x": 1504, + "y": 189 + }, + { + "id": "87", + "x": 0, + "y": 0 + } + ], + "relationships": [ + { + "id": "93" + }, + { + "id": "80" + }, + { + "id": "92" + }, + { + "id": "81" + }, + { + "id": "76" + }, + { + "id": "85" + }, + { + "id": "89" + } + ] + }, + { + "key": "DevelopmentDeployment", + "order": 6, + "description": "An example development deployment scenario for the Internet Banking System - diagram created with Structurizr.", + "softwareSystemId": "7", + "paperSize": "A5_Landscape", + "dimensions": { + "width": 2480, + "height": 1748 + }, + "environment": "Development", + "animations": [ + { + "order": 1, + "elements": [ + "50", + "51", + "52" + ] + }, + { + "order": 2, + "elements": [ + "55", + "57", + "53", + "54" + ], + "relationships": [ + "56", + "58" + ] + }, + { + "order": 3, + "elements": [ + "59", + "60", + "61" + ], + "relationships": [ + "62" + ] + } + ], + "elements": [ + { + "id": "55", + "x": 989, + "y": 136 + }, + { + "id": "57", + "x": 989, + "y": 476 + }, + { + "id": "59", + "x": 0, + "y": 0 + }, + { + "id": "60", + "x": 0, + "y": 0 + }, + { + "id": "61", + "x": 1827, + "y": 136 + }, + { + "id": "50", + "x": 0, + "y": 0 + }, + { + "id": "51", + "x": 0, + "y": 0 + }, + { + "id": "52", + "x": 152, + "y": 306 + }, + { + "id": "63", + "x": 0, + "y": 0 + }, + { + "id": "53", + "x": 0, + "y": 0 + }, + { + "id": "64", + "x": 0, + "y": 0 + }, + { + "id": "54", + "x": 0, + "y": 0 + }, + { + "id": "65", + "x": 1827, + "y": 1151 + } + ], + "relationships": [ + { + "id": "62", + "position": 50 + }, + { + "id": "56" + }, + { + "id": "66" + }, + { + "id": "58" + } + ] + } + ], + "imageViews": [ + { + "key": "MainframeBankingSystemFacade", + "order": 4, + "title": "Class diagram for the Mainframe Banking System Facade component", + "elementId": "25", + "content": "data:image/png;base64,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Nothw4alL26RZAluzJgx0aFDhzjooIOirKwsfRUAADZACmwAAAAAAADA10re/vOtt96Kbt26pW7/+eGHH6av0iINDQ0xY8aM2HnnnWOHHXaIp59+On0VAAA2QApsAAAAAAAAwNeqqKiIQYMGxSabbBJ9+vSJ6urq9FVarLy8PM4555zo2LFjnHDCCa16LgAAMoMCGwAAAAAAAPC1krOm7bTTTqlZ05544on0xaslOZvb66+/HltssUXsscceMWXKlPRVAADYwCiwAQAAAAAAAM1KzpA2atSo6NChQxx88MGxaNGi9FVW28KFC+PQQw+NLl26xBVXXJG+GACADYwCGwAAAAAAANCs3Nzc+NnPfhbdu3ePW265JX3xGqmpqYl77rknOnXqFAcccEDqNQAA2HApsAEAAAAAAAArSd7u89lnn42OHTvGXnvtFQsWLEhfZY19+eWX8cMf/jB69eoV48ePT18MAMAGRIENAAAAAAAAWElFRUUcffTRqZnSzj///GhoaEhfZY2Vl5enbh+avDXp//3f/0VZWVn6KgAAbCAU2AAAAAAAAICVTJ06Nbp16xa9e/eOyZMnpy9utXfffTc1A9tuu+0WL774YvpiAAA2EApsAAAAAAAAwApqa2tjyJAhqRnSjjzyyPTFbSIvLy/69euXukXpgAEDUrcsBQBgw6PABgAAAAAAAKwgJycndt111+jRo0c8/fTT6YvbzGOPPRbdu3ePfffdNwoKCtIXAwCwAVBgAwAAAAAAAFYwbty42HjjjWO//faLxYsXpy9uM1999VUceOCBseWWW8ZLL70UDQ0N6asAAJDhFNgAAAAAAACApRYtWhT7779/bLbZZnHDDTekL25TVVVVcdNNN8Umm2wSl156aVRUVKSvAgBAhlNgAwAAAAAAAJZ68803o0uXLvHtb3875s2bl764zX3wwQex++67x09+8pOYMmVK+mIAADKcAhsAAAAAAACQUldXFyeeeGJ07NgxTj311PTFa0VRUVEMGjQoOnXqFHfeeWfU1NSkrwIAQAZTYAMAAAAAAABSZs2aFTvttFNsscUW8c4776QvXmuefPLJ6Ny5cxx99NGRlZWVvhgAgAymwAYAAAAAAABEQ0NDXH311bHpppvGr371q3U6E9rcuXNjzz33jO233z6eeuqp1ExwAABsGBTYAAAAAAAAgMjNzY0f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qlSBLfl3hQIbAAAtpcAG7ZgCGwAAsC6kF9jq6uqirKysxUnOwgYAAHDllVdGx44d48wzz1RgAwCgxRTYoB1TYAMAANYFBTYAAKAtJP+uaCqwlZaWpi8GAIBmKbBBO6bABgAArAsKbAAAQFtQYAMAYE0osEE7psAGAACsCwpsAABAW2gqsJ111lkKbAAAtJgCG7RjCmwAAMC6oMAGAAC0hcsvvzz1d8XZZ5+twAYAQIspsEE7psAGAACsCwpsAABAW7jssstSf1cMGDBAgQ0AgBZTYIN2TIENAABYFxTYAACAtjBs2DAFNgAAVpsCG7RjCmwAAMC6oMAGAAC0hWSBbeONN46BAwem/lYAAICWUGCDdkyBDQAAWBcU2AAAgLbQVGAbNGiQAhsAAC2mwAbtWFOB7fDDD1dgAwAA1ppkYW348OGpAtuZZ56Z+t8VFRUtTnV1dfpTAgAAG6BLL700VWA777zzUn8rAABASyiwQTuWLLBtueWW8dvf/jZycnJWmuVARERERESkLVJaWpqaKSFZYDvttNNS/zs3N7fFSa6f/pwiIiIiIrLhZciQIakC24ABA1J/K6QvFxERaW3Ky8ujpqYm/at1YD2nwAbt2I033hjdunWLvfbaK0aNGhVjx44VERERERFp84wZMyb+93//N1VgO+CAA+L222+Pm2++ucW59dZbV3pOERERERHZsHLHHXfEoYceGhtttFH86le/Sv2tkL6OiIhIa5L8rLn//vvj888/T/9qHVjPKbBBO3buuedG586do2vXrvGtb31LRERERERkrWWrrbaKLbbYIjULdPoyERERERGRVaV3797RvXv3VIEt+f+cv+OOO660joiISGvSq1ev+OEPfxjjx49P/2odWM8psEE71q9fv9hss81i5513jmOPPTb69u0bffr0ERERERERafMk/9444YQT/N0hIiIiIiJrlBNPPDH23HPPVIEt+c8//elP/r4QEZFWp+mz5Mgjj0yVpXv06BFXX311+lfrwHpOgQ3asaOOOio1A9shhxwSCxYsiMLCQhERERERERERERERkXaXkpKSGDRoUKrAds4558S8efNWWkdERGRNUlBQEJ988kkcccQRsemmm6Y+b4DMosAG7djxxx8fXbp0icMPPzwqKyvTFwMAAAAAALQbF110UarAduGFF0ZVVVX6YgBYIw0NDZGV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bJB/AjbIMAEbAAAAAABQDpKArVu3btGmTRsBGwAlJWCD/BOwQYYJ2AAAAAAAgHLwzjvvpAFb27ZtBWwAlJSADfJPwAYZJmADAAAAAADKweDBgwVsANQIARvkn4ANMkzABgAAAAAAlIOqgK19+/YxcuTI4t0AUG0CNsg/ARtkmIANAAAAAAAoB2+//XZ07dpVwAZAyQnYIP8EbJBhAjYAAAAAAKAcvPXWW2nA1rFjRwEbACUlYIP8E7BBhgnYAAAAAACAcrB6wPbZZ58V7waAahOwQf4J2CDDBGwAAAAAAEA5SAK2TTbZJDp16iRgA6CkBGyQfwI2yDABGwAAAAAAUA4GDhyYBmydO3cWsAFQUgI2yD8BG2SYgA0AAAAAACgHAjYAaoqADfJPwAYZJmADAAAAAADKwYABA6JLly7pJmADoJQEbJB/AjbIMAEbAAAAAABQDt58883YeOONBWwAlJyADfJPwAYZJmADAAAAAADKQb9+/dKArWvXrjFq1Kji3QBQbQI2yD8BG2SYgA0AAAAAACgHAjYAaoqADfJPwAYZJmADAAAAAADKQVXAtummmwrYACgpARvkn4ANMkzABgAAAAAAlIM33ngjOnfuLGADoOQEbJB/AjbIMAEbAAAAAABQDv7+97+vCthGjx5dvBsAqk3ABvknYIMME7ABAAAAAADlIAnYOnXqFJtvvrmADYCSErBB/gnYIMMEbAAAAAAAQDl47bXXBGwA1AgBG+SfgA0yTMAGAACUs+R7zMiRI+P999+PN954I5599tl46KGH4r777ounn346Xn/99Xj33Xdj+PDhMXv27OKbAwAAZaQqYNtyyy0FbACUlIAN8k/ABhkmYAMAAMpJZWVlzJgxI95888247bbb4txzz43jjz8+DjvssNh///1jl112ia222iqdkWHHHXeMfffdN3r16hXHHnts/OpXv4obbrghXn755Zg4cWIsW7as+O4BAIAME7ABUFMEbJB/AjbIMAEbAABQDioqKmLs2LFxzz33xOmnn57Gal26dIkGDRpEhw4dokePHmm8lvx9ErP17t07vve978Vuu+0W3/rWt2LjjTeOhg0bRtu2bWPPPfeMU045JW688cYYOnRoOriYDFICAADZ9sorr0THjh3T/2llzJgxxbsBoNoEbJB/AjbIMAEbAACQZUm4NmXKlHRJ0B//+Mfp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bER6jAE2IiIiIiIyFAywEREREREZLinAVrNmTQbYiIjoX8EAG5HhY4CNSI8xwEZERERERIaCATYiIiIiIsN1584deYDN1taWATYiInrtGGAjMnwMsBHpMQbYiIiIiIjIUDDARkRERERkuG7fvs0AGxER/WsYYCMyfAywEekxBtiIiIiIiMhQMMBGRERERGS4pABbjRo15AG269ev604mIiL6RxhgIzJ8DLAR6TEG2IiIiIiIyFAwwEZEREREZLhu3rwpD7DZ29szwEZERK8dA2xEho8BNiI9xgAbEREREREZCgbYiIiIiIgMlxRgq169OgNsRET0r2CAjcjwMcBGpMcYYCMiIiIiIkPBABsRERERkeFSBdgcHBxw48YN3clERET/CANsRIaPATYiPcYAGxERERERGQoG2IiIiIiIDJf01DUpwFasWDEG2IiI6LVjgI3I8DHARqTHGGAjIiIiIiJDwQAbEREREZHhkgJs1apVY4CNiIj+FQywERk+BtiI9BgDbEREREREZCgYYCMiIiIiMlxSgK1q1apwdHRkgI2IiF47BtiIDB8DbER6jAE2IiIiIiIyFAywEREREREZrqtXrzLARkRE/xoG2IgMHwNsRHqMATYiIiIiIjIUDLARERERERkuVYBN6u/fvHlTdzIREdE/wgAbkeFjgI1IjzHARkREREREhoIBNiIiIiIiw3XlyhUG2IiI6F/DABuR4WOAjUiPMcBGRERERESGQgqw+fr6wsHBAVu2bNGdTEREREREb7HLly+jQoUK8leIMsBGRESvGwNsRIaPATYiPcYAGxERERERGYqYmBjMmzcPX375JS5cuKA7mYiIiIiI3mJSqOCHH37AmDFjEBERoTuZiIjoH2GAjcjwMcBGpMcYYCMiIqJ3XWZmJvz8/LBixQqsXr2ahYXlLS4rV67E7NmzMX36dPz888/5prOwsLydZdWqVfKnKj548EC3GSciem3S0tJw9OhRjgtYWPS4LF++HDNnzpT396X/rTudhYVFP8umTZvkT0wn0ncMsBEZPgbYiPQYA2xERET0rnv58iW6deuGkiVLwsPDA56eniwsLG954bHMwmJYxc3NDXXr1sX27dt1m3EiotdGepLryJEj5eMCd3f3fOciFhYW/Sns77OwvD2lVKlSqFatGvbu3avb9BLpHQbYiAwfA2xEeowBNiIi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Zh2URUREKDw8nKIo6qI6efKkpk+fLn9/fwJsAADH27Jli3URyfwJAACyBxNge/DBB1WlShWtXLnSOm+1n9tSFEVldJ0+fVqvvPIKATYAcAECbID7EWADHIwAG0yAzYyoVL16dfXq1UsvvfQSRVFUmurdu7ceeugh66SNABsAwOk8Ho+ioqKsPwEAQPZgAmwtW7ZU0aJF9cQTT+jll19Oc25LURSV0dWnTx81btzYCrCZoAMBNgDIugiwAe5HgA1wMAJsMAG2woULy8/PT4GBgQoKCqIoirpkFSpUyGrGEWADAAAAADiNCbA9/PDDypkzp4oUKaJixYqlOa+lKIrK6DK/a8w1FtMz69atGwE2AMjCCLAB7keADXAwAmwwAbYCBQqobt261jRLAwcOpCiKSlODBg1S+/btrbArATYAAAAAgNMkj8BmAiU9e/a0zmPt57YURVEZXeZ3TbNmzRiBDQBcgAAb4H4E2AAHI8AGE2DLmzevOnTooMjISCtgQlEUdalasGCBNeUwATYAAAAAgNOYAFuLFi1000036YcffkhzTktRFHWt6u2337YCbF27diXABgBZGAE2wP0IsAEORoANyQG2jh07Kioqyr4YAFIsXLiQABsAIEswzcIzZ84oLi7OvggAALhUcoCtatWq2rhxo30xAFwz/fr1I8AGAC5AgA1wPwJsgIMRYAMBNgDpRYANAJBVbN682TrXWb9+vX0RAABwKQJsADILATYAcAcCbID7EWADHIwAGwiwAUgvAmwAgKzi008/tS4gjRkzxr4IAAC4FAE2AJmFABsAuAMBNsD9CLABDkaADQTYAKQXATYAQFYxa9Ys6wLSqFGj7IsAAIBLEWADkFkIsAGAOxBgA9yPABvgYATYQIANQHoRYAMAZBWzZ88mwAYAQDZDgA1AZiHABgDuQIANcD8CbICDEWADATYA6UWADQCQVSRPIWqOdQEAQPZAgA1AZiHABgDuQIANcD8CbICDEWADATYA6UWADQCQVSxYsECBgYEaP348F4MAAMgmCLAByCwE2ADAHQiwAe5HgA1wMAJsIMAGIL0IsAEAsopffvlFPXv21Jo1a7gYBABANkGADUBmIcAGAO5AgA1wPwJsgIMRYAMBNgDpRYANAJBVmAtGu3fvVmRkpH0RAABwKQJsADILATYAcAcCbID7EWADHIwAGwiwAUgv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", + "contentType": "image/png" + } + ], + "configuration": { + "branding": {}, + "styles": { + "elements": [ + { + "tag": "Person", + "color": "#ffffff", + "fontSize": 22, + "shape": "Person" + }, + { + "tag": "Customer", + "background": "#08427b" + }, + { + "tag": "Bank Staff", + "background": "#999999" + }, + { + "tag": "Software System", + "background": "#1168bd", + "color": "#ffffff" + }, + { + "tag": "Existing System", + "background": "#999999", + "color": "#ffffff" + }, + { + "tag": "Container", + "background": "#438dd5", + "color": "#ffffff" + }, + { + "tag": "Web Browser", + "shape": "WebBrowser" + }, + { + "tag": "Mobile App", + "shape": "MobileDeviceLandscape" + }, + { + "tag": "Database", + "shape": "Cylinder" + }, + { + "tag": "Component", + "background": "#85bbf0", + "color": "#000000" + }, + { + "tag": "Failover", + "opacity": 25 + } + ] + }, + "terminology": {}, + "metadataSymbols": "SquareBrackets", + "lastSavedView": "SystemContext" + } + } +} \ No newline at end of file diff --git a/structurizr-client/src/test/resources/theme.json b/structurizr-client/src/test/resources/theme.json new file mode 100644 index 000000000..57e64ae06 --- /dev/null +++ b/structurizr-client/src/test/resources/theme.json @@ -0,0 +1,12 @@ +{ + "name" : "Theme", + "elements" : [ { + "tag" : "Tag", + "background" : "#ff0000", + "icon" : "logo.png" + } ], + "relationships" : [ { + "tag" : "Tag", + "color" : "#00ff00" + } ] +} \ No newline at end of file diff --git a/structurizr-client/test/integration/workspaceValidation/ChildDeploymentNodeNamesAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/ChildDeploymentNodeNamesAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ChildDeploymentNodeNamesAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/ChildDeploymentNodeNamesAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/ComponentNamesAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/ComponentNamesAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ComponentNamesAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/ComponentNamesAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/ContainerAssociatedWithComponentViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/ContainerAssociatedWithComponentViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ContainerAssociatedWithComponentViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/ContainerAssociatedWithComponentViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/ContainerNamesAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/ContainerNamesAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ContainerNamesAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/ContainerNamesAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/ElementAssociatedWithDynamicViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/ElementAssociatedWithDynamicViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ElementAssociatedWithDynamicViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/ElementAssociatedWithDynamicViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/ElementReferencedByViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/ElementReferencedByViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ElementReferencedByViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/ElementReferencedByViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/PeopleAndSoftwareSystemNamesAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/PeopleAndSoftwareSystemNamesAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/PeopleAndSoftwareSystemNamesAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/PeopleAndSoftwareSystemNamesAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/RelationshipDescriptionsAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/RelationshipDescriptionsAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/RelationshipDescriptionsAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/RelationshipDescriptionsAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/RelationshipReferencedByViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/RelationshipReferencedByViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/RelationshipReferencedByViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/RelationshipReferencedByViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithContainerViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithDeploymentViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel.json b/structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/SoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel.json rename to structurizr-client/src/test/resources/workspaceValidation/SoftwareSystemAssociatedWithSystemContextViewIsMissingFromTheModel.json diff --git a/structurizr-client/test/integration/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUnique.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUnique.json diff --git a/structurizr-client/test/integration/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments.json b/structurizr-client/src/test/resources/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments.json rename to structurizr-client/src/test/resources/workspaceValidation/TopLevelDeploymentNodeNamesAreNotUniqueButTheyExistInDifferentEnvironments.json diff --git a/structurizr-client/test/integration/workspaceValidation/ViewAssociatedWithFilteredViewIsMissingFromTheWorkspace.json b/structurizr-client/src/test/resources/workspaceValidation/ViewAssociatedWithFilteredViewIsMissingFromTheWorkspace.json similarity index 100% rename from structurizr-client/test/integration/workspaceValidation/ViewAssociatedWithFilteredViewIsMissingFromTheWorkspace.json rename to structurizr-client/src/test/resources/workspaceValidation/ViewAssociatedWithFilteredViewIsMissingFromTheWorkspace.json diff --git a/structurizr-client/test/integration/workspaceValidation/ViewKeysAreNotUnique.json b/structurizr-client/src/test/resources/workspaceValidation/ViewKeysAreNotUnique.json similarity index 80% rename from structurizr-client/test/integration/workspaceValidation/ViewKeysAreNotUnique.json rename to structurizr-client/src/test/resources/workspaceValidation/ViewKeysAreNotUnique.json index d3732b727..322d2b828 100644 --- a/structurizr-client/test/integration/workspaceValidation/ViewKeysAreNotUnique.json +++ b/structurizr-client/src/test/resources/workspaceValidation/ViewKeysAreNotUnique.json @@ -8,10 +8,10 @@ "views" : { "systemLandscapeViews" : [ { "key" : "key", - "enterpriseBoundaryVisible" : true + "order": 1 }, { "key" : "key", - "enterpriseBoundaryVisible" : true + "order": 2 } ], "configuration" : { "branding" : { }, diff --git a/structurizr-client/test/integration/com/structurizr/api/BackwardsCompatibilityTests.java b/structurizr-client/test/integration/com/structurizr/api/BackwardsCompatibilityTests.java deleted file mode 100644 index b5c9959da..000000000 --- a/structurizr-client/test/integration/com/structurizr/api/BackwardsCompatibilityTests.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.structurizr.api; - -import com.structurizr.util.WorkspaceUtils; -import org.junit.Test; - -import java.io.File; - -public class BackwardsCompatibilityTests { - - private static final File PATH_TO_WORKSPACE_FILES = new File("test/integration/backwardsCompatibility"); - - @Test - public void test() throws Exception { - for (File file : PATH_TO_WORKSPACE_FILES.listFiles(f -> f.getName().endsWith(".json"))) { - WorkspaceUtils.loadWorkspaceFromJson(file); - } - } - -} \ No newline at end of file diff --git a/structurizr-client/test/integration/workspaceValidation/ElementIdsAreNotUnique.json b/structurizr-client/test/integration/workspaceValidation/ElementIdsAreNotUnique.json deleted file mode 100644 index 671db2c38..000000000 --- a/structurizr-client/test/integration/workspaceValidation/ElementIdsAreNotUnique.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id" : 0, - "name" : "Name", - "description" : "Description", - "configuration" : { }, - "model" : { - "softwareSystems" : [ { - "id" : "1", - "tags" : "Element,Software System", - "name" : "Software System 1", - "location" : "Unspecified" - }, { - "id" : "1", - "tags" : "Element,Software System", - "name" : "Software System 2", - "location" : "Unspecified" - } ] - }, - "documentation" : { }, - "views" : { - "configuration" : { - "branding" : { }, - "styles" : { }, - "terminology" : { } - } - } -} \ No newline at end of file diff --git a/structurizr-client/test/integration/workspaceValidation/RelationshipIdsAreNotUnique.json b/structurizr-client/test/integration/workspaceValidation/RelationshipIdsAreNotUnique.json deleted file mode 100644 index a756781c1..000000000 --- a/structurizr-client/test/integration/workspaceValidation/RelationshipIdsAreNotUnique.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "id" : 0, - "name" : "Name", - "description" : "Description", - "configuration" : { }, - "model" : { - "people" : [ { - "id" : "1", - "tags" : "Element,Person", - "name" : "User", - "relationships" : [ { - "id" : "3", - "tags" : "Relationship,Synchronous", - "sourceId" : "1", - "destinationId" : "2", - "description" : "Uses 1", - "interactionStyle" : "Synchronous" - }, { - "id" : "3", - "tags" : "Relationship,Synchronous", - "sourceId" : "1", - "destinationId" : "2", - "description" : "Uses 2", - "interactionStyle" : "Synchronous" - } ], - "location" : "Unspecified" - } ], - "softwareSystems" : [ { - "id" : "2", - "tags" : "Element,Software System", - "name" : "Software System", - "location" : "Unspecified" - } ] - }, - "documentation" : { }, - "views" : { - "configuration" : { - "branding" : { }, - "styles" : { }, - "terminology" : { } - } - } -} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/api/StructurizrClientTests.java b/structurizr-client/test/unit/com/structurizr/api/StructurizrClientTests.java deleted file mode 100644 index d0a6caab3..000000000 --- a/structurizr-client/test/unit/com/structurizr/api/StructurizrClientTests.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.structurizr.api; - -import com.structurizr.Workspace; -import org.junit.Test; - -import static org.junit.Assert.*; - -public class StructurizrClientTests { - - private StructurizrClient structurizrClient; - - @Test - public void test_construction_WithTwoParameters() { - structurizrClient = new StructurizrClient("key", "secret"); - assertEquals("https://api.structurizr.com", structurizrClient.getUrl()); - assertEquals("key", structurizrClient.getApiKey()); - assertEquals("secret", structurizrClient.getApiSecret()); - } - - @Test - public void test_construction_WithThreeParameters() { - structurizrClient = new StructurizrClient("https://localhost", "key", "secret"); - assertEquals("https://localhost", structurizrClient.getUrl()); - assertEquals("key", structurizrClient.getApiKey()); - assertEquals("secret", structurizrClient.getApiSecret()); - } - - @Test - public void test_construction_WithThreeParameters_TruncatesTheApiUrl_WhenTheApiUrlHasATrailingSlashCharacter() { - structurizrClient = new StructurizrClient("https://localhost/", "key", "secret"); - assertEquals("https://localhost", structurizrClient.getUrl()); - assertEquals("key", structurizrClient.getApiKey()); - assertEquals("secret", structurizrClient.getApiSecret()); - } - - @Test - public void test_construction_ThrowsAnException_WhenANullApiKeyIsUsed() { - try { - structurizrClient = new StructurizrClient(null, "secret"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API key must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_construction_ThrowsAnException_WhenAnEmptyApiKeyIsUsed() { - try { - structurizrClient = new StructurizrClient(" ", "secret"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API key must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_construction_ThrowsAnException_WhenANullApiSecretIsUsed() { - try { - structurizrClient = new StructurizrClient("key", null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API secret must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_construction_ThrowsAnException_WhenAnEmptyApiSecretIsUsed() { - try { - structurizrClient = new StructurizrClient("key", " "); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API secret must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_construction_ThrowsAnException_WhenANullApiUrlIsUsed() { - try { - structurizrClient = new StructurizrClient(null, "key", "secret"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API URL must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_construction_ThrowsAnException_WhenAnEmptyApiUrlIsUsed() { - try { - structurizrClient = new StructurizrClient(" ", "key", "secret"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The API URL must not be null or empty.", iae.getMessage()); - } - } - - @Test - public void test_getWorkspace_ThrowsAnException_WhenTheWorkspaceIdIsNotValid() throws Exception { - try { - structurizrClient = new StructurizrClient("key", "secret"); - structurizrClient.getWorkspace(0); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The workspace ID must be a positive integer.", iae.getMessage()); - } - } - - @Test - public void test_putWorkspace_ThrowsAnException_WhenTheWorkspaceIdIsNotValid() throws Exception { - try { - structurizrClient = new StructurizrClient("key", "secret"); - structurizrClient.putWorkspace(0, new Workspace("Name", "Description")); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The workspace ID must be a positive integer.", iae.getMessage()); - } - } - - @Test - public void test_putWorkspace_ThrowsAnException_WhenANullWorkspaceIsSpecified() throws Exception { - try { - structurizrClient = new StructurizrClient("key", "secret"); - structurizrClient.putWorkspace(1234, null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("The workspace must not be null.", iae.getMessage()); - } - } - - @Test - public void test_constructionWithAPropertiesFile_ThrowsAnException_WhenNoPropertiesAreFound() { - try { - structurizrClient = new StructurizrClient(); - fail(); - } catch (Exception e) { - assertEquals("Could not find a structurizr.properties file on the classpath.", e.getMessage()); - } - } - - @Test - public void test_getAgent() { - structurizrClient = new StructurizrClient("key", "secret"); - assertTrue(structurizrClient.getAgent().startsWith("structurizr-java/")); - } - - @Test - public void test_setAgent() { - structurizrClient = new StructurizrClient("key", "secret"); - structurizrClient.setAgent("new_agent"); - assertEquals("new_agent", structurizrClient.getAgent()); - } - - @Test - public void test_setAgent_ThrowsAnException_WhenPassedNull() { - structurizrClient = new StructurizrClient("key", "secret"); - - try { - structurizrClient.setAgent(null); - fail(); - } catch (Exception e) { - assertEquals("An agent must be provided.", e.getMessage()); - } - } - - @Test - public void test_setAgent_ThrowsAnException_WhenPassedAnEmptyString() { - structurizrClient = new StructurizrClient("key", "secret"); - - try { - structurizrClient.setAgent(" "); - fail(); - } catch (Exception e) { - assertEquals("An agent must be provided.", e.getMessage()); - } - } - -} \ No newline at end of file diff --git a/structurizr-client/test/unit/com/structurizr/encryption/AesEncryptionStrategyTests.java b/structurizr-client/test/unit/com/structurizr/encryption/AesEncryptionStrategyTests.java deleted file mode 100644 index 8c8f4a8ec..000000000 --- a/structurizr-client/test/unit/com/structurizr/encryption/AesEncryptionStrategyTests.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.structurizr.encryption; - -import org.junit.Test; - -import javax.crypto.BadPaddingException; -import java.security.InvalidKeyException; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -public class AesEncryptionStrategyTests { - - @Test - public void test_encrypt_EncryptsPlaintext() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "06DC30A48ADEEE72D98E33C2CEAEAD3E", "ED124530AF64A5CAD8EF463CF5628434", "password"); - - String ciphertext = strategy.encrypt("Hello world"); - assertEquals("A/DzjV17WVS6ZAKsLOaC/Q==", ciphertext); - } - - @Test - public void test_decrypt_decryptsTheCiphertext_WhenTheSameStrategyInstanceIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - assertEquals("Hello world", strategy.decrypt(ciphertext)); - } - - @Test - public void test_decrypt_decryptsTheCiphertext_WhenTheSameConfigurationIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "password"); - assertEquals("Hello world", strategy.decrypt(ciphertext)); - } - - @Test - public void test_decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectKeySizeIsUsed() throws Exception { - try { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(256, strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "password"); - strategy.decrypt(ciphertext); - } catch (BadPaddingException | InvalidKeyException bpe) { - // BadPaddingException is thrown on Mac and Linux - // InvalidKeyException is thrown in Windows - } catch (Exception e) { - fail(); - } - } - - @Test(expected = BadPaddingException.class) - public void test_decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectIterationCountIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(strategy.getKeySize(), 2000, strategy.getSalt(), strategy.getIv(), "password"); - strategy.decrypt(ciphertext); - } - - @Test(expected = BadPaddingException.class) - public void test_decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectSaltIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), "133D30C2A658B3081279A97FD3B1F7CDE10C4FB61D39EEA8", strategy.getIv(), "password"); - strategy.decrypt(ciphertext); - } - - @Test(expected = BadPaddingException.class) - public void test_decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectIvIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), "1DED89E4FB15F61DC6433E3BADA4A891", "password"); - strategy.decrypt(ciphertext); - } - - @Test(expected = BadPaddingException.class) - public void test_decrypt_doesNotDecryptTheCiphertext_WhenTheIncorrectPassphraseIsUsed() throws Exception { - AesEncryptionStrategy strategy = new AesEncryptionStrategy(128, 1000, "password"); - - String ciphertext = strategy.encrypt("Hello world"); - - strategy = new AesEncryptionStrategy(strategy.getKeySize(), strategy.getIterationCount(), strategy.getSalt(), strategy.getIv(), "The Wrong Password"); - strategy.decrypt(ciphertext); - } - -} diff --git a/structurizr-client/test/unit/com/structurizr/util/WorkspaceUtilsTests.java b/structurizr-client/test/unit/com/structurizr/util/WorkspaceUtilsTests.java deleted file mode 100644 index 0ef0bcac0..000000000 --- a/structurizr-client/test/unit/com/structurizr/util/WorkspaceUtilsTests.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.structurizr.util; - -import com.structurizr.Workspace; -import org.junit.Test; - -import java.io.File; -import java.io.FilenameFilter; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -public class WorkspaceUtilsTests { - - @Test - public void test_loadWorkspaceFromJson_ThrowsAnException_WhenANullFileIsSpecified() { - try { - WorkspaceUtils.loadWorkspaceFromJson(null); - fail(); - } catch (Exception e) { - assertEquals("The path to a JSON file must be specified.", e.getMessage()); - } - } - - @Test - public void test_loadWorkspaceFromJson_ThrowsAnException_WhenTheFileDoesNotExist() { - try { - WorkspaceUtils.loadWorkspaceFromJson(new File("test/unit/com/structurizr/util/other-workspace.json")); - fail(); - } catch (Exception e) { - assertEquals("The specified JSON file does not exist.", e.getMessage()); - } - } - - @Test - public void test_saveWorkspaceToJson_ThrowsAnException_WhenANullWorkspaceIsSpecified() { - try { - WorkspaceUtils.saveWorkspaceToJson(null, null); - fail(); - } catch (Exception e) { - assertEquals("A workspace must be provided.", e.getMessage()); - } - } - - @Test - public void test_saveWorkspaceToJson_ThrowsAnException_WhenANullFileIsSpecified() { - try { - WorkspaceUtils.saveWorkspaceToJson(new Workspace("Name", "Description"), null); - fail(); - } catch (Exception e) { - assertEquals("The path to a JSON file must be specified.", e.getMessage()); - } - } - - @Test - public void test_saveWorkspaceToJson_and_loadWorkspaceFromJson() throws Exception { - File file = new File("build/workspace-utils.json"); - Workspace workspace = new Workspace("Name", "Description"); - WorkspaceUtils.saveWorkspaceToJson(workspace, file); - - workspace = WorkspaceUtils.loadWorkspaceFromJson(file); - assertEquals("Name", workspace.getName()); - } - - @Test - public void test_toJson_ThrowsAnException_WhenANullWorkspaceIsProvided() throws Exception { - try { - WorkspaceUtils.toJson(null, true); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A workspace must be provided.", iae.getMessage()); - } - } - - @Test - public void test_toJson() throws Exception { - Workspace workspace = new Workspace("Name", "Description"); - String indentedOutput = WorkspaceUtils.toJson(workspace, true); - String unindentedOutput = WorkspaceUtils.toJson(workspace, false); - - assertEquals("{\n" + - " \"id\" : 0,\n" + - " \"name\" : \"Name\",\n" + - " \"description\" : \"Description\",\n" + - " \"configuration\" : { },\n" + - " \"model\" : { },\n" + - " \"documentation\" : { },\n" + - " \"views\" : {\n" + - " \"configuration\" : {\n" + - " \"branding\" : { },\n" + - " \"styles\" : { },\n" + - " \"terminology\" : { }\n" + - " }\n" + - " }\n" + - "}", indentedOutput); - assertEquals("{\"id\":0,\"name\":\"Name\",\"description\":\"Description\",\"configuration\":{},\"model\":{},\"documentation\":{},\"views\":{\"configuration\":{\"branding\":{},\"styles\":{},\"terminology\":{}}}}", unindentedOutput); - } - - @Test - public void test_fromJson_ThrowsAnException_WhenANullJsonStringIsProvided() throws Exception { - try { - WorkspaceUtils.fromJson(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A JSON string must be provided.", iae.getMessage()); - } - } - - @Test - public void test_fromJson_ThrowsAnException_WhenAnEmptyJsonStringIsProvided() throws Exception { - try { - WorkspaceUtils.fromJson(" "); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A JSON string must be provided.", iae.getMessage()); - } - } - - @Test - public void test_fromJson() throws Exception { - Workspace workspace = WorkspaceUtils.fromJson("{\"id\":0,\"name\":\"Name\",\"description\":\"Description\",\"model\":{},\"documentation\":{},\"views\":{\"configuration\":{\"branding\":{},\"styles\":{},\"terminology\":{}}}}"); - assertEquals("Name", workspace.getName()); - assertEquals("Description", workspace.getDescription()); - } - -} \ No newline at end of file diff --git a/structurizr-component/README.md b/structurizr-component/README.md new file mode 100644 index 000000000..06bb920af --- /dev/null +++ b/structurizr-component/README.md @@ -0,0 +1,13 @@ +# structurizr-component + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-component.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-component) + +This library provides a facility to discover components in a Java codebase, via a combination of +[Apache Commons BCEL](https://commons.apache.org/proper/commons-bcel/) and [JavaParser](https://javaparser.org), +using a pluggable and customisable set of matching and filtering rules. +It is also available via the Structurizr DSL `!components` keyword. + +See the following tests for an example: + +- https://github.com/structurizr/java/blob/master/structurizr-component/src/test/java/com/structurizr/component/SpringPetClinicTests.java +- https://github.com/structurizr/java/blob/master/structurizr-dsl/src/test/resources/dsl/spring-petclinic/workspace.dsl \ No newline at end of file diff --git a/structurizr-component/build.gradle b/structurizr-component/build.gradle new file mode 100644 index 000000000..79940da9f --- /dev/null +++ b/structurizr-component/build.gradle @@ -0,0 +1,11 @@ +dependencies { + + api project(':structurizr-core') + implementation 'org.apache.bcel:bcel:6.11.0' + implementation 'com.github.javaparser:javaparser-symbol-solver-core:3.27.1' + + testImplementation project(':structurizr-annotation') + +} + +description = 'Component finder for Java code' \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/ComponentFinder.java b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinder.java new file mode 100644 index 000000000..387b66711 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinder.java @@ -0,0 +1,119 @@ +package com.structurizr.component; + +import com.structurizr.component.filter.TypeFilter; +import com.structurizr.component.provider.TypeProvider; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.util.StringUtils; +import org.apache.bcel.Repository; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.*; + +/** + * Allows you to find components in a Java codebase based upon a set of pluggable and customisable rules. + * Use the {@link ComponentFinderBuilder} to create an instance of this class. + */ +public final class ComponentFinder { + + private static final Log log = LogFactory.getLog(ComponentFinder.class); + + private static final String COMPONENT_TYPE_PROPERTY_NAME = "component.type"; + private static final String COMPONENT_SOURCE_PROPERTY_NAME = "component.src"; + + private final TypeRepository typeRepository = new TypeRepository(); + private final Container container; + private final List componentFinderStrategies = new ArrayList<>(); + + ComponentFinder(Container container, TypeFilter typeFilter, Collection typeProviders, List componentFinderStrategies) { + this.container = container; + this.componentFinderStrategies.addAll(componentFinderStrategies); + + log.debug("Initialising component finder:"); + log.debug(" - for: " + container.getCanonicalName()); + for (TypeProvider typeProvider : typeProviders) { + log.debug(" - from: " + typeProvider); + } + log.debug(" - filtered by: " + typeFilter); + for (ComponentFinderStrategy strategy : componentFinderStrategies) { + log.debug(" - with strategy: " + strategy); + } + + new TypeFinder().run(typeProviders, typeFilter, typeRepository); + Repository.clearCache(); + for (Type type : typeRepository.getTypes()) { + if (type.getJavaClass() != null) { + Repository.addClass(type.getJavaClass()); + new TypeDependencyFinder().run(type, typeRepository); + } + } + } + + /** + * Find components, using all configured strategies, in the order they were added. + */ + public Set run() { + Set discoveredComponents = new LinkedHashSet<>(); + Map componentMap = new HashMap<>(); + Set componentSet = new LinkedHashSet<>(); + + for (ComponentFinderStrategy componentFinderStrategy : componentFinderStrategies) { + Set set = componentFinderStrategy.run(typeRepository); + if (set.isEmpty()) { + log.debug("No components were found by " + componentFinderStrategy); + } + discoveredComponents.addAll(set); + } + + for (DiscoveredComponent discoveredComponent : discoveredComponents) { + Component component = container.addComponent(discoveredComponent.getName()); + component.addProperty(COMPONENT_TYPE_PROPERTY_NAME, discoveredComponent.getPrimaryType().getFullyQualifiedName()); + if (!StringUtils.isNullOrEmpty(discoveredComponent.getPrimaryType().getSource())) { + component.addProperty(COMPONENT_SOURCE_PROPERTY_NAME, discoveredComponent.getPrimaryType().getSource()); + } + component.setDescription(discoveredComponent.getDescription()); + component.setTechnology(discoveredComponent.getTechnology()); + component.setUrl(discoveredComponent.getUrl()); + + component.addTags(discoveredComponent.getTags().toArray(new String[0])); + for (String name : discoveredComponent.getProperties().keySet()) { + component.addProperty(name, discoveredComponent.getProperties().get(name)); + } + + componentMap.put(discoveredComponent, component); + componentSet.add(component); + } + + // find dependencies between all components + for (DiscoveredComponent discoveredComponent : discoveredComponents) { + Component component = componentMap.get(discoveredComponent); + log.debug("Component dependencies for \"" + component.getName() + "\":"); + Set typeDependencies = discoveredComponent.getAllDependencies(); + for (Type typeDependency : typeDependencies) { + for (DiscoveredComponent c : discoveredComponents) { + if (c != discoveredComponent) { + if (c.getAllTypes().contains(typeDependency)) { + Component componentDependency = componentMap.get(c); + log.debug(" -> " + componentDependency.getName()); + component.uses(componentDependency, ""); + } + } + } + } + if (component.getRelationships().isEmpty()) { + log.debug(" - none"); + } + } + + // now visit all components + for (DiscoveredComponent discoveredComponent : componentMap.keySet()) { + Component component = componentMap.get(discoveredComponent); + log.debug("Visiting \"" + component.getName() + "\""); + discoveredComponent.getComponentFinderStrategy().visit(component); + } + + return componentSet; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderBuilder.java b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderBuilder.java new file mode 100644 index 000000000..21f1a37e1 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderBuilder.java @@ -0,0 +1,105 @@ +package com.structurizr.component; + +import com.structurizr.component.filter.DefaultTypeFilter; +import com.structurizr.component.filter.TypeFilter; +import com.structurizr.component.provider.ClassDirectoryTypeProvider; +import com.structurizr.component.provider.ClassJarFileTypeProvider; +import com.structurizr.component.provider.SourceDirectoryTypeProvider; +import com.structurizr.component.provider.TypeProvider; +import com.structurizr.model.Container; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Provides a way to create a {@link ComponentFinder} instance. + */ +public class ComponentFinderBuilder { + + private static final String JAR_FILE_EXTENSION = ".jar"; + + private Container container; + private final List typeProviders = new ArrayList<>(); + private TypeFilter typeFilter; + private final List componentFinderStrategies = new ArrayList<>(); + + public ComponentFinderBuilder forContainer(Container container) { + this.container = container; + + return this; + } + + public ComponentFinderBuilder fromClasses(String path) { + return fromClasses(new File(path)); + } + + public ComponentFinderBuilder fromClasses(File path) { + if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist"); + } + + if (path.isDirectory()) { + this.typeProviders.add(new ClassDirectoryTypeProvider(path)); + } else if (path.getName().endsWith(JAR_FILE_EXTENSION)) { + this.typeProviders.add(new ClassJarFileTypeProvider(path)); + } else { + throw new IllegalArgumentException("Expected a directory of classes or a .jar file: " + path.getAbsolutePath()); + } + + return this; + } + + public ComponentFinderBuilder fromSource(String path) { + return fromSource(new File(path)); + } + + public ComponentFinderBuilder fromSource(File path) { + this.typeProviders.add(new SourceDirectoryTypeProvider(path)); + + return this; + } + + public ComponentFinderBuilder filteredBy(TypeFilter typeFilter) { + this.typeFilter = typeFilter; + + return this; + } + + public ComponentFinderBuilder withStrategy(ComponentFinderStrategy componentFinderStrategy) { + this.componentFinderStrategies.add(componentFinderStrategy); + + return this; + } + + public ComponentFinder build() { + if (container == null) { + throw new RuntimeException("A container must be specified"); + } + + if (typeProviders.isEmpty()) { + throw new RuntimeException("One or more type providers must be configured"); + } + + if (typeFilter == null) { + typeFilter = new DefaultTypeFilter(); + } + + if (componentFinderStrategies.isEmpty()) { + throw new RuntimeException("One or more component finder strategies must be configured"); + } + + return new ComponentFinder(container, typeFilter, typeProviders, componentFinderStrategies); + } + + @Override + public String toString() { + return "ComponentFinderBuilder{" + + "container=" + container + + ", typeProviders=" + typeProviders + + ", typeFilter=" + typeFilter + + ", componentFinderStrategies=" + componentFinderStrategies + + '}'; + } + +} diff --git a/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategy.java new file mode 100644 index 000000000..6862e7abe --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategy.java @@ -0,0 +1,118 @@ +package com.structurizr.component; + +import com.structurizr.component.description.DescriptionStrategy; +import com.structurizr.component.filter.TypeFilter; +import com.structurizr.component.matcher.TypeMatcher; +import com.structurizr.component.naming.NamingStrategy; +import com.structurizr.component.supporting.SupportingTypesStrategy; +import com.structurizr.component.url.UrlStrategy; +import com.structurizr.component.visitor.ComponentVisitor; +import com.structurizr.model.Component; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * A component finder strategy is a wrapper for a combination of the following: + * - {@link TypeMatcher} + * - {@link TypeFilter} + * - {@link SupportingTypesStrategy} + * - {@link NamingStrategy} + * + * Use the {@link ComponentFinderStrategyBuilder} to create an instance of this class. + */ +public final class ComponentFinderStrategy { + + private static final Log log = LogFactory.getLog(ComponentFinderStrategy.class); + + private final String technology; + private final TypeMatcher typeMatcher; + private final TypeFilter typeFilter; + private final SupportingTypesStrategy supportingTypesStrategy; + private final NamingStrategy namingStrategy; + private final DescriptionStrategy descriptionStrategy; + private final UrlStrategy urlStrategy; + private final ComponentVisitor componentVisitor; + + ComponentFinderStrategy(String technology, TypeMatcher typeMatcher, TypeFilter typeFilter, SupportingTypesStrategy supportingTypesStrategy, NamingStrategy namingStrategy, DescriptionStrategy descriptionStrategy, UrlStrategy urlStrategy, ComponentVisitor componentVisitor) { + this.technology = technology; + this.typeMatcher = typeMatcher; + this.typeFilter = typeFilter; + this.supportingTypesStrategy = supportingTypesStrategy; + this.namingStrategy = namingStrategy; + this.descriptionStrategy = descriptionStrategy; + this.urlStrategy = urlStrategy; + this.componentVisitor = componentVisitor; + } + + Set run(TypeRepository typeRepository) { + Set components = new LinkedHashSet<>(); + log.debug("Running " + this.toString()); + + Set types = typeRepository.getTypes(); + for (Type type : types) { + + boolean matched = typeMatcher.matches(type); + boolean accepted = typeFilter.accept(type); + + if (matched) { + if (accepted) { + log.debug(" + " + type.getFullyQualifiedName() + " (matched=true, accepted=true)"); + } else { + log.debug(" - " + type.getFullyQualifiedName() + " (matched=true, accepted=false)"); + } + } else { + log.debug(" - " + type.getFullyQualifiedName() + " (matched=false)"); + } + + if (matched && accepted) { + DiscoveredComponent component = new DiscoveredComponent(namingStrategy.nameOf(type), type); + component.setDescription(descriptionStrategy.descriptionOf(type)); + component.setTechnology(this.technology); + component.setUrl(urlStrategy.urlOf(type)); + component.addTags(type.getTags()); + Map properties = type.getProperties(); + for (String name : properties.keySet()) { + component.addProperty(name, properties.get(name)); + } + component.setComponentFinderStrategy(this); + components.add(component); + + // now find supporting types + Set supportingTypes = supportingTypesStrategy.findSupportingTypes(type, typeRepository); + if (supportingTypes.isEmpty()) { + log.debug(" - none"); + } else { + for (Type supportingType : supportingTypes) { + log.debug(" + supporting type: " + supportingType.getFullyQualifiedName()); + } + component.addSupportingTypes(supportingTypes); + } + } + } + + return components; + } + + void visit(Component component) { + this.componentVisitor.visit(component); + } + + @Override + public String toString() { + return "ComponentFinderStrategy{" + + "technology=" + (technology == null ? null : "'" + technology + "'") + + ", typeMatcher=" + typeMatcher + + ", typeFilter=" + typeFilter + + ", supportingTypesStrategy=" + supportingTypesStrategy + + ", namingStrategy=" + namingStrategy + + ", descriptionStrategy=" + descriptionStrategy + + ", urlStrategy=" + urlStrategy + + ", componentVisitor=" + componentVisitor + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategyBuilder.java b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategyBuilder.java new file mode 100644 index 000000000..6f692dc30 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/ComponentFinderStrategyBuilder.java @@ -0,0 +1,193 @@ +package com.structurizr.component; + +import com.structurizr.component.description.DefaultDescriptionStrategy; +import com.structurizr.component.description.DescriptionStrategy; +import com.structurizr.component.filter.DefaultTypeFilter; +import com.structurizr.component.filter.TypeFilter; +import com.structurizr.component.matcher.TypeMatcher; +import com.structurizr.component.naming.DefaultNamingStrategy; +import com.structurizr.component.naming.NamingStrategy; +import com.structurizr.component.supporting.DefaultSupportingTypesStrategy; +import com.structurizr.component.supporting.SupportingTypesStrategy; +import com.structurizr.component.url.DefaultUrlStrategy; +import com.structurizr.component.url.UrlStrategy; +import com.structurizr.component.visitor.ComponentVisitor; +import com.structurizr.component.visitor.DefaultComponentVisitor; +import com.structurizr.util.StringUtils; + +/** + * Provides a way to create a {@link ComponentFinderStrategy} instance. + */ +public final class ComponentFinderStrategyBuilder { + + private TypeMatcher typeMatcher; + private TypeFilter typeFilter; + private SupportingTypesStrategy supportingTypesStrategy; + private NamingStrategy namingStrategy; + private DescriptionStrategy descriptionStrategy; + private String technology; + private UrlStrategy urlStrategy; + private ComponentVisitor componentVisitor; + + public ComponentFinderStrategyBuilder() { + } + + public ComponentFinderStrategyBuilder matchedBy(TypeMatcher typeMatcher) { + if (typeMatcher == null) { + throw new IllegalArgumentException("A type matcher must be provided"); + } + + if (this.typeMatcher != null) { + throw new IllegalArgumentException("A type matcher has already been configured"); + } + + this.typeMatcher = typeMatcher; + + return this; + } + + public ComponentFinderStrategyBuilder filteredBy(TypeFilter typeFilter) { + if (typeFilter == null) { + throw new IllegalArgumentException("A type filter must be provided"); + } + + if (this.typeFilter != null) { + throw new IllegalArgumentException("A type filter has already been configured"); + } + + this.typeFilter = typeFilter; + + return this; + } + + public ComponentFinderStrategyBuilder supportedBy(SupportingTypesStrategy supportingTypesStrategy) { + if (supportingTypesStrategy == null) { + throw new IllegalArgumentException("A supporting types strategy must be provided"); + } + + if (this.supportingTypesStrategy != null) { + throw new IllegalArgumentException("A supporting types strategy has already been configured"); + } + + this.supportingTypesStrategy = supportingTypesStrategy; + + return this; + } + + public ComponentFinderStrategyBuilder withName(NamingStrategy namingStrategy) { + if (namingStrategy == null) { + throw new IllegalArgumentException("A naming strategy must be provided"); + } + + if (this.namingStrategy != null) { + throw new IllegalArgumentException("A naming strategy has already been configured"); + } + + this.namingStrategy = namingStrategy; + + return this; + } + + public ComponentFinderStrategyBuilder withDescription(DescriptionStrategy descriptionStrategy) { + if (descriptionStrategy == null) { + throw new IllegalArgumentException("A description strategy must be provided"); + } + + if (this.descriptionStrategy != null) { + throw new IllegalArgumentException("A description strategy has already been configured"); + } + + this.descriptionStrategy = descriptionStrategy; + + return this; + } + + public ComponentFinderStrategyBuilder withTechnology(String technology) { + if (StringUtils.isNullOrEmpty(technology)) { + throw new IllegalArgumentException("A technology must be provided"); + } + + if (!StringUtils.isNullOrEmpty(this.technology)) { + throw new IllegalArgumentException("A technology has already been configured"); + } + + this.technology = technology; + + return this; + } + + public ComponentFinderStrategyBuilder withUrl(UrlStrategy urlStrategy) { + if (urlStrategy == null) { + throw new IllegalArgumentException("A URL strategy must be provided"); + } + + if (this.urlStrategy != null) { + throw new IllegalArgumentException("A url strategy has already been configured"); + } + + this.urlStrategy = urlStrategy; + + return this; + } + + public ComponentFinderStrategyBuilder forEach(ComponentVisitor componentVisitor) { + if (componentVisitor == null) { + throw new IllegalArgumentException("A component visitor must be provided"); + } + + if (this.componentVisitor != null) { + throw new IllegalArgumentException("A component visitor has already been configured"); + } + + this.componentVisitor = componentVisitor; + + return this; + } + + public ComponentFinderStrategy build() { + if (typeMatcher == null) { + throw new RuntimeException("A type matcher must be provided"); + } + + if (typeFilter == null) { + typeFilter = new DefaultTypeFilter(); + } + + if (supportingTypesStrategy == null) { + supportingTypesStrategy = new DefaultSupportingTypesStrategy(); + } + + if (namingStrategy == null) { + namingStrategy = new DefaultNamingStrategy(); + } + + if (descriptionStrategy == null) { + descriptionStrategy = new DefaultDescriptionStrategy(); + } + + if (urlStrategy == null) { + urlStrategy = new DefaultUrlStrategy(); + } + + if (componentVisitor == null) { + componentVisitor = new DefaultComponentVisitor(); + } + + return new ComponentFinderStrategy(technology, typeMatcher, typeFilter, supportingTypesStrategy, namingStrategy, descriptionStrategy, urlStrategy, componentVisitor); + } + + @Override + public String toString() { + return "ComponentFinderStrategyBuilder{" + + "technology=" + (technology == null ? null : "'" + technology + "'") + + ", typeMatcher=" + typeMatcher + + ", typeFilter=" + typeFilter + + ", supportingTypesStrategy=" + supportingTypesStrategy + + ", namingStrategy=" + namingStrategy + + ", descriptionStrategy=" + descriptionStrategy + + ", urlStrategy=" + urlStrategy + + ", componentVisitor=" + componentVisitor + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/DiscoveredComponent.java b/structurizr-component/src/main/java/com/structurizr/component/DiscoveredComponent.java new file mode 100644 index 000000000..cd77da3c1 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/DiscoveredComponent.java @@ -0,0 +1,113 @@ +package com.structurizr.component; + +import java.util.*; + +final class DiscoveredComponent { + + private final Type primaryType; + private final String name; + private String description; + private String technology; + private String url; + private final List tags = new ArrayList<>(); + private final Map properties = new HashMap<>(); + private final Set supportingTypes = new HashSet<>(); + + private ComponentFinderStrategy componentFinderStrategy; + + DiscoveredComponent(String name, Type primaryType) { + this.name = name; + this.primaryType = primaryType; + } + + void addSupportingTypes(Set types) { + supportingTypes.addAll(types); + } + + Type getPrimaryType() { + return primaryType; + } + + String getName() { + return this.name; + } + + String getDescription() { + return description; + } + + void setDescription(String description) { + this.description = description; + } + + String getTechnology() { + return technology; + } + + void setTechnology(String technology) { + this.technology = technology; + } + + String getUrl() { + return url; + } + + void setUrl(String url) { + this.url = url; + } + + void addTags(List tags) { + this.tags.addAll(tags); + } + + List getTags() { + return List.copyOf(tags); + } + + void addProperty(String key, String value) { + properties.put(key, value); + } + + Map getProperties() { + return Map.copyOf(properties); + } + + Set getSupportingTypes() { + return new HashSet<>(supportingTypes); + } + + Set getAllTypes() { + Set types = new HashSet<>(); + + types.add(primaryType); + types.addAll(supportingTypes); + + return types; + } + + Set getAllDependencies() { + Set dependencies = new HashSet<>(); + + for (Type type : getAllTypes()) { + dependencies.addAll(type.getDependencies()); + } + + return dependencies; + } + + ComponentFinderStrategy getComponentFinderStrategy() { + return componentFinderStrategy; + } + + void setComponentFinderStrategy(ComponentFinderStrategy componentFinderStrategy) { + this.componentFinderStrategy = componentFinderStrategy; + } + + @Override + public String toString() { + return "DiscoveredComponent{" + + "name='" + name + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/Type.java b/structurizr-component/src/main/java/com/structurizr/component/Type.java new file mode 100644 index 000000000..dea55129f --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/Type.java @@ -0,0 +1,182 @@ +package com.structurizr.component; + +import com.structurizr.util.StringUtils; +import org.apache.bcel.classfile.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.*; + +/** + * Represents a Java type (e.g. class or interface) - it's a wrapper around a BCEL JavaClass. + */ +public class Type { + + private static final Log log = LogFactory.getLog(Type.class); + + private static final String STRUCTURIZR_TAG_ANNOTATION = "Lcom/structurizr/annotation/Tag;"; + private static final String STRUCTURIZR_TAGS_ANNOTATION = "Lcom/structurizr/annotation/Tags;"; + + private static final String STRUCTURIZR_PROPERTY_ANNOTATION = "Lcom/structurizr/annotation/Property;"; + private static final String STRUCTURIZR_PROPERTIES_ANNOTATION = "Lcom/structurizr/annotation/Properties;"; + + private JavaClass javaClass = null; + private final String fullyQualifiedName; + private String description; + private String source; + private final Set dependencies = new LinkedHashSet<>(); + + public Type(JavaClass javaClass) { + if (javaClass == null) { + throw new IllegalArgumentException("A BCEL JavaClass must be supplied"); + } + + this.fullyQualifiedName = javaClass.getClassName(); + this.javaClass = javaClass; + } + + public Type(String fullyQualifiedName) { + if (StringUtils.isNullOrEmpty(fullyQualifiedName)) { + throw new IllegalArgumentException("A fully qualified name must be supplied"); + } + + this.fullyQualifiedName = fullyQualifiedName; + this.javaClass = null; + } + + public String getFullyQualifiedName() { + return fullyQualifiedName; + } + + public String getName() { + return fullyQualifiedName.substring(fullyQualifiedName.lastIndexOf(".")+1); + } + + public String getPackageName() { + return getFullyQualifiedName().substring(0, getFullyQualifiedName().lastIndexOf(".")); + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public JavaClass getJavaClass() { + return this.javaClass; + } + + void setJavaClass(JavaClass javaClass) { + this.javaClass = javaClass; + } + + public void addDependency(Type type) { + this.dependencies.add(type); + } + + public Set getDependencies() { + return new LinkedHashSet<>(dependencies); + } + + public boolean hasDependency(Type type) { + return dependencies.contains(type); + } + + public boolean isAbstractClass() { + return javaClass.isAbstract() && javaClass.isClass(); + } + + public boolean isInterface() { + return javaClass.isInterface(); + } + + public List getTags() { + List tags = new ArrayList<>(); + + AnnotationEntry[] annotationEntries = javaClass.getAnnotationEntries(); + for (AnnotationEntry annotationEntry : annotationEntries) { + if (STRUCTURIZR_TAG_ANNOTATION.equals(annotationEntry.getAnnotationType())) { + ElementValuePair elementValuePair = annotationEntry.getElementValuePairs()[0]; + String tag = elementValuePair.getValue().stringifyValue(); + tags.add(tag); + } else if (STRUCTURIZR_TAGS_ANNOTATION.equals(annotationEntry.getAnnotationType())) { + ElementValuePair elementValuePair = annotationEntry.getElementValuePairs()[0]; + ArrayElementValue elementValue = (ArrayElementValue)elementValuePair.getValue(); + for (ElementValue value : elementValue.getElementValuesArray()) { + AnnotationElementValue annotationElementValue = (AnnotationElementValue)value; + AnnotationEntry tagAannotationEntry = annotationElementValue.getAnnotationEntry(); + String tag = tagAannotationEntry.getElementValuePairs()[0].getValue().stringifyValue(); + tags.add(tag); + } + } + } + + return tags; + } + + public Map getProperties() { + Map properties = new HashMap<>(); + + AnnotationEntry[] annotationEntries = javaClass.getAnnotationEntries(); + for (AnnotationEntry annotationEntry : annotationEntries) { + if (STRUCTURIZR_PROPERTY_ANNOTATION.equals(annotationEntry.getAnnotationType())) { + String name = annotationEntry.getElementValuePairs()[0].getValue().stringifyValue(); + String value = annotationEntry.getElementValuePairs()[1].getValue().stringifyValue(); + properties.put(name, value); + } else if (STRUCTURIZR_PROPERTIES_ANNOTATION.equals(annotationEntry.getAnnotationType())) { + ArrayElementValue arrayElementValue = (ArrayElementValue)annotationEntry.getElementValuePairs()[0].getValue(); + for (ElementValue elementValue : arrayElementValue.getElementValuesArray()) { + AnnotationElementValue annotationElementValue = (AnnotationElementValue)elementValue; + AnnotationEntry tagAannotationEntry = annotationElementValue.getAnnotationEntry(); + + String name = tagAannotationEntry.getElementValuePairs()[0].getValue().stringifyValue(); + String value = tagAannotationEntry.getElementValuePairs()[1].getValue().stringifyValue(); + properties.put(name, value); + } + } + } + + return properties; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Type type = (Type) o; + return fullyQualifiedName.equals(type.fullyQualifiedName); + } + + @Override + public int hashCode() { + return Objects.hash(fullyQualifiedName); + } + + @Override + public String toString() { + return this.fullyQualifiedName; + } + + public boolean implementsInterface(Type type) { + if (javaClass != null) { + try { + return javaClass.implementationOf(type.javaClass); + } catch (ClassNotFoundException e) { + log.warn(e); + } + } + + return false; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/TypeDependencyFinder.java b/structurizr-component/src/main/java/com/structurizr/component/TypeDependencyFinder.java new file mode 100644 index 000000000..2b8352b4d --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/TypeDependencyFinder.java @@ -0,0 +1,53 @@ +package com.structurizr.component; + +import org.apache.bcel.classfile.ConstantPool; +import org.apache.bcel.classfile.Method; +import org.apache.bcel.generic.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +class TypeDependencyFinder { + + private static final Log log = LogFactory.getLog(TypeDependencyFinder.class); + + void run(Type type, TypeRepository typeRepository) { + log.debug("Type dependencies for " + type.getFullyQualifiedName() + ":"); + ConstantPool cp = type.getJavaClass().getConstantPool(); + ConstantPoolGen cpg = new ConstantPoolGen(cp); + for (Method m : type.getJavaClass().getMethods()) { + MethodGen mg = new MethodGen(m, type.getJavaClass().getClassName(), cpg); + InstructionList il = mg.getInstructionList(); + if (il == null) { + continue; + } + + InstructionHandle[] instructionHandles = il.getInstructionHandles(); + for (InstructionHandle instructionHandle : instructionHandles) { + Instruction instruction = instructionHandle.getInstruction(); + if (!(instruction instanceof InvokeInstruction)) { + continue; + } + + InvokeInstruction invokeInstruction = (InvokeInstruction)instruction; + ReferenceType referenceType = invokeInstruction.getReferenceType(cpg); + if (!(referenceType instanceof ObjectType)) { + continue; + } + + ObjectType objectType = (ObjectType)referenceType; + String referencedClassName = objectType.getClassName(); + com.structurizr.component.Type referencedType = typeRepository.getType(referencedClassName); + if (referencedType != null && !referencedType.getFullyQualifiedName().equals(type.getFullyQualifiedName()) && !type.hasDependency(referencedType)) { + log.debug(" + " + referencedType.getFullyQualifiedName()); + + type.addDependency(referencedType); + } + } + } + + if (type.getDependencies().isEmpty()) { + log.debug(" - none"); + } + } + +} diff --git a/structurizr-component/src/main/java/com/structurizr/component/TypeFinder.java b/structurizr-component/src/main/java/com/structurizr/component/TypeFinder.java new file mode 100644 index 000000000..5ee4dcf3f --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/TypeFinder.java @@ -0,0 +1,36 @@ +package com.structurizr.component; + +import com.structurizr.component.filter.TypeFilter; +import com.structurizr.component.provider.TypeProvider; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.Collection; +import java.util.Set; + +class TypeFinder { + + private static final Log log = LogFactory.getLog(TypeFinder.class); + + void run(Collection typeProviders, TypeFilter typeFilter, TypeRepository typeRepository) { + for (TypeProvider typeProvider : typeProviders) { + log.debug("Running " + typeProvider.toString()); + + Set types = typeProvider.getTypes(); + for (com.structurizr.component.Type type : types) { + + boolean accepted = typeFilter.accept(type); + if (accepted) { + log.debug(" + " + type.getFullyQualifiedName() + " (accepted=true)"); + } else { + log.debug(" - " + type.getFullyQualifiedName() + " (accepted=false)"); + } + + if (accepted) { + typeRepository.add(type); + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/TypeRepository.java b/structurizr-component/src/main/java/com/structurizr/component/TypeRepository.java new file mode 100644 index 000000000..33683165e --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/TypeRepository.java @@ -0,0 +1,35 @@ +package com.structurizr.component; + +import java.util.LinkedHashSet; +import java.util.Set; + +public final class TypeRepository { + + private final Set types = new LinkedHashSet<>(); + + public void add(Type type) { + Type t = getType(type.getFullyQualifiedName()); + if (t == null) { + // type isn't yet registered, so add it + types.add(type); + } else { + if (type.getJavaClass() != null) { + // this is the BCEL identified type + t.setJavaClass(type.getJavaClass()); + } else { + // this is the source code identified type + t.setDescription(type.getDescription()); + t.setSource(type.getSource()); + } + } + } + + public Set getTypes() { + return new LinkedHashSet<>(types); + } + + Type getType(String fullyQualifiedClassName) { + return types.stream().filter(t -> t.getFullyQualifiedName().equals(fullyQualifiedClassName)).findFirst().orElse(null); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/description/DefaultDescriptionStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/description/DefaultDescriptionStrategy.java new file mode 100644 index 000000000..f2e1ef0ff --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/description/DefaultDescriptionStrategy.java @@ -0,0 +1,21 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; +import com.structurizr.component.naming.NamingStrategy; + +/** + * Uses the type description as-is. + */ +public class DefaultDescriptionStrategy implements DescriptionStrategy { + + @Override + public String descriptionOf(Type type) { + return type.getDescription(); + } + + @Override + public String toString() { + return "DefaultDescriptionStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/description/DescriptionStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/description/DescriptionStrategy.java new file mode 100644 index 000000000..cf8129d2c --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/description/DescriptionStrategy.java @@ -0,0 +1,12 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; + +/** + * Provides a way customise how component descriptions are generated. + */ +public interface DescriptionStrategy { + + String descriptionOf(Type type); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/description/FirstSentenceDescriptionStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/description/FirstSentenceDescriptionStrategy.java new file mode 100644 index 000000000..bdeccb146 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/description/FirstSentenceDescriptionStrategy.java @@ -0,0 +1,32 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * Uses the first sentence of the type description, or the description as-is if there are no sentences. + */ +public class FirstSentenceDescriptionStrategy implements DescriptionStrategy { + + @Override + public String descriptionOf(Type type) { + String description = type.getDescription(); + + if (StringUtils.isNullOrEmpty(description)) { + return ""; + } + + int index = description.indexOf('.'); + if (index == -1) { + return description.trim(); + } else { + return description.trim().substring(0, index+1); + } + } + + @Override + public String toString() { + return "FirstSentenceDescriptionStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/description/TruncatedDescriptionStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/description/TruncatedDescriptionStrategy.java new file mode 100644 index 000000000..a006ec504 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/description/TruncatedDescriptionStrategy.java @@ -0,0 +1,42 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * Truncates the type description to the max length, appending "..." when truncated. + */ +public class TruncatedDescriptionStrategy implements DescriptionStrategy { + + private final int maxLength; + + public TruncatedDescriptionStrategy(int maxLength) { + if (maxLength < 1) { + throw new IllegalArgumentException("Max length must be greater than 0"); + } + + this.maxLength = maxLength; + } + + @Override + public String descriptionOf(Type type) { + String description = type.getDescription(); + + if (StringUtils.isNullOrEmpty(description)) { + return description; + } + + if (description.length() > maxLength) { + return description.substring(0, maxLength) + "..."; + } else { + return description; + } + } + + @Override + public String toString() { + return "TruncatedDescriptionStrategy{" + + "maxLength=" + maxLength + + '}'; + } +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/filter/DefaultTypeFilter.java b/structurizr-component/src/main/java/com/structurizr/component/filter/DefaultTypeFilter.java new file mode 100644 index 000000000..64e9370d1 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/filter/DefaultTypeFilter.java @@ -0,0 +1,19 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; + +/** + * A type filter that accepts all types. + */ +public class DefaultTypeFilter implements TypeFilter { + + public boolean accept(Type type) { + return true; + } + + @Override + public String toString() { + return "DefaultTypeFilter{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeAbstractClassesTypeFilter.java b/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeAbstractClassesTypeFilter.java new file mode 100644 index 000000000..2a2d979c9 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeAbstractClassesTypeFilter.java @@ -0,0 +1,19 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; + +/** + * A type filter that excludes abstract types. + */ +public class ExcludeAbstractClassesTypeFilter implements TypeFilter { + + public boolean accept(Type type) { + return !type.isAbstractClass(); + } + + @Override + public String toString() { + return "ExcludeAbstractClassTypeFilter{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilter.java b/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilter.java new file mode 100644 index 000000000..c1ddaa9df --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilter.java @@ -0,0 +1,33 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * A type filter that excludes by matching a regex against the fully qualified type name. + */ +public class ExcludeFullyQualifiedNameRegexFilter implements TypeFilter { + + private final String regex; + + public ExcludeFullyQualifiedNameRegexFilter(String regex) { + if (StringUtils.isNullOrEmpty(regex)) { + throw new IllegalArgumentException("A regex must be supplied"); + } + + this.regex = regex; + } + + @Override + public boolean accept(Type type) { + return !type.getFullyQualifiedName().matches(regex); + } + + @Override + public String toString() { + return "ExcludeFullyQualifiedNameRegexFilter{" + + "regex='" + regex + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilter.java b/structurizr-component/src/main/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilter.java new file mode 100644 index 000000000..dcf3a9597 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilter.java @@ -0,0 +1,33 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * A type filter that includes by matching a regex against the fully qualified type name. + */ +public class IncludeFullyQualifiedNameRegexFilter implements TypeFilter { + + private final String regex; + + public IncludeFullyQualifiedNameRegexFilter(String regex) { + if (StringUtils.isNullOrEmpty(regex)) { + throw new IllegalArgumentException("A regex must be supplied"); + } + + this.regex = regex; + } + + @Override + public boolean accept(Type type) { + return type.getFullyQualifiedName().matches(regex); + } + + @Override + public String toString() { + return "IncludeFullyQualifiedNameRegexFilter{" + + "regex='" + regex + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/filter/TypeFilter.java b/structurizr-component/src/main/java/com/structurizr/component/filter/TypeFilter.java new file mode 100644 index 000000000..4d36b407a --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/filter/TypeFilter.java @@ -0,0 +1,12 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; + +/** + * Determines whether a given type should be accepted or not. + */ +public interface TypeFilter { + + boolean accept(Type type); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/AnnotationTypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/AnnotationTypeMatcher.java new file mode 100644 index 000000000..e418ce84c --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/AnnotationTypeMatcher.java @@ -0,0 +1,59 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; +import org.apache.bcel.classfile.AnnotationEntry; + +import java.lang.annotation.Annotation; + +/** + * Matches types based upon the presence of a type-level annotation. + */ +public class AnnotationTypeMatcher implements TypeMatcher { + + private final String annotationType; + + public AnnotationTypeMatcher(String annotationType) { + if (StringUtils.isNullOrEmpty(annotationType)) { + throw new IllegalArgumentException("An annotation type must be supplied"); + } + + this.annotationType = "L" + annotationType.replace(".", "/") + ";"; + } + + public AnnotationTypeMatcher(Class annotation) { + if (annotation == null) { + throw new IllegalArgumentException("An annotation must be supplied"); + } + + this.annotationType = "L" + annotation.getCanonicalName().replace(".", "/") + ";"; + } + + @Override + public boolean matches(Type type) { + if (type == null) { + throw new IllegalArgumentException("A type must be specified"); + } + + if (type.getJavaClass() == null) { + return false; + } + + AnnotationEntry[] annotationEntries = type.getJavaClass().getAnnotationEntries(); + for (AnnotationEntry annotationEntry : annotationEntries) { + if (annotationType.equals(annotationEntry.getAnnotationType())) { + return true; + } + } + + return false; + } + + @Override + public String toString() { + return "AnnotationTypeMatcher{" + + "annotationType='" + annotationType + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/ExtendsTypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/ExtendsTypeMatcher.java new file mode 100644 index 000000000..81bad21fe --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/ExtendsTypeMatcher.java @@ -0,0 +1,58 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; +import org.apache.bcel.classfile.JavaClass; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Matches types where the type extends the specified class. + */ +public class ExtendsTypeMatcher implements TypeMatcher { + + private static final Log log = LogFactory.getLog(ExtendsTypeMatcher.class); + + private final String className; + + public ExtendsTypeMatcher(String className) { + if (StringUtils.isNullOrEmpty(className)) { + throw new IllegalArgumentException("A fully qualified class name must be supplied"); + } + + this.className = className; + } + + @Override + public boolean matches(Type type) { + if (type == null) { + throw new IllegalArgumentException("A type must be specified"); + } + + if (type.getJavaClass() == null) { + return false; + } + + JavaClass javaClass = type.getJavaClass(); + try { + Set superClasses = Stream.of(javaClass.getSuperClasses()).map(JavaClass::getClassName).collect(Collectors.toSet()); + return superClasses.contains(className); + } catch (ClassNotFoundException e) { + log.warn("Cannot find super classes of " + type.getFullyQualifiedName(), e); + } + + return false; + } + + @Override + public String toString() { + return "ExtendsTypeMatcher{" + + "className='" + className + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/ImplementsTypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/ImplementsTypeMatcher.java new file mode 100644 index 000000000..4c6c24aaa --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/ImplementsTypeMatcher.java @@ -0,0 +1,50 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; +import org.apache.bcel.classfile.JavaClass; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.util.Set; + +/** + * Matches types where the type implements the specified interface. + */ +public class ImplementsTypeMatcher implements TypeMatcher { + + private static final Log log = LogFactory.getLog(ImplementsTypeMatcher.class); + + private final String interfaceName; + + public ImplementsTypeMatcher(String interfaceName) { + if (StringUtils.isNullOrEmpty(interfaceName)) { + throw new IllegalArgumentException("A fully qualified interface name must be supplied"); + } + + this.interfaceName = interfaceName; + } + + @Override + public boolean matches(Type type) { + if (type == null) { + throw new IllegalArgumentException("A type must be specified"); + } + + if (type.getJavaClass() == null) { + return false; + } + + JavaClass javaClass = type.getJavaClass(); + Set interfaceNames = Set.of(javaClass.getInterfaceNames()); + return interfaceNames.contains(interfaceName); + } + + @Override + public String toString() { + return "ImplementsTypeMatcher{" + + "interfaceName='" + interfaceName + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/NameSuffixTypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/NameSuffixTypeMatcher.java new file mode 100644 index 000000000..9f89ca07c --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/NameSuffixTypeMatcher.java @@ -0,0 +1,37 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * Matches types where the name of the type ends with the specified suffix. + */ +public class NameSuffixTypeMatcher implements TypeMatcher { + + private final String suffix; + + public NameSuffixTypeMatcher(String suffix) { + if (StringUtils.isNullOrEmpty(suffix)) { + throw new IllegalArgumentException("A suffix must be supplied"); + } + + this.suffix = suffix; + } + + @Override + public boolean matches(Type type) { + if (type == null) { + throw new IllegalArgumentException("A type must be specified"); + } + + return type.getFullyQualifiedName().endsWith(suffix); + } + + @Override + public String toString() { + return "NameSuffixTypeMatcher{" + + "suffix='" + suffix + '\'' + + '}'; + } + +} diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/RegexTypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/RegexTypeMatcher.java new file mode 100644 index 000000000..12bc4a586 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/RegexTypeMatcher.java @@ -0,0 +1,39 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +import java.util.regex.Pattern; + +/** + * Matches types using a regex against the fully qualified type name. + */ +public class RegexTypeMatcher implements TypeMatcher { + + private final Pattern regex; + + public RegexTypeMatcher(String regex) { + if (StringUtils.isNullOrEmpty(regex)) { + throw new IllegalArgumentException("A regex must be supplied"); + } + + this.regex = Pattern.compile(regex); + } + + @Override + public boolean matches(Type type) { + if (type == null) { + throw new IllegalArgumentException("A type must be specified"); + } + + return Pattern.matches(regex.pattern(), type.getFullyQualifiedName()); + } + + @Override + public String toString() { + return "RegexTypeMatcher{" + + "regex='" + regex + "'" + + '}'; + } + +} diff --git a/structurizr-component/src/main/java/com/structurizr/component/matcher/TypeMatcher.java b/structurizr-component/src/main/java/com/structurizr/component/matcher/TypeMatcher.java new file mode 100644 index 000000000..4293c937a --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/matcher/TypeMatcher.java @@ -0,0 +1,12 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; + +/** + * Determines whether a given type matches the rules for being identified as a component. + */ +public interface TypeMatcher { + + boolean matches(Type type); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultNamingStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultNamingStrategy.java new file mode 100644 index 000000000..61cc11b64 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultNamingStrategy.java @@ -0,0 +1,22 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; + +/** + * Uses Apache commons-lang to split a camel/Pascal cased name into separate words + * (e.g. "CustomerRepository" -> "Customer Repository"). + */ +public class DefaultNamingStrategy implements NamingStrategy { + + @Override + public String nameOf(Type type) { + String[] parts = org.apache.commons.lang3.StringUtils.splitByCharacterTypeCamelCase(type.getName()); + return String.join(" ", parts); + } + + @Override + public String toString() { + return "DefaultNamingStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultPackageNamingStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultPackageNamingStrategy.java new file mode 100644 index 000000000..4f93efecd --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/naming/DefaultPackageNamingStrategy.java @@ -0,0 +1,25 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; + +/** + * Uses Apache commons-lang to split a camel-cased package name into separate words + * (e.g. "com.example.order.package-info" -> "Order"). + */ +public class DefaultPackageNamingStrategy implements NamingStrategy { + + @Override + public String nameOf(Type type) { + String packageName = type.getPackageName(); + if (packageName.contains(".")) { + packageName = packageName.substring(packageName.lastIndexOf(".") + 1); + } + + String[] parts = org.apache.commons.lang3.StringUtils.splitByCharacterTypeCamelCase(packageName); + String name = String.join(" ", parts); + name = name.substring(0, 1).toUpperCase() + name.substring(1); + + return name; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/naming/FullyQualifiedNamingStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/naming/FullyQualifiedNamingStrategy.java new file mode 100644 index 000000000..a1e947497 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/naming/FullyQualifiedNamingStrategy.java @@ -0,0 +1,15 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; + +/** + * Uses the fully qualified type name. + */ +public class FullyQualifiedNamingStrategy implements NamingStrategy { + + @Override + public String nameOf(Type type) { + return type.getFullyQualifiedName(); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/naming/NamingStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/naming/NamingStrategy.java new file mode 100644 index 000000000..1520d7313 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/naming/NamingStrategy.java @@ -0,0 +1,12 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; + +/** + * Provides a way customise how component names are generated. + */ +public interface NamingStrategy { + + String nameOf(Type type); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/naming/TypeNamingStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/naming/TypeNamingStrategy.java new file mode 100644 index 000000000..47ffe3b90 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/naming/TypeNamingStrategy.java @@ -0,0 +1,19 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; + +/** + * Uses the simple/short name of the type (i.e. without the package name). + */ +public class TypeNamingStrategy implements NamingStrategy { + + public String nameOf(Type type) { + return type.getName(); + } + + @Override + public String toString() { + return "TypeNamingStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/provider/ClassDirectoryTypeProvider.java b/structurizr-component/src/main/java/com/structurizr/component/provider/ClassDirectoryTypeProvider.java new file mode 100644 index 000000000..a25606763 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/provider/ClassDirectoryTypeProvider.java @@ -0,0 +1,82 @@ +package com.structurizr.component.provider; + +import com.structurizr.component.Type; +import org.apache.bcel.classfile.ClassParser; +import org.apache.bcel.classfile.JavaClass; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * A type repository that uses Apache Commons BCEL to load Java classes from a local directory. + */ +public final class ClassDirectoryTypeProvider implements TypeProvider { + + private static final Log log = LogFactory.getLog(ClassDirectoryTypeProvider.class); + private static final String CLASS_FILE_EXTENSION = ".class"; + + private final File directory; + + public ClassDirectoryTypeProvider(File directory) { + if (directory == null) { + throw new IllegalArgumentException("A directory must be supplied"); + } + + if (!directory.exists()) { + throw new IllegalArgumentException(directory.getAbsolutePath() + " does not exist"); + } + + if (!directory.isDirectory()) { + throw new IllegalArgumentException(directory.getAbsolutePath() + " is not a directory"); + } + + this.directory = directory; + } + + public Set getTypes() { + Set types = new LinkedHashSet<>(); + + Set files = findClassFiles(directory); + for (File file : files) { + ClassParser parser = new ClassParser(file.getAbsolutePath()); + try { + JavaClass javaClass = parser.parse(); + types.add(new Type(javaClass)); + } catch (IOException e) { + log.warn(e); + } + } + + return types; + } + + private Set findClassFiles(File path) { + Set classFiles = new LinkedHashSet<>(); + if (path.isDirectory()) { + File[] files = path.listFiles(); + if (files != null) { + for (File file : files) { + classFiles.addAll(findClassFiles(file)); + } + } + } else { + if (path.getName().endsWith(CLASS_FILE_EXTENSION)) { + classFiles.add(path); + } + } + + return classFiles; + } + + @Override + public String toString() { + return "ClassDirectoryTypeProvider{" + + "directory=" + directory + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/provider/ClassJarFileTypeProvider.java b/structurizr-component/src/main/java/com/structurizr/component/provider/ClassJarFileTypeProvider.java new file mode 100644 index 000000000..908f2c34b --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/provider/ClassJarFileTypeProvider.java @@ -0,0 +1,68 @@ +package com.structurizr.component.provider; + +import com.structurizr.component.Type; +import org.apache.bcel.classfile.ClassParser; +import org.apache.bcel.classfile.JavaClass; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.io.IOException; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.jar.JarEntry; + +/** + * A type repository that uses Apache Commons BCEL to load Java classes from a local JAR file. + */ +public final class ClassJarFileTypeProvider implements TypeProvider { + + private static final Log log = LogFactory.getLog(ClassJarFileTypeProvider.class); + private static final String CLASS_FILE_EXTENSION = ".class"; + + private final File jarFile; + + public ClassJarFileTypeProvider(File file) { + this.jarFile = file; + } + + public Set getTypes() { + Set types = new LinkedHashSet<>(); + java.util.jar.JarFile jar = null; + try { + jar = new java.util.jar.JarFile(jarFile); + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(CLASS_FILE_EXTENSION)) { + continue; + } + + ClassParser parser = new ClassParser(jarFile.getAbsolutePath(), entry.getName()); + JavaClass javaClass = parser.parse(); + types.add(new Type(javaClass)); + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (jar != null) { + try { + jar.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + return types; + } + + @Override + public String toString() { + return "ClassJarFileTypeProvider{" + + "jarFile=" + jarFile + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/provider/JavadocCommentFilter.java b/structurizr-component/src/main/java/com/structurizr/component/provider/JavadocCommentFilter.java new file mode 100644 index 000000000..51b02900b --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/provider/JavadocCommentFilter.java @@ -0,0 +1,21 @@ +package com.structurizr.component.provider; + +/** + * Cleans up Javadoc comments for inclusion in the software architecture model. + */ +class JavadocCommentFilter { + + String filter(String s) { + if (s == null) { + return null; + } + + s = s.replaceAll("\\n", " "); + s = s.replaceAll("(?s)<.*?>", ""); + s = s.replaceAll("\\{@link (\\S*)\\}", "$1"); + s = s.replaceAll("\\{@link (\\S*) (.*?)\\}", "$2"); + + return s; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/provider/SourceDirectoryTypeProvider.java b/structurizr-component/src/main/java/com/structurizr/component/provider/SourceDirectoryTypeProvider.java new file mode 100644 index 000000000..94a0a986c --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/provider/SourceDirectoryTypeProvider.java @@ -0,0 +1,138 @@ +package com.structurizr.component.provider; + +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.PackageDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.comments.JavadocComment; +import com.github.javaparser.ast.visitor.VoidVisitorAdapter; +import com.structurizr.component.Type; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * A type provider that uses JavaParser to read Javadoc comments from source code. + */ +public final class SourceDirectoryTypeProvider implements TypeProvider { + + private static final Log log = LogFactory.getLog(SourceDirectoryTypeProvider.class); + private static final String JAVA_FILE_EXTENSION = ".java"; + + private final File directory; + private final Set types = new LinkedHashSet<>(); + + public SourceDirectoryTypeProvider(File directory) { + if (directory == null) { + throw new IllegalArgumentException("A directory must be supplied"); + } + + if (!directory.exists()) { + throw new IllegalArgumentException(directory.getAbsolutePath() + " does not exist"); + } + + if (!directory.isDirectory()) { + throw new IllegalArgumentException(directory.getAbsolutePath() + " is not a directory"); + } + + this.directory = directory; + StaticJavaParser.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); + } + + @Override + public Set getTypes() { + parse(directory); + + return new LinkedHashSet<>(types); + } + + private void parse(File path) { + if (path.isDirectory()) { + File[] files = path.listFiles(); + if (files != null) { + for (File file : files) { + try { + parse(file); + } catch (Exception e) { + log.warn("Error parsing " + file.getAbsolutePath(), e); + } + } + } + } else { + if (path.getName().endsWith(JAVA_FILE_EXTENSION)) { + try { + new VoidVisitorAdapter<>() { + @Override + public void visit(ClassOrInterfaceDeclaration n, Object arg) { + if (n.getFullyQualifiedName().isPresent()) { + String fullyQualifiedName = n.getFullyQualifiedName().get(); + Type type = new Type(fullyQualifiedName); + type.setSource(relativePath(path)); + + if (n.getComment().isPresent() && n.getComment().get() instanceof JavadocComment) { + JavadocComment javadocComment = (JavadocComment) n.getComment().get(); + String description = javadocComment.parse().getDescription().toText(); + + type.setDescription(new JavadocCommentFilter().filter(description)); + } + types.add(type); + } + } + + @Override + public void visit(PackageDeclaration n, Object arg) { + String PACKAGE_INFO_JAVA_SOURCE = "package-info.java"; + String PACKAGE_INFO_SUFFIX = ".package-info"; + + if (path.getName().endsWith(PACKAGE_INFO_JAVA_SOURCE)) { + String fullyQualifiedName = n.getName().asString() + PACKAGE_INFO_SUFFIX; + + Type type = new Type(fullyQualifiedName); + type.setSource(relativePath(path)); + + Node rootNode = n.findRootNode(); + if (rootNode != null && rootNode.getComment().isPresent() && rootNode.getComment().get() instanceof JavadocComment) { + JavadocComment javadocComment = (JavadocComment)rootNode.getComment().get(); + String description = javadocComment.parse().getDescription().toText(); + + type.setDescription(new JavadocCommentFilter().filter(description)); + } + + types.add(type); + } + } + }.visit(StaticJavaParser.parse(path), null); + } catch (IOException e) { + log.warn("Error parsing source code", e); + } + } else { + log.debug("Ignoring " + path.getAbsolutePath()); + } + } + } + + private String relativePath(File path) { + String relativePath = path.getAbsolutePath().replace(directory.getAbsolutePath(), ""); + + String pathSeparator = System.getProperty("file.separator"); + + if (relativePath.startsWith(pathSeparator)) { + relativePath = relativePath.substring(1); + } + + return relativePath; + } + + @Override + public String toString() { + return "SourceDirectoryTypeProvider{" + + "directory=" + directory + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/provider/TypeProvider.java b/structurizr-component/src/main/java/com/structurizr/component/provider/TypeProvider.java new file mode 100644 index 000000000..4172e4409 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/provider/TypeProvider.java @@ -0,0 +1,14 @@ +package com.structurizr.component.provider; + +import com.structurizr.component.Type; + +import java.util.Set; + +/** + * Provides a way to load Java types. + */ +public interface TypeProvider { + + Set getTypes(); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategy.java new file mode 100644 index 000000000..2e34875e2 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategy.java @@ -0,0 +1,23 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A strategy that finds all referenced types in the same package as the component type. + */ +public class AllReferencedTypesInPackageSupportingTypesStrategy implements SupportingTypesStrategy { + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + String packageName = type.getPackageName(); + + return type.getDependencies().stream() + .filter(dependency -> dependency.getPackageName().startsWith(packageName)) + .collect(Collectors.toSet()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategy.java new file mode 100644 index 000000000..e2b4724ce --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategy.java @@ -0,0 +1,18 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; + +/** + * A strategy that finds all referenced types, irrespective of package. + */ +public class AllReferencedTypesSupportingTypesStrategy implements SupportingTypesStrategy { + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + return type.getDependencies(); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategy.java new file mode 100644 index 000000000..cc998f5ed --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategy.java @@ -0,0 +1,23 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A strategy that finds all referenced types in the same package as the component type. + */ +public class AllTypesInPackageSupportingTypesStrategy implements SupportingTypesStrategy { + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + String packageName = type.getPackageName(); + + return typeRepository.getTypes().stream() + .filter(dependency -> dependency.getPackageName().equals(packageName)) + .collect(Collectors.toSet()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategy.java new file mode 100644 index 000000000..59f7a2a3a --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategy.java @@ -0,0 +1,23 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A strategy that finds all referenced types in the same package as the component type. + */ +public class AllTypesUnderPackageSupportingTypesStrategy implements SupportingTypesStrategy { + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + String packageName = type.getPackageName(); + + return typeRepository.getTypes().stream() + .filter(dependency -> dependency.getPackageName().startsWith(packageName)) + .collect(Collectors.toSet()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategy.java new file mode 100644 index 000000000..134d2cbae --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategy.java @@ -0,0 +1,24 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Collections; +import java.util.Set; + +/** + * A strategy that returns an empty set of supporting types. + */ +public class DefaultSupportingTypesStrategy implements SupportingTypesStrategy { + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + return Collections.emptySet(); + } + + @Override + public String toString() { + return "DefaultSupportingTypesStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategy.java new file mode 100644 index 000000000..1d0b64fe5 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategy.java @@ -0,0 +1,38 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A strategy that, given an interface, finds the implementation class with the specified prefix. + */ +public class ImplementationWithPrefixSupportingTypesStrategy implements SupportingTypesStrategy { + + private final String prefix; + + public ImplementationWithPrefixSupportingTypesStrategy(String prefix) { + this.prefix = prefix; + } + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + if (!type.isInterface()) { + throw new IllegalArgumentException("The type " + type.getFullyQualifiedName() + " is not an interface"); + } + + return typeRepository.getTypes().stream() + .filter(dependency -> dependency.implementsInterface(type) && dependency.getName().equals(prefix + type.getName())) + .collect(Collectors.toSet()); + } + + @Override + public String toString() { + return "ImplementationWithPrefixSupportingTypesStrategy{" + + "prefix='" + prefix + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategy.java new file mode 100644 index 000000000..f14006cea --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategy.java @@ -0,0 +1,38 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A strategy that, given an interface, finds the implementation class with the specified suffix. + */ +public class ImplementationWithSuffixSupportingTypesStrategy implements SupportingTypesStrategy { + + private final String suffix; + + public ImplementationWithSuffixSupportingTypesStrategy(String suffix) { + this.suffix = suffix; + } + + @Override + public Set findSupportingTypes(Type type, TypeRepository typeRepository) { + if (!type.isInterface()) { + throw new IllegalArgumentException("The type " + type.getFullyQualifiedName() + " is not an interface"); + } + + return typeRepository.getTypes().stream() + .filter(dependency -> dependency.implementsInterface(type) && dependency.getName().equals(type.getName() + suffix)) + .collect(Collectors.toSet()); + } + + @Override + public String toString() { + return "ImplementationWithSuffixSupportingTypesStrategy{" + + "suffix='" + suffix + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/supporting/SupportingTypesStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/supporting/SupportingTypesStrategy.java new file mode 100644 index 000000000..e3d6f21a3 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/supporting/SupportingTypesStrategy.java @@ -0,0 +1,15 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; + +import java.util.Set; + +/** + * Provides a strategy to identify the types that support a component. + */ +public interface SupportingTypesStrategy { + + Set findSupportingTypes(Type type, TypeRepository typeRepository); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/url/DefaultUrlStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/url/DefaultUrlStrategy.java new file mode 100644 index 000000000..2e6fa6d1e --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/url/DefaultUrlStrategy.java @@ -0,0 +1,20 @@ +package com.structurizr.component.url; + +import com.structurizr.component.Type; + +/** + * Generates a null URL. + */ +public class DefaultUrlStrategy implements UrlStrategy { + + @Override + public String urlOf(Type type) { + return null; + } + + @Override + public String toString() { + return "DefaultUrlStrategy{}"; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/url/PrefixSourceUrlStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/url/PrefixSourceUrlStrategy.java new file mode 100644 index 000000000..5aa951cd5 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/url/PrefixSourceUrlStrategy.java @@ -0,0 +1,41 @@ +package com.structurizr.component.url; + +import com.structurizr.component.Type; +import com.structurizr.util.StringUtils; + +/** + * Adds a given prefix to the component source location. + */ +public class PrefixSourceUrlStrategy implements UrlStrategy { + + private final String prefix; + + public PrefixSourceUrlStrategy(String prefix) { + if (StringUtils.isNullOrEmpty(prefix)) { + throw new IllegalArgumentException("A prefix must be supplied"); + } + + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + + this.prefix = prefix; + } + + @Override + public String urlOf(Type type) { + if (type.getSource() != null) { + return prefix + (type.getSource().replaceAll("\\\\", "/")); + } else { + return null; + } + } + + @Override + public String toString() { + return "PrefixUrlStrategy{" + + "prefix='" + prefix + '\'' + + '}'; + } + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/url/UrlStrategy.java b/structurizr-component/src/main/java/com/structurizr/component/url/UrlStrategy.java new file mode 100644 index 000000000..e066b3c1f --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/url/UrlStrategy.java @@ -0,0 +1,12 @@ +package com.structurizr.component.url; + +import com.structurizr.component.Type; + +/** + * Provides a way customise how component URLs are generated. + */ +public interface UrlStrategy { + + String urlOf(Type type); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/visitor/ComponentVisitor.java b/structurizr-component/src/main/java/com/structurizr/component/visitor/ComponentVisitor.java new file mode 100644 index 000000000..9c6a98646 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/visitor/ComponentVisitor.java @@ -0,0 +1,12 @@ +package com.structurizr.component.visitor; + +import com.structurizr.model.Component; + +/** + * Provides a way to visit each discovered component. + */ +public interface ComponentVisitor { + + void visit(Component component); + +} \ No newline at end of file diff --git a/structurizr-component/src/main/java/com/structurizr/component/visitor/DefaultComponentVisitor.java b/structurizr-component/src/main/java/com/structurizr/component/visitor/DefaultComponentVisitor.java new file mode 100644 index 000000000..054d7e9c9 --- /dev/null +++ b/structurizr-component/src/main/java/com/structurizr/component/visitor/DefaultComponentVisitor.java @@ -0,0 +1,19 @@ +package com.structurizr.component.visitor; + +import com.structurizr.model.Component; + +/** + * No-op implementation of a component visitor. + */ +public class DefaultComponentVisitor implements ComponentVisitor { + + @Override + public void visit(Component component) { + } + + @Override + public String toString() { + return "DefaultComponentVisitor{}"; + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/AbstractWorkspaceTestBase.java b/structurizr-component/src/test/java/com/structurizr/AbstractWorkspaceTestBase.java similarity index 100% rename from structurizr-core/test/unit/com/structurizr/AbstractWorkspaceTestBase.java rename to structurizr-component/src/test/java/com/structurizr/AbstractWorkspaceTestBase.java diff --git a/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderBuilderTests.java b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderBuilderTests.java new file mode 100644 index 000000000..13206859a --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderBuilderTests.java @@ -0,0 +1,47 @@ +package com.structurizr.component; + +import com.structurizr.Workspace; +import com.structurizr.model.Container; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class ComponentFinderBuilderTests { + + @Test + void build_ThrowsAnException_WhenAContainerHasNotBeenSpecified() { + try { + new ComponentFinderBuilder().build(); + fail(); + } catch (RuntimeException e) { + assertEquals("A container must be specified", e.getMessage()); + } + } + + @Test + void build_ThrowsAnException_WhenATypeProviderHasNotBeenConfigured() { + Container container = new Workspace("Name", "Description").getModel().addSoftwareSystem("Software System").addContainer("Container"); + try { + new ComponentFinderBuilder().forContainer(container).build(); + fail(); + } catch (RuntimeException e) { + assertEquals("One or more type providers must be configured", e.getMessage()); + } + } + + @Test + void build_ThrowsAnException_WhenAComponentFinderStrategyHasNotBeenConfigured() { + Container container = new Workspace("Name", "Description").getModel().addSoftwareSystem("Software System").addContainer("Container"); + File sources = new File("src/main/java"); + try { + new ComponentFinderBuilder().forContainer(container).fromSource(sources).build(); + fail(); + } catch (RuntimeException e) { + assertEquals("One or more component finder strategies must be configured", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderStrategyBuilderTests.java b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderStrategyBuilderTests.java new file mode 100644 index 000000000..b7937f09b --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderStrategyBuilderTests.java @@ -0,0 +1,171 @@ +package com.structurizr.component; + +import com.structurizr.component.description.FirstSentenceDescriptionStrategy; +import com.structurizr.component.description.TruncatedDescriptionStrategy; +import com.structurizr.component.filter.ExcludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.filter.IncludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.matcher.NameSuffixTypeMatcher; +import com.structurizr.component.naming.FullyQualifiedNamingStrategy; +import com.structurizr.component.naming.TypeNamingStrategy; +import com.structurizr.component.supporting.AllTypesInPackageSupportingTypesStrategy; +import com.structurizr.component.supporting.AllTypesUnderPackageSupportingTypesStrategy; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ComponentFinderStrategyBuilderTests { + + @Test + void matchedBy_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().matchedBy(null); + fail(); + } catch (Exception e) { + assertEquals("A type matcher must be provided", e.getMessage()); + } + } + + @Test + void matchedBy_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().matchedBy(new NameSuffixTypeMatcher("X")).matchedBy(new NameSuffixTypeMatcher("Y")); + fail(); + } catch (Exception e) { + assertEquals("A type matcher has already been configured", e.getMessage()); + } + } + + @Test + void filteredBy_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().filteredBy(null); + fail(); + } catch (Exception e) { + assertEquals("A type filter must be provided", e.getMessage()); + } + } + + @Test + void filteredBy_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().filteredBy(new IncludeFullyQualifiedNameRegexFilter(".*")).filteredBy(new ExcludeFullyQualifiedNameRegexFilter(".*")); + fail(); + } catch (Exception e) { + assertEquals("A type filter has already been configured", e.getMessage()); + } + } + + @Test + void supportedBy_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().supportedBy(null); + fail(); + } catch (Exception e) { + assertEquals("A supporting types strategy must be provided", e.getMessage()); + } + } + + @Test + void supportedBy_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().supportedBy(new AllTypesInPackageSupportingTypesStrategy()).supportedBy(new AllTypesUnderPackageSupportingTypesStrategy()); + fail(); + } catch (Exception e) { + assertEquals("A supporting types strategy has already been configured", e.getMessage()); + } + } + + @Test + void withName_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().withName(null); + fail(); + } catch (Exception e) { + assertEquals("A naming strategy must be provided", e.getMessage()); + } + } + + @Test + void withName_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().withName(new TypeNamingStrategy()).withName(new FullyQualifiedNamingStrategy()); + fail(); + } catch (Exception e) { + assertEquals("A naming strategy has already been configured", e.getMessage()); + } + } + + @Test + void withDescription_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().withDescription(null); + fail(); + } catch (Exception e) { + assertEquals("A description strategy must be provided", e.getMessage()); + } + } + + @Test + void withDescription_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().withDescription(new TruncatedDescriptionStrategy(50)).withDescription(new FirstSentenceDescriptionStrategy()); + fail(); + } catch (Exception e) { + assertEquals("A description strategy has already been configured", e.getMessage()); + } + } + + @Test + void withTechnology_ThrowsAnException_WhenPassedNull() { + try { + new ComponentFinderStrategyBuilder().withTechnology(null); + fail(); + } catch (Exception e) { + assertEquals("A technology must be provided", e.getMessage()); + } + } + + @Test + void withTechnology_ThrowsAnException_WhenPassedAnEmptyString() { + try { + new ComponentFinderStrategyBuilder().withTechnology(""); + fail(); + } catch (Exception e) { + assertEquals("A technology must be provided", e.getMessage()); + } + } + + @Test + void withTechnology_ThrowsAnException_WhenCalledTwice() { + try { + new ComponentFinderStrategyBuilder().withTechnology("X").withTechnology("Y"); + fail(); + } catch (Exception e) { + assertEquals("A technology has already been configured", e.getMessage()); + } + } + + @Test + void build_ThrowsAnException_WhenATypeMatcherHasNotBeenConfigured() { + try { + new ComponentFinderStrategyBuilder().build(); + fail(); + } catch (Exception e) { + assertEquals("A type matcher must be provided", e.getMessage()); + } + } + + @Test + void build() { + ComponentFinderStrategy strategy = new ComponentFinderStrategyBuilder() + .withTechnology("Spring MVC Controller") + .matchedBy(new NameSuffixTypeMatcher("Controller")) + .filteredBy(new IncludeFullyQualifiedNameRegexFilter("com.example.web.\\.*")) + .withName(new TypeNamingStrategy()) + .withDescription(new FirstSentenceDescriptionStrategy()) + .build(); + + assertEquals("ComponentFinderStrategy{technology='Spring MVC Controller', typeMatcher=NameSuffixTypeMatcher{suffix='Controller'}, typeFilter=IncludeFullyQualifiedNameRegexFilter{regex='com.example.web.\\.*'}, supportingTypesStrategy=DefaultSupportingTypesStrategy{}, namingStrategy=TypeNamingStrategy{}, descriptionStrategy=FirstSentenceDescriptionStrategy{}, urlStrategy=DefaultUrlStrategy{}, componentVisitor=DefaultComponentVisitor{}}", strategy.toString()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderTests.java b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderTests.java new file mode 100644 index 000000000..6b1689f04 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/ComponentFinderTests.java @@ -0,0 +1,69 @@ +package com.structurizr.component; + +import com.structurizr.Workspace; +import com.structurizr.component.description.FirstSentenceDescriptionStrategy; +import com.structurizr.component.filter.IncludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.matcher.ImplementsTypeMatcher; +import com.structurizr.component.naming.TypeNamingStrategy; +import com.structurizr.component.url.PrefixSourceUrlStrategy; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static com.github.javaparser.utils.Utils.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ComponentFinderTests { + + @Test + void run() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + Container container = softwareSystem.addContainer("Name"); + + ComponentFinder componentFinder = new ComponentFinderBuilder() + .forContainer(container) + .fromSource(new File("src/test/java")) + .fromClasses(new File("build/classes/java/test")) + .filteredBy(new IncludeFullyQualifiedNameRegexFilter("com\\.structurizr\\.component\\.example\\..*")) + .withStrategy(new ComponentFinderStrategyBuilder() + .withTechnology("Web Controller") + .matchedBy(new ImplementsTypeMatcher("com.structurizr.component.example.Controller")) + .filteredBy(new IncludeFullyQualifiedNameRegexFilter("com\\.structurizr\\.component\\.example\\..*")) + .withName(new TypeNamingStrategy()) + .withDescription(new FirstSentenceDescriptionStrategy()) + .withUrl(new PrefixSourceUrlStrategy("https://example.com/src/main/java")) + .build() + ) + .withStrategy(new ComponentFinderStrategyBuilder() + .withTechnology("Data Repository") + .matchedBy(new ImplementsTypeMatcher("com.structurizr.component.example.Repository")) + .filteredBy(new IncludeFullyQualifiedNameRegexFilter("com\\.structurizr\\.component\\.example\\..*")) + .withName(new TypeNamingStrategy()) + .withDescription(new FirstSentenceDescriptionStrategy()) + .withUrl(new PrefixSourceUrlStrategy("https://example.com/src/main/java")) + .build() + ) + .build(); + + componentFinder.run(); + + assertEquals(2, container.getComponents().size()); + Component exampleController = container.getComponentWithName("ExampleController"); + assertNotNull(exampleController); + assertEquals("https://example.com/src/main/java/com/structurizr/component/example/ExampleController.java", exampleController.getUrl()); + assertTrue(exampleController.hasTag("Controller")); + assertEquals("https://example.com", exampleController.getProperties().get("Documentation")); + + Component exampleRepository = container.getComponentWithName("ExampleRepository"); + assertNotNull(exampleRepository); + assertEquals("https://example.com/src/main/java/com/structurizr/component/example/ExampleRepository.java", exampleRepository.getUrl()); + + assertTrue(exampleController.hasEfferentRelationshipWith(exampleRepository)); + } + +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/SpringPetClinicTests.java b/structurizr-component/src/test/java/com/structurizr/component/SpringPetClinicTests.java new file mode 100644 index 000000000..4df29513a --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/SpringPetClinicTests.java @@ -0,0 +1,132 @@ +package com.structurizr.component; + +import com.structurizr.Workspace; +import com.structurizr.component.description.FirstSentenceDescriptionStrategy; +import com.structurizr.component.filter.ExcludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.filter.IncludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.matcher.AnnotationTypeMatcher; +import com.structurizr.component.matcher.ImplementsTypeMatcher; +import com.structurizr.component.matcher.NameSuffixTypeMatcher; +import com.structurizr.component.url.PrefixSourceUrlStrategy; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.Person; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.util.StringUtils; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class SpringPetClinicTests { + + @Test + void springPetClinic() { + String springPetClinicHome = System.getenv().getOrDefault("SPRING_PETCLINIC_HOME", ""); + System.out.println(springPetClinicHome); + if (!StringUtils.isNullOrEmpty(springPetClinicHome)) { + System.out.println("Running Spring PetClinic example..."); + + Workspace workspace = new Workspace("Spring PetClinic", "Description"); + Person clinicEmployee = workspace.getModel().addPerson("Clinic Employee"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Spring PetClinic"); + Container webApplication = softwareSystem.addContainer("Web Application"); + Container relationalDatabaseSchema = softwareSystem.addContainer("Relational Database Schema"); + + ComponentFinder componentFinder = new ComponentFinderBuilder() + .forContainer(webApplication) + .fromClasses(new File(springPetClinicHome, "target/spring-petclinic-3.4.0-SNAPSHOT.jar")) + .fromSource(new File(springPetClinicHome, "src/main/java")) + .filteredBy(new IncludeFullyQualifiedNameRegexFilter("org\\.springframework\\.samples\\.petclinic\\..*")) + .withStrategy( + new ComponentFinderStrategyBuilder() + .matchedBy(new AnnotationTypeMatcher("org.springframework.stereotype.Controller")) + .filteredBy(new ExcludeFullyQualifiedNameRegexFilter(".*.CrashController")) + .withTechnology("Spring MVC Controller") + .withUrl(new PrefixSourceUrlStrategy("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java")) + .forEach((component -> { + clinicEmployee.uses(component, "Uses"); + component.addTags(component.getTechnology()); + })) + .build() + ) + .withStrategy( + new ComponentFinderStrategyBuilder() + .matchedBy(new NameSuffixTypeMatcher("Repository")) + .withDescription(new FirstSentenceDescriptionStrategy()) + .withTechnology("Spring Data Repository") + .withUrl(new PrefixSourceUrlStrategy("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java")) + .forEach((component -> { + component.uses(relationalDatabaseSchema, "Reads from and writes to"); + component.addTags(component.getTechnology()); + })) + .build() + ) + .build(); + + componentFinder.run(); + assertEquals(7, webApplication.getComponents().size()); + + Component welcomeController = webApplication.getComponentWithName("Welcome Controller"); + assertNotNull(welcomeController); + assertEquals("org.springframework.samples.petclinic.system.WelcomeController", welcomeController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/system/WelcomeController.java", welcomeController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/system/WelcomeController.java", welcomeController.getUrl()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(welcomeController)); + + Component ownerController = webApplication.getComponentWithName("Owner Controller"); + assertNotNull(ownerController); + assertEquals("org.springframework.samples.petclinic.owner.OwnerController", ownerController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/OwnerController.java", ownerController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java", ownerController.getUrl()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(ownerController)); + + Component petController = webApplication.getComponentWithName("Pet Controller"); + assertNotNull(petController); + assertEquals("org.springframework.samples.petclinic.owner.PetController", petController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/PetController.java", petController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/PetController.java", petController.getUrl()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(petController)); + + Component vetController = webApplication.getComponentWithName("Vet Controller"); + assertNotNull(vetController); + assertEquals("org.springframework.samples.petclinic.vet.VetController", vetController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/vet/VetController.java", vetController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/vet/VetController.java", vetController.getUrl()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(vetController)); + + Component visitController = webApplication.getComponentWithName("Visit Controller"); + assertNotNull(visitController); + assertEquals("org.springframework.samples.petclinic.owner.VisitController", visitController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/VisitController.java", visitController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java", visitController.getUrl()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(visitController)); + + Component ownerRepository = webApplication.getComponentWithName("Owner Repository"); + assertNotNull(ownerRepository); + assertEquals("Repository class for Owner domain objects All method names are compliant with Spring Data naming conventions so this interface can easily be extended for Spring Data.", ownerRepository.getDescription()); + assertEquals("org.springframework.samples.petclinic.owner.OwnerRepository", ownerRepository.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/OwnerRepository.java", ownerRepository.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java", ownerRepository.getUrl()); + assertTrue(ownerRepository.hasEfferentRelationshipWith(relationalDatabaseSchema, "Reads from and writes to")); + + Component vetRepository = webApplication.getComponentWithName("Vet Repository"); + assertNotNull(vetRepository); + assertEquals("Repository class for Vet domain objects All method names are compliant with Spring Data naming conventions so this interface can easily be extended for Spring Data.", vetRepository.getDescription()); + assertEquals("org.springframework.samples.petclinic.vet.VetRepository", vetRepository.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/vet/VetRepository.java", vetRepository.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java", vetRepository.getUrl()); + assertTrue(vetRepository.hasEfferentRelationshipWith(relationalDatabaseSchema, "Reads from and writes to")); + + assertTrue(welcomeController.getRelationships().isEmpty()); + assertNotNull(petController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(visitController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(ownerController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(vetController.getEfferentRelationshipWith(vetRepository)); + } else { + System.out.println("Skipping Spring PetClinic example..."); + } + } + +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/TypeRepositoryTests.java b/structurizr-component/src/test/java/com/structurizr/component/TypeRepositoryTests.java new file mode 100644 index 000000000..53cdedc0b --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/TypeRepositoryTests.java @@ -0,0 +1,52 @@ +package com.structurizr.component; + +import org.apache.bcel.classfile.ClassParser; +import org.apache.bcel.classfile.JavaClass; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +public class TypeRepositoryTests { + + @Test + void add_MergesClassInformation() throws Exception { + String fqn = "com.structurizr.component.TypeRepositoryTests"; + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/TypeRepositoryTests.class").getAbsolutePath()); + JavaClass javaClass = parser.parse(); + + Type classType = new Type(javaClass); + Type sourceType = new Type(fqn); + sourceType.setSource("source path"); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(sourceType); // source first + typeRepository.add(classType); + + assertSame(javaClass, typeRepository.getType(fqn).getJavaClass()); + assertEquals("source path", typeRepository.getType(fqn).getSource()); + } + + @Test + void add_MergesSourceInformation() throws Exception { + String fqn = "com.structurizr.component.TypeRepositoryTests"; + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/TypeRepositoryTests.class").getAbsolutePath()); + JavaClass javaClass = parser.parse(); + + Type classType = new Type(javaClass); + Type sourceType = new Type(fqn); + sourceType.setSource("source path"); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(classType); // class first + typeRepository.add(sourceType); + + assertSame(javaClass, typeRepository.getType(fqn).getJavaClass()); + assertEquals("source path", typeRepository.getType(fqn).getSource()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/TypeTests.java b/structurizr-component/src/test/java/com/structurizr/component/TypeTests.java new file mode 100644 index 000000000..1df5874d5 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/TypeTests.java @@ -0,0 +1,78 @@ +package com.structurizr.component; + +import com.structurizr.component.matcher.ImplementsTypeMatcher; +import org.apache.bcel.classfile.ClassParser; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +public class TypeTests { + + private Type type; + + @Test + void name() { + type = new Type("com.example.ClassName"); + assertEquals("ClassName", type.getName()); + } + + @Test + void packageName() { + type = new Type("com.example.ClassName"); + assertEquals("com.example", type.getPackageName()); + } + + @Test + void getTags_WhenTypeHasOneTag() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/types/TypeWithTag.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + List tags = type.getTags(); + assertEquals(1, tags.size()); + assertTrue(tags.contains("Tag 1")); + } + + @Test + void getTags_WhenTypeHasManyTags() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/types/TypeWithTags.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + List tags = type.getTags(); + assertEquals(3, tags.size()); + assertEquals("Tag 1", tags.get(0)); + assertEquals("Tag 2", tags.get(1)); + assertEquals("Tag 3", tags.get(2)); + } + + @Test + void getTags_WhenTypeHasOneProperty() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/types/TypeWithProperty.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + Map properties = type.getProperties(); + assertEquals(1, properties.size()); + assertEquals("Value", properties.get("Name")); + } + + @Test + void getTags_WhenTypeHasManyProperties() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/types/TypeWithProperties.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + Map properties = type.getProperties(); + assertEquals(3, properties.size()); + assertEquals("Value1", properties.get("Name1")); + assertEquals("Value2", properties.get("Name2")); + assertEquals("Value3", properties.get("Name3")); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/description/FirstSentenceDescriptionStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/description/FirstSentenceDescriptionStrategyTests.java new file mode 100644 index 000000000..4039cf8f8 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/description/FirstSentenceDescriptionStrategyTests.java @@ -0,0 +1,39 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class FirstSentenceDescriptionStrategyTests { + + @Test + void descriptionOf_WhenTheDescriptionIsNull() { + Type type = new Type("com.example.ClassName"); + type.setDescription(null); + assertEquals("", new FirstSentenceDescriptionStrategy().descriptionOf(type)); + } + + @Test + void descriptionOf_WhenTheDescriptionIsEmpty() { + Type type = new Type("com.example.ClassName"); + type.setDescription(" "); + assertEquals("", new FirstSentenceDescriptionStrategy().descriptionOf(type)); + } + + @Test + void descriptionOf_WhenThereIsASentence() { + Type type = new Type("com.example.ClassName"); + type.setDescription("This is the first sentence. And this is the second."); + assertEquals("This is the first sentence.", new FirstSentenceDescriptionStrategy().descriptionOf(type)); + } + + @Test + void descriptionOf_WhenThereIsNotASentence() { + Type type = new Type("com.example.ClassName"); + type.setDescription("This is just lots of text without any punctuation"); + assertEquals("This is just lots of text without any punctuation", new FirstSentenceDescriptionStrategy().descriptionOf(type)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/description/TruncatedDescriptionStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/description/TruncatedDescriptionStrategyTests.java new file mode 100644 index 000000000..358e78020 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/description/TruncatedDescriptionStrategyTests.java @@ -0,0 +1,36 @@ +package com.structurizr.component.description; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TruncatedDescriptionStrategyTests { + + @Test + public void test_construction_ThrowsAnIllegalArgumentException_WhenZeroIsSpecified() { + try { + new TruncatedDescriptionStrategy(0); + } catch (Exception e) { + assertEquals("Max length must be greater than 0", e.getMessage()); + } + } + + @Test + public void test_construction_ThrowsAnIllegalArgumentException_WhenANegativeNumberIsSpecified() { + try { + new TruncatedDescriptionStrategy(-1); + } catch (Exception e) { + assertEquals("Max length must be greater than 0", e.getMessage()); + } + } + + @Test + public void test_descriptionOf_TruncatesTheDescription() + { + Type type = new Type("Name"); + type.setDescription("Here is some text."); + assertEquals("Here...", new TruncatedDescriptionStrategy(4).descriptionOf(type)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/example/Controller.java b/structurizr-component/src/test/java/com/structurizr/component/example/Controller.java new file mode 100644 index 000000000..ebaf960c0 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/example/Controller.java @@ -0,0 +1,4 @@ +package com.structurizr.component.example; + +interface Controller { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/example/ExampleController.java b/structurizr-component/src/test/java/com/structurizr/component/example/ExampleController.java new file mode 100644 index 000000000..f6ff0569b --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/example/ExampleController.java @@ -0,0 +1,12 @@ +package com.structurizr.component.example; + +import com.structurizr.annotation.Property; +import com.structurizr.annotation.Tag; + +@Tag(name = "Controller") +@Property(name = "Documentation", value = "https://example.com") +class ExampleController implements Controller { + + private Repository exampleRepository = new ExampleRepository(); + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/example/ExampleRepository.java b/structurizr-component/src/test/java/com/structurizr/component/example/ExampleRepository.java new file mode 100644 index 000000000..ce3afddf3 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/example/ExampleRepository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.example; + +public class ExampleRepository implements Repository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/example/Repository.java b/structurizr-component/src/test/java/com/structurizr/component/example/Repository.java new file mode 100644 index 000000000..276ed87a7 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/example/Repository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.example; + +interface Repository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/filter/DefaultTypeFilterTests.java b/structurizr-component/src/test/java/com/structurizr/component/filter/DefaultTypeFilterTests.java new file mode 100644 index 000000000..6352fb213 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/filter/DefaultTypeFilterTests.java @@ -0,0 +1,15 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultTypeFilterTests { + + @Test + void filter_ReturnsTrue() { + assertTrue(new DefaultTypeFilter().accept(new Type("com.example.Class"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeAbstractClassesFilterTests.java b/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeAbstractClassesFilterTests.java new file mode 100644 index 000000000..1b3b8c22f --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeAbstractClassesFilterTests.java @@ -0,0 +1,31 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ExcludeAbstractClassesFilterTests { + + @Test + void filter_ReturnsTrue_WhenTheTypeIsNotAbstract() { + assertTrue(new ExcludeAbstractClassesTypeFilter().accept(new Type("com.example.Class") { + @Override + public boolean isAbstractClass() { + return false; + } + })); + } + + @Test + void filter_ReturnsFalse_WhenTheTypeIsAbstract() { + assertFalse(new ExcludeAbstractClassesTypeFilter().accept(new Type("com.example.Class") { + @Override + public boolean isAbstractClass() { + return true; + } + })); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilterTests.java b/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilterTests.java new file mode 100644 index 000000000..c7d156e8b --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/filter/ExcludeFullyQualifiedNameRegexFilterTests.java @@ -0,0 +1,32 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ExcludeFullyQualifiedNameRegexFilterTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullSuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ExcludeFullyQualifiedNameRegexFilter(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptySuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ExcludeFullyQualifiedNameRegexFilter("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new ExcludeFullyQualifiedNameRegexFilter(" ")); + } + + + @Test + void filter_ReturnsTrue_WhenTheTypeDoesNotMatchRegex() { + assertTrue(new ExcludeFullyQualifiedNameRegexFilter(".*Utils").accept(new Type("com.example.CustomerComponent"))); + } + + @Test + void filter_ReturnsFalse_WhenTheTypeMatchesRegex() { + assertFalse(new ExcludeFullyQualifiedNameRegexFilter(".*Utils").accept(new Type("com.example.DateUtils"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilterTests.java b/structurizr-component/src/test/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilterTests.java new file mode 100644 index 000000000..dfa1ed908 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/filter/IncludeFullyQualifiedNameRegexFilterTests.java @@ -0,0 +1,32 @@ +package com.structurizr.component.filter; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class IncludeFullyQualifiedNameRegexFilterTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullSuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new IncludeFullyQualifiedNameRegexFilter(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptySuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new IncludeFullyQualifiedNameRegexFilter("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new IncludeFullyQualifiedNameRegexFilter(" ")); + } + + + @Test + void filter_ReturnsFalse_WhenTheTypeDoesNotMatchRegex() { + assertFalse(new IncludeFullyQualifiedNameRegexFilter(".*Component").accept(new Type("com.example.DateUtils"))); + } + + @Test + void filter_ReturnsTrue_WhenTheTypeMatchesRegex() { + assertTrue(new IncludeFullyQualifiedNameRegexFilter(".*Component").accept(new Type("com.example.CustomerComponent"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/AnnotationTypeMatcherTests.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/AnnotationTypeMatcherTests.java new file mode 100644 index 000000000..415b00420 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/AnnotationTypeMatcherTests.java @@ -0,0 +1,61 @@ +package com.structurizr.component.matcher; + +import com.structurizr.annotation.Component; +import com.structurizr.component.Type; +import org.apache.bcel.classfile.ClassParser; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.lang.annotation.Annotation; + +import static org.junit.jupiter.api.Assertions.*; + +public class AnnotationTypeMatcherTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new AnnotationTypeMatcher((String)null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptyName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new AnnotationTypeMatcher("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new AnnotationTypeMatcher(" ")); + } + + @Test + void construction_ThrowsAnException_WhenPassedANullClass() { + assertThrowsExactly(IllegalArgumentException.class, () -> new AnnotationTypeMatcher((Class) null)); + } + + @Test + void matches_ThrowsAnException_WhenPassedNull() { + assertThrowsExactly(IllegalArgumentException.class, () -> new AnnotationTypeMatcher("com.example.AnnotationName").matches(null)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoUnderlyingJavaClass() { + Type type = new Type("com.structurizr.component.matcher.annotationTypeMatcher.CustomerController"); + + assertFalse(new AnnotationTypeMatcher("com.structurizr.component.matcher.annotationTypeMatcher.Controller").matches(type)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/annotationTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertFalse(new AnnotationTypeMatcher("com.structurizr.component.matcher.annotationTypeMatcher.Repository").matches(type)); + } + + @Test + void matches_ReturnsTrue_WhenThereIsAMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/annotationTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertTrue(new AnnotationTypeMatcher(Component.class.getName()).matches(type)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/ExtendsTypeMatcherTests.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/ExtendsTypeMatcherTests.java new file mode 100644 index 000000000..66481383d --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/ExtendsTypeMatcherTests.java @@ -0,0 +1,55 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import org.apache.bcel.classfile.ClassParser; +import org.apache.bcel.classfile.JavaClass; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class ExtendsTypeMatcherTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendsTypeMatcher(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptyName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendsTypeMatcher("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendsTypeMatcher(" ")); + } + + @Test + void matches_ThrowsAnException_WhenPassedNull() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendsTypeMatcher("com.example.ClassName").matches(null)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoUnderlyingJavaClass() { + Type type = new Type("com.structurizr.component.matcher.extendsTypeMatcher.CustomerController"); + + assertFalse(new ExtendsTypeMatcher("com.structurizr.component.matcher.extendsTypeMatcher.AbstractController").matches(type)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/extendsTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertFalse(new ExtendsTypeMatcher("com.structurizr.component.matcher.extendsTypeMatcher.AbstractRepository").matches(type)); + } + + @Test + void matches_ReturnsTrue_WhenThereIsAMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/extendsTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertTrue(new ExtendsTypeMatcher("com.structurizr.component.matcher.extendsTypeMatcher.AbstractController").matches(type)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/ImplementsTypeMatcherTests.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/ImplementsTypeMatcherTests.java new file mode 100644 index 000000000..af5acd807 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/ImplementsTypeMatcherTests.java @@ -0,0 +1,54 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import org.apache.bcel.classfile.ClassParser; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImplementsTypeMatcherTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ImplementsTypeMatcher(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptyName() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ImplementsTypeMatcher("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new ImplementsTypeMatcher(" ")); + } + + @Test + void matches_ThrowsAnException_WhenPassedNull() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ImplementsTypeMatcher("com.example.InterfaceName").matches(null)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoUnderlyingJavaClass() { + Type type = new Type("com.structurizr.component.matcher.implementsTypeMatcher.CustomerController"); + + assertFalse(new ImplementsTypeMatcher("com.structurizr.component.matcher.implementsTypeMatcher.Controller").matches(type)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/implementsTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertFalse(new ImplementsTypeMatcher("com.structurizr.component.matcher.implementsTypeMatcher.Repository").matches(type)); + } + + @Test + void matches_ReturnsTrue_WhenThereIsAMatch() throws Exception { + File classes = new File("build/classes/java/test"); + ClassParser parser = new ClassParser(new File(classes, "com/structurizr/component/matcher/implementsTypeMatcher/CustomerController.class").getAbsolutePath()); + Type type = new Type(parser.parse()); + + assertTrue(new ImplementsTypeMatcher("com.structurizr.component.matcher.implementsTypeMatcher.Controller").matches(type)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/NameSuffixTypeMatcherTests.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/NameSuffixTypeMatcherTests.java new file mode 100644 index 000000000..b0055a443 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/NameSuffixTypeMatcherTests.java @@ -0,0 +1,36 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class NameSuffixTypeMatcherTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullSuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new NameSuffixTypeMatcher(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptySuffix() { + assertThrowsExactly(IllegalArgumentException.class, () -> new NameSuffixTypeMatcher("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new NameSuffixTypeMatcher(" ")); + } + + @Test + void matches_ThrowsAnException_WhenPassedNull() { + assertThrowsExactly(IllegalArgumentException.class, () -> new NameSuffixTypeMatcher("Suffix").matches(null)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoMatch() { + assertFalse(new NameSuffixTypeMatcher("Component").matches(new Type("com.example.SomeClass"))); + } + + @Test + void matches_ReturnsTrue_WhenThereIsAMatch() { + assertTrue(new NameSuffixTypeMatcher("Component").matches(new Type("com.example.SomeComponent"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/RegexSuffixTypeMatcherTests.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/RegexSuffixTypeMatcherTests.java new file mode 100644 index 000000000..2c757c025 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/RegexSuffixTypeMatcherTests.java @@ -0,0 +1,36 @@ +package com.structurizr.component.matcher; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class RegexSuffixTypeMatcherTests { + + @Test + void construction_ThrowsAnException_WhenPassedANullRegex() { + assertThrowsExactly(IllegalArgumentException.class, () -> new RegexTypeMatcher(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAnEmptyRegex() { + assertThrowsExactly(IllegalArgumentException.class, () -> new RegexTypeMatcher("")); + assertThrowsExactly(IllegalArgumentException.class, () -> new RegexTypeMatcher(" ")); + } + + @Test + void matches_ThrowsAnException_WhenPassedNull() { + assertThrowsExactly(IllegalArgumentException.class, () -> new RegexTypeMatcher(".*Controller").matches(null)); + } + + @Test + void matches_ReturnsFalse_WhenThereIsNoMatch() { + assertFalse(new RegexTypeMatcher(".*Controller").matches(new Type("com.example.SomeClass"))); + } + + @Test + void matches_ReturnsTrue_WhenThereIsAMatch() { + assertTrue(new RegexTypeMatcher(".*Controller").matches(new Type("com.example.SomeController"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/CustomerController.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/CustomerController.java new file mode 100644 index 000000000..2cd2b65e4 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/CustomerController.java @@ -0,0 +1,7 @@ +package com.structurizr.component.matcher.annotationTypeMatcher; + +import com.structurizr.annotation.Component; + +@Component +public class CustomerController { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/Repository.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/Repository.java new file mode 100644 index 000000000..09a13b504 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/annotationTypeMatcher/Repository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.annotationTypeMatcher; + +public @interface Repository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractController.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractController.java new file mode 100644 index 000000000..b70b98848 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractController.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.extendsTypeMatcher; + +abstract class AbstractController { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractRepository.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractRepository.java new file mode 100644 index 000000000..467507cfc --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/AbstractRepository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.extendsTypeMatcher; + +abstract class AbstractRepository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/CustomerController.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/CustomerController.java new file mode 100644 index 000000000..6edec8bd5 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/extendsTypeMatcher/CustomerController.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.extendsTypeMatcher; + +class CustomerController extends AbstractController { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Controller.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Controller.java new file mode 100644 index 000000000..a616ef84c --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Controller.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.implementsTypeMatcher; + +interface Controller { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/CustomerController.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/CustomerController.java new file mode 100644 index 000000000..d6c47e64f --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/CustomerController.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.implementsTypeMatcher; + +class CustomerController implements Controller { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Repository.java b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Repository.java new file mode 100644 index 000000000..ba17b8d88 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/matcher/implementsTypeMatcher/Repository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.matcher.implementsTypeMatcher; + +interface Repository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultNamingStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultNamingStrategyTests.java new file mode 100644 index 000000000..f6f8f726d --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultNamingStrategyTests.java @@ -0,0 +1,15 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DefaultNamingStrategyTests { + + @Test + void nameOf() { + assertEquals("Class Name", new DefaultNamingStrategy().nameOf(new Type("com.example.ClassName"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultPackageNamingStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultPackageNamingStrategyTests.java new file mode 100644 index 000000000..7adf582ae --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/naming/DefaultPackageNamingStrategyTests.java @@ -0,0 +1,15 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DefaultPackageNamingStrategyTests { + + @Test + void nameOf() { + assertEquals("Order Management", new DefaultPackageNamingStrategy().nameOf(new Type("com.example.orderManagement.package-info"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/naming/FullyQualifiedNamingStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/naming/FullyQualifiedNamingStrategyTests.java new file mode 100644 index 000000000..c3d72fa2e --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/naming/FullyQualifiedNamingStrategyTests.java @@ -0,0 +1,15 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class FullyQualifiedNamingStrategyTests { + + @Test + void nameOf() { + assertEquals("com.example.ClassName", new FullyQualifiedNamingStrategy().nameOf(new Type("com.example.ClassName"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/naming/TypeNamingStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/naming/TypeNamingStrategyTests.java new file mode 100644 index 000000000..b86749322 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/naming/TypeNamingStrategyTests.java @@ -0,0 +1,15 @@ +package com.structurizr.component.naming; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TypeNamingStrategyTests { + + @Test + void nameOf() { + assertEquals("ClassName", new TypeNamingStrategy().nameOf(new Type("com.example.ClassName"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/provider/ClassDirectoryTypeProviderTests.java b/structurizr-component/src/test/java/com/structurizr/component/provider/ClassDirectoryTypeProviderTests.java new file mode 100644 index 000000000..924ecabe9 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/provider/ClassDirectoryTypeProviderTests.java @@ -0,0 +1,39 @@ +package com.structurizr.component.provider; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class ClassDirectoryTypeProviderTests { + + private static final File classes = new File("build/classes/java/test"); + + @Test + void construction_ThrowsAnException_WhenPassedANullDirectory() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ClassDirectoryTypeProvider(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAPathThatDoesNotExist() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ClassDirectoryTypeProvider(new File(classes, "com/example"))); + } + + @Test + void construction_ThrowsAnException_WhenPassedAFile() { + assertThrowsExactly(IllegalArgumentException.class, () -> new ClassDirectoryTypeProvider(new File(classes, "com/structurizr/component/provider/ClassDirectoryTypeProviderTests.class"))); + } + + @Test + void getTypes() { + TypeProvider typeProvider = new ClassDirectoryTypeProvider(classes); + Set types = typeProvider.getTypes(); + + assertFalse(types.isEmpty()); + assertNotNull(types.stream().filter(t -> t.getFullyQualifiedName().equals("com.structurizr.component.provider.ClassDirectoryTypeProviderTests"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/provider/JavadocCommentFilterTests.java b/structurizr-component/src/test/java/com/structurizr/component/provider/JavadocCommentFilterTests.java new file mode 100644 index 000000000..fa1cc0b39 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/provider/JavadocCommentFilterTests.java @@ -0,0 +1,44 @@ +package com.structurizr.component.provider; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class JavadocCommentFilterTests { + + @Test + public void test_filter_ReturnsNull_WhenGivenNull() { + assertNull(new JavadocCommentFilter().filter(null)); + } + + @Test + public void test_filter_ReturnsTheOriginalText_WhenNoMaxLengthHasBeenSpecified() + { + assertEquals("Here is some text.", new JavadocCommentFilter().filter("Here is some text.")); + } + + @Test + public void test_filter_FiltersJavadocLinkTags() + { + assertEquals("Uses SomeClass and AnotherClass to do some work.", new JavadocCommentFilter().filter("Uses {@link SomeClass} and {@link AnotherClass} to do some work.")); + } + + @Test + public void test_filter_FiltersJavadocLinkTagsWithLabels() + { + assertEquals("Uses some class and another class to do some work.", new JavadocCommentFilter().filter("Uses {@link SomeClass some class} and {@link AnotherClass another class} to do some work.")); + } + + @Test + public void test_filter_FiltersHtml() + { + assertEquals("Uses SomeClass and AnotherClass to do some work.", new JavadocCommentFilter().filter("Uses SomeClass and AnotherClass to do some work.")); + } + + @Test + public void test_filter_FiltersLineBreaks() + { + assertEquals("Uses SomeClass and AnotherClass to do some work.", new JavadocCommentFilter().filter("Uses SomeClass and AnotherClass\nto do some work.")); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/provider/SourceDirectoryTypeProviderTests.java b/structurizr-component/src/test/java/com/structurizr/component/provider/SourceDirectoryTypeProviderTests.java new file mode 100644 index 000000000..564258a54 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/provider/SourceDirectoryTypeProviderTests.java @@ -0,0 +1,39 @@ +package com.structurizr.component.provider; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class SourceDirectoryTypeProviderTests { + + private static final File sources = new File("src/main/java"); + + @Test + void construction_ThrowsAnException_WhenPassedANullDirectory() { + assertThrowsExactly(IllegalArgumentException.class, () -> new SourceDirectoryTypeProvider(null)); + } + + @Test + void construction_ThrowsAnException_WhenPassedAPathThatDoesNotExist() { + assertThrowsExactly(IllegalArgumentException.class, () -> new SourceDirectoryTypeProvider(new File(sources, "com/example"))); + } + + @Test + void construction_ThrowsAnException_WhenPassedAFile() { + assertThrowsExactly(IllegalArgumentException.class, () -> new SourceDirectoryTypeProvider(new File(sources, "com/structurizr/component/provider/SourceDirectoryTypeProviderTests.java"))); + } + + @Test + void getTypes() { + TypeProvider typeProvider = new SourceDirectoryTypeProvider(sources); + Set types = typeProvider.getTypes(); + + assertTrue(types.size() > 0); + assertNotNull(types.stream().filter(t -> t.getFullyQualifiedName().equals("com.structurizr.component.provider.SourceDirectoryTypeProviderTests"))); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategyTests.java new file mode 100644 index 000000000..5562ed190 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesInPackageSupportingTypesStrategyTests.java @@ -0,0 +1,27 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AllReferencedTypesInPackageSupportingTypesStrategyTests { + + @Test + void findSupportingTypes() { + Type type = new Type("com.example.a.A"); + + Type dependency1 = new Type("com.example.a.AImpl"); + Type dependency2 = new Type("com.example.util.SomeUtils"); + type.addDependency(dependency1); + type.addDependency(dependency2); + + Set supportingTypes = new AllReferencedTypesInPackageSupportingTypesStrategy().findSupportingTypes(type, null); + assertEquals(1, supportingTypes.size()); + assertTrue(supportingTypes.contains(dependency1)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategyTests.java new file mode 100644 index 000000000..6f3122bcc --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllReferencedTypesSupportingTypesStrategyTests.java @@ -0,0 +1,28 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AllReferencedTypesSupportingTypesStrategyTests { + + @Test + void findSupportingTypes() { + Type type = new Type("com.example.a.A"); + + Type dependency1 = new Type("com.example.a.AImpl"); + Type dependency2 = new Type("com.example.util.SomeUtils"); + type.addDependency(dependency1); + type.addDependency(dependency2); + + Set supportingTypes = new AllReferencedTypesSupportingTypesStrategy().findSupportingTypes(type, null); + assertEquals(2, supportingTypes.size()); + assertTrue(supportingTypes.contains(dependency1)); + assertTrue(supportingTypes.contains(dependency2)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategyTests.java new file mode 100644 index 000000000..cf36e4c00 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesInPackageSupportingTypesStrategyTests.java @@ -0,0 +1,35 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AllTypesInPackageSupportingTypesStrategyTests { + + @Test + void findSupportingTypes() { + Type type = new Type("com.example.a.package-info"); + + Type dependency1 = new Type("com.example.a.AImpl"); + Type dependency2 = new Type("com.example.a.internal.AInternal"); + Type dependency3 = new Type("com.example.util.SomeUtils"); + type.addDependency(dependency1); + type.addDependency(dependency2); + type.addDependency(dependency3); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(dependency1); + typeRepository.add(dependency2); + typeRepository.add(dependency3); + + Set supportingTypes = new AllTypesInPackageSupportingTypesStrategy().findSupportingTypes(type, typeRepository); + assertEquals(1, supportingTypes.size()); + assertTrue(supportingTypes.contains(dependency1)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategyTests.java new file mode 100644 index 000000000..7cf97d4c3 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/AllTypesUnderPackageSupportingTypesStrategyTests.java @@ -0,0 +1,36 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AllTypesUnderPackageSupportingTypesStrategyTests { + + @Test + void findSupportingTypes() { + Type type = new Type("com.example.a.package-info"); + + Type dependency1 = new Type("com.example.a.AImpl"); + Type dependency2 = new Type("com.example.a.internal.AInternal"); + Type dependency3 = new Type("com.example.util.SomeUtils"); + type.addDependency(dependency1); + type.addDependency(dependency2); + type.addDependency(dependency3); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(dependency1); + typeRepository.add(dependency2); + typeRepository.add(dependency3); + + Set supportingTypes = new AllTypesUnderPackageSupportingTypesStrategy().findSupportingTypes(type, typeRepository); + assertEquals(2, supportingTypes.size()); + assertTrue(supportingTypes.contains(dependency1)); + assertTrue(supportingTypes.contains(dependency2)); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategyTests.java new file mode 100644 index 000000000..ff83615a2 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/DefaultSupportingTypesStrategyTests.java @@ -0,0 +1,21 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DefaultSupportingTypesStrategyTests { + + @Test + void findSupportingTypes() { + Type type = new Type("com.example.a.A"); + type.addDependency(new Type("com.example.a.AImpl")); + + Set supportingTypes = new DefaultSupportingTypesStrategy().findSupportingTypes(type, null); + assertTrue(supportingTypes.isEmpty()); + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategyTests.java new file mode 100644 index 000000000..6afccd312 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithPrefixSupportingTypesStrategyTests.java @@ -0,0 +1,44 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; +import org.apache.bcel.classfile.ClassParser; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImplementationWithPrefixSupportingTypesStrategyTests { + + private final File classes = new File("build/classes/java/test"); + + @Test + void findSupportingTypes() throws Exception { + Type interfaceType = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/ExampleRepository.class").getAbsolutePath()).parse()); + Type implementationTypeWithPrefix = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/JdbcExampleRepository.class").getAbsolutePath()).parse()); + Type implementationTypeWithSuffix = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/ExampleRepositoryImpl.class").getAbsolutePath()).parse()); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(interfaceType); + typeRepository.add(implementationTypeWithPrefix); + typeRepository.add(implementationTypeWithSuffix); + + Set supportingTypes = new ImplementationWithPrefixSupportingTypesStrategy("Jdbc").findSupportingTypes(interfaceType, typeRepository); + assertEquals(1, supportingTypes.size()); + assertTrue(supportingTypes.contains(implementationTypeWithPrefix)); + } + + @Test + void findSupportingTypes_ThrowsAnException_WhenTheTypeIsNotAnInterface() throws Exception { + try { + Type type = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/JdbcExampleRepository.class").getAbsolutePath()).parse()); + new ImplementationWithPrefixSupportingTypesStrategy("Impl").findSupportingTypes(type, null); + fail(); + } catch (Exception e) { + assertEquals("The type com.structurizr.component.supporting.implementation.JdbcExampleRepository is not an interface", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategyTests.java new file mode 100644 index 000000000..1929432ad --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/ImplementationWithSuffixSupportingTypesStrategyTests.java @@ -0,0 +1,46 @@ +package com.structurizr.component.supporting; + +import com.structurizr.component.Type; +import com.structurizr.component.TypeRepository; +import org.apache.bcel.classfile.ClassFormatException; +import org.apache.bcel.classfile.ClassParser; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImplementationWithSuffixSupportingTypesStrategyTests { + + private final File classes = new File("build/classes/java/test"); + + @Test + void findSupportingTypes() throws Exception { + Type interfaceType = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/ExampleRepository.class").getAbsolutePath()).parse()); + Type implementationTypeWithPrefix = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/JdbcExampleRepository.class").getAbsolutePath()).parse()); + Type implementationTypeWithSuffix = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/ExampleRepositoryImpl.class").getAbsolutePath()).parse()); + + TypeRepository typeRepository = new TypeRepository(); + typeRepository.add(interfaceType); + typeRepository.add(implementationTypeWithPrefix); + typeRepository.add(implementationTypeWithSuffix); + + Set supportingTypes = new ImplementationWithSuffixSupportingTypesStrategy("Impl").findSupportingTypes(interfaceType, typeRepository); + assertEquals(1, supportingTypes.size()); + assertTrue(supportingTypes.contains(implementationTypeWithSuffix)); + } + + @Test + void findSupportingTypes_ThrowsAnException_WhenTheTypeIsNotAnInterface() throws Exception { + try { + Type type = new Type(new ClassParser(new File(classes, "com/structurizr/component/supporting/implementation/JdbcExampleRepository.class").getAbsolutePath()).parse()); + new ImplementationWithSuffixSupportingTypesStrategy("Impl").findSupportingTypes(type, null); + fail(); + } catch (Exception e) { + assertEquals("The type com.structurizr.component.supporting.implementation.JdbcExampleRepository is not an interface", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepository.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepository.java new file mode 100644 index 000000000..6765edae1 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.supporting.implementation; + +public interface ExampleRepository { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepositoryImpl.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepositoryImpl.java new file mode 100644 index 000000000..14447809b --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/ExampleRepositoryImpl.java @@ -0,0 +1,4 @@ +package com.structurizr.component.supporting.implementation; + +public class ExampleRepositoryImpl implements ExampleRepository { +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/JdbcExampleRepository.java b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/JdbcExampleRepository.java new file mode 100644 index 000000000..3a565ab90 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/supporting/implementation/JdbcExampleRepository.java @@ -0,0 +1,4 @@ +package com.structurizr.component.supporting.implementation; + +public class JdbcExampleRepository implements ExampleRepository { +} \ No newline at end of file diff --git a/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperties.java b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperties.java new file mode 100644 index 000000000..f9f0157db --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperties.java @@ -0,0 +1,9 @@ +package com.structurizr.component.types; + +import com.structurizr.annotation.Property; + +@Property(name = "Name1", value = "Value1") +@Property(name = "Name2", value = "Value2") +@Property(name = "Name3", value = "Value3") +public class TypeWithProperties { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperty.java b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperty.java new file mode 100644 index 000000000..d6de25773 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithProperty.java @@ -0,0 +1,7 @@ +package com.structurizr.component.types; + +import com.structurizr.annotation.Property; + +@Property(name = "Name", value = "Value") +public class TypeWithProperty { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTag.java b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTag.java new file mode 100644 index 000000000..74cea4cdb --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTag.java @@ -0,0 +1,7 @@ +package com.structurizr.component.types; + +import com.structurizr.annotation.Tag; + +@Tag(name = "Tag 1") +public class TypeWithTag { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTags.java b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTags.java new file mode 100644 index 000000000..b577d6ca8 --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/types/TypeWithTags.java @@ -0,0 +1,9 @@ +package com.structurizr.component.types; + +import com.structurizr.annotation.Tag; + +@Tag(name = "Tag 1") +@Tag(name = "Tag 2") +@Tag(name = "Tag 3") +public class TypeWithTags { +} diff --git a/structurizr-component/src/test/java/com/structurizr/component/url/PrefixSourceUrlStrategyTests.java b/structurizr-component/src/test/java/com/structurizr/component/url/PrefixSourceUrlStrategyTests.java new file mode 100644 index 000000000..d4de1065c --- /dev/null +++ b/structurizr-component/src/test/java/com/structurizr/component/url/PrefixSourceUrlStrategyTests.java @@ -0,0 +1,26 @@ +package com.structurizr.component.url; + +import com.structurizr.component.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PrefixSourceUrlStrategyTests { + + @Test + void test_urlOf_WhenTheSourceUsesForwardSlashFileSeparators() { + Type type = new Type("com.example.ClassName"); + type.setSource("com/example/ClassName.java"); + + assertEquals("https://example.com/src/main/java/com/example/ClassName.java", new PrefixSourceUrlStrategy("https://example.com/src/main/java").urlOf(type)); + } + + @Test + void test_urlOf_WhenTheSourceUsesBackslashFileSeparators() { + Type type = new Type("com.example.ClassName"); + type.setSource("com\\example\\ClassName.java"); + + assertEquals("https://example.com/src/main/java/com/example/ClassName.java", new PrefixSourceUrlStrategy("https://example.com/src/main/java").urlOf(type)); + } + +} \ No newline at end of file diff --git a/structurizr-core/README.md b/structurizr-core/README.md new file mode 100644 index 000000000..2a1d0b062 --- /dev/null +++ b/structurizr-core/README.md @@ -0,0 +1,8 @@ +# structurizr-core + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-core.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-core) + +This library provides the core functionality of Structurizr related to creating workspaces. + +- [Documentation](https://docs.structurizr.com/java) + diff --git a/structurizr-core/build.gradle b/structurizr-core/build.gradle index 4f395e47f..2ac80b7d1 100644 --- a/structurizr-core/build.gradle +++ b/structurizr-core/build.gradle @@ -1,10 +1,9 @@ dependencies { - implementation 'com.fasterxml.jackson.core:jackson-annotations:2.13.3' - implementation 'com.google.code.findbugs:jsr305:3.0.2' + api 'com.fasterxml.jackson.core:jackson-annotations:2.20' + api 'com.google.code.findbugs:jsr305:3.0.2' + api 'commons-logging:commons-logging:1.3.5' - implementation 'commons-logging:commons-logging:1.2' + testImplementation 'org.assertj:assertj-core:3.27.3' - testImplementation 'junit:junit:4.12' - testImplementation 'org.assertj:assertj-core:3.9.1' } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/Workspace.java b/structurizr-core/src/com/structurizr/Workspace.java deleted file mode 100644 index 3c2dc0334..000000000 --- a/structurizr-core/src/com/structurizr/Workspace.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.structurizr; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.structurizr.documentation.Documentable; -import com.structurizr.documentation.Documentation; -import com.structurizr.model.*; -import com.structurizr.view.ViewSet; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - -import javax.annotation.Nonnull; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.LinkedList; -import java.util.List; - -/** - * Represents a Structurizr workspace, which is a wrapper for a - * software architecture model, views and documentation. - */ -public final class Workspace extends AbstractWorkspace implements Documentable { - - private static final Log log = LogFactory.getLog(Workspace.class); - - private Model model = createModel(); - private ViewSet viewSet; - private Documentation documentation; - - Workspace() { - } - - /** - * Creates a new workspace. - * - * @param name the name of the workspace - * @param description a short description - */ - public Workspace(String name, String description) { - super(name, description); - - model = createModel(); - viewSet = createViewSet(); - documentation = new Documentation(); - } - - /** - * Gets the software architecture model. - * - * @return a Model instance - */ - public Model getModel() { - return model; - } - - void setModel(Model model) { - this.model = model; - } - - /** - * Gets the set of views onto a software architecture model. - * - * @return a ViewSet instance - */ - public ViewSet getViews() { - return viewSet; - } - - void setViews(ViewSet viewSet) { - this.viewSet = viewSet; - } - - private Model createModel() { - try { - Constructor constructor = Model.class.getDeclaredConstructor(); - constructor.setAccessible(true); - return (Model)constructor.newInstance(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private ViewSet createViewSet() { - try { - Constructor constructor = ViewSet.class.getDeclaredConstructor(Model.class); - constructor.setAccessible(true); - return (ViewSet)constructor.newInstance(model); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - /** - * Called when deserialising JSON, to re-create the object graph - * based upon element/relationship IDs. - */ - public void hydrate() { - if (viewSet == null) { - viewSet = createViewSet(); - } - - hydrateModel(); - hydrateViewSet(); - } - - private void hydrateModel() { - try { - Method hydrateMethod = Model.class.getDeclaredMethod("hydrate"); - hydrateMethod.setAccessible(true); - hydrateMethod.invoke(model); - } catch (InvocationTargetException ite) { - if (ite.getCause() != null && ite.getCause() instanceof WorkspaceValidationException) { - throw (WorkspaceValidationException)ite.getCause(); - } else { - throw new RuntimeException(ite.getCause()); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private void hydrateViewSet() { - try { - Method hydrateMethod = ViewSet.class.getDeclaredMethod("hydrate", Model.class); - hydrateMethod.setAccessible(true); - hydrateMethod.invoke(viewSet, model); - } catch (InvocationTargetException ite) { - if (ite.getCause() != null && ite.getCause() instanceof WorkspaceValidationException) { - throw (WorkspaceValidationException)ite.getCause(); - } else { - throw new RuntimeException(ite.getCause()); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - /** - * Gets the documentation associated with this workspace. - * - * @return a Documentation object - */ - public Documentation getDocumentation() { - return documentation; - } - - /** - * Sets the documentation associated with this workspace. - * - * @param documentation a Documentation object - */ - void setDocumentation(@Nonnull Documentation documentation) { - this.documentation = documentation; - } - - /** - * Determines whether this model is empty. - * - * @return true if the model has no elements, views or documentation; false otherwise - */ - @JsonIgnore - public boolean isEmpty() { - return model.isEmpty() && viewSet.isEmpty() && documentation.isEmpty(); - } - - /** - * Counts and logs any warnings within the workspace (e.g. missing element descriptions). - * - * @return the number of warnings - */ - public int countAndLogWarnings() { - final List warnings = new LinkedList<>(); - - // find elements with a missing description - getModel().getElements().stream() - .filter(e -> !(e instanceof SoftwareSystemInstance) && !(e instanceof ContainerInstance) && !(e instanceof DeploymentNode) && !(e instanceof InfrastructureNode)) - .filter(e -> e.getDescription() == null || e.getDescription().trim().length() == 0) - .forEach(e -> warnings.add(e.getCanonicalName() + " is missing a description.")); - - // find containers with a missing technology - getModel().getElements().stream() - .filter(e -> e instanceof Container) - .map(e -> (Container)e) - .filter(c -> c.getTechnology() == null || c.getTechnology().trim().length() == 0) - .forEach(c -> warnings.add(c.getCanonicalName() + " is missing a technology.")); - - // find components with a missing technology - getModel().getElements().stream() - .filter(e -> e instanceof Component) - .map(e -> (Component)e) - .filter(c -> c.getTechnology() == null || c.getTechnology().trim().length() == 0) - .forEach(c -> warnings.add(c.getCanonicalName() + " is missing a technology.")); - - // find component relationships with a missing description - for (Relationship relationship : getModel().getRelationships()) { - if (relationship.getSource() instanceof Component && relationship.getDestination() instanceof Component && - relationship.getSource().getParent().equals(relationship.getDestination().getParent())) { - // ignore component-component relationships inside the same container because these are - // often identified using reflection and won't have a description - // (i.e. let's not flood the user with warnings) - } else { - if (relationship.getDescription() == null || relationship.getDescription().trim().length() == 0) { - warnings.add("The relationship between " + relationship.getSource().getCanonicalName() + " and " + relationship.getDestination().getCanonicalName() + " is missing a description."); - } - } - } - - // diagram keys have not been specified - this is only applicable to - // workspaces created with the early versions of Structurizr for Java - getViews().getSystemLandscapeViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("System Landscape view \"" + v.getName() + "\": Missing key")); - getViews().getSystemContextViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("System Context view \"" + v.getName() + "\": Missing key")); - getViews().getContainerViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("Container view \"" + v.getName() + "\": Missing key")); - getViews().getComponentViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("Component view \"" + v.getName() + "\": Missing key")); - getViews().getDynamicViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("Dynamic view \"" + v.getName() + "\": Missing key")); - getViews().getDeploymentViews().stream() - .filter(v -> v.getKey() == null) - .forEach(v -> warnings.add("Deployment view \"" + v.getName() + "\": Missing key")); - - warnings.forEach(log::warn); - - return warnings.size(); - } - - private String typeof(Element element) { - if (element instanceof SoftwareSystem) { - return "software system"; - } else { - return element.getClass().getSimpleName().toLowerCase(); - } - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/configuration/WorkspaceConfiguration.java b/structurizr-core/src/com/structurizr/configuration/WorkspaceConfiguration.java deleted file mode 100644 index 63627feea..000000000 --- a/structurizr-core/src/com/structurizr/configuration/WorkspaceConfiguration.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.structurizr.configuration; - -import com.structurizr.util.StringUtils; - -import java.util.HashSet; -import java.util.Set; - -/** - * A wrapper for configuration options related to the workspace. - */ -public final class WorkspaceConfiguration { - - private Set users = new HashSet<>(); - - WorkspaceConfiguration() { - } - - /** - * Gets the set of users should have read-write or read-only access to the workspace. - * - * @return a Set of User objects (could be empty) - */ - public Set getUsers() { - return new HashSet<>(users); - } - - void setUsers(Set users) { - if (users != null) { - this.users = new HashSet<>(users); - } - } - - /** - * Adds a user, with the specified username and role. - * - * @param username the username (e.g. an e-mail address) - * @param role the user's role - */ - public void addUser(String username, Role role) { - if (StringUtils.isNullOrEmpty(username)) { - throw new IllegalArgumentException("A username must be specified."); - } - - if (role == null) { - throw new IllegalArgumentException("A role must be specified."); - } - - users.add(new User(username, role)); - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/documentation/Section.java b/structurizr-core/src/com/structurizr/documentation/Section.java deleted file mode 100644 index 3a84e26c3..000000000 --- a/structurizr-core/src/com/structurizr/documentation/Section.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.structurizr.documentation; - -/** - * A documentation section. - */ -public final class Section extends DocumentationContent { - - private int order; - - public Section() { - } - - public Section(String title, Format format, String content) { - setTitle(title); - setFormat(format); - setContent(content); - } - - public int getOrder() { - return order; - } - - void setOrder(int order) { - this.order = order; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - - if (object == null || getClass() != object.getClass()) { - return false; - } - - Section section = (Section)object; - if (getElementId() != null) { - return getElementId().equals(section.getElementId()) && getTitle().equals(section.getTitle()); - } else { - return getTitle().equals(section.getTitle()); - } - } - - @Override - public int hashCode() { - int result = getElementId() != null ? getElementId().hashCode() : 0; - result = 31 * result + getTitle().hashCode(); - return result; - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/CodeElement.java b/structurizr-core/src/com/structurizr/model/CodeElement.java deleted file mode 100644 index a6935494a..000000000 --- a/structurizr-core/src/com/structurizr/model/CodeElement.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.structurizr.model; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.structurizr.util.Url; - -/** - * Represents a code element, such as a Java class or interface, - * that is part of the implementation of a component. - */ -public final class CodeElement { - - /** the role of the code element ... Primary or Supporting */ - private CodeElementRole role = CodeElementRole.Supporting; - - /** the name of the code element ... typically the simple class/interface name */ - private String name; - - /** the fully qualified type of the code element **/ - private String type; - - /** a short description of the code element */ - private String description; - - /** a URL; e.g. a reference to the code element in source code control */ - private String url; - - /** the programming language used to create the code element */ - private String language = "Java"; - - /** the category of code element; e.g. class, interface, etc */ - private String category; - - /** the visibility of the code element; e.g. public, package, private */ - private String visibility; - - /** the size of the code element; e.g. the number of lines */ - private long size; - - CodeElement() { - } - - CodeElement(String fullyQualifiedTypeName) { - if (fullyQualifiedTypeName == null || fullyQualifiedTypeName.trim().isEmpty()) { - throw new IllegalArgumentException("A fully qualified name must be provided."); - } - - int dot = fullyQualifiedTypeName.lastIndexOf('.'); - if (dot > -1) { - setName(fullyQualifiedTypeName.substring(dot+1, fullyQualifiedTypeName.length())); - setType(fullyQualifiedTypeName); - } else { - setName(fullyQualifiedTypeName); - setType(fullyQualifiedTypeName); - } - } - - /** - * Gets the role of this code element; Primary or Supporting. - * - * @return a CodeElementRole enum - */ - public CodeElementRole getRole() { - return role; - } - - void setRole(CodeElementRole role) { - this.role = role; - } - - /** - * Gets the name of this code element. - * - * @return the name, as a String - */ - public String getName() { - return name; - } - - void setName(String name) { - this.name = name; - } - - /** - * Gets the type (fully qualified type name) of this code element. - * - * @return the type, as a String - */ - public String getType() { - return type; - } - - void setType(String type) { - this.type = type; - } - - /** - * Gets the Java package of this component (i.e. the package of the primary code element). - * - * @return the package name, as a String - */ - @JsonIgnore - public String getPackage() { - return type.substring(0, type.lastIndexOf('.')); - } - - /** - * Gets the description of this code element. - * - * @return the description, as a String - */ - public String getDescription() { - return description; - } - - /** - * Sets the description of this code element. - * - * @param description the description, as a String - */ - public void setDescription(String description) { - this.description = description; - } - - /** - * Gets the URL where more information about this code element can be found. - * - * @return the URL as a String, or null if not set - */ - public String getUrl() { - return url; - } - - /** - * Sets the URL where more information about this code element can be found. - * - * @param url the URL as a String - * @throws IllegalArgumentException if the URL is not a well-formed URL - */ - public void setUrl(String url) { - if (url != null && url.trim().length() > 0) { - if (Url.isUrl(url)) { - this.url = url; - } else { - throw new IllegalArgumentException(url + " is not a valid URL."); - } - } - } - - /** - * Gets the programming language of this code element. - * - * @return the programming language, as a String - */ - public String getLanguage() { - return language; - } - - /** - * Sets the programming language of this code element. - * - * @param language the programming language, as a String - */ - public void setLanguage(String language) { - this.language = language; - } - - /** - * Gets the category of this code element (interface, class, etc). - * - * @return the category, as a String - */ - public String getCategory() { - return category; - } - - /** - * Sets the category of this code element. - * - * @param category the category, as a String - */ - public void setCategory(String category) { - this.category = category; - } - - /** - * Gets the visibility of this code element (public, package, etc). - * - * @return the visibility, as a String - */ - public String getVisibility() { - return visibility; - } - - /** - * Sets the visibility of this code element. - * - * @param visibility the visibility, as a String - */ - public void setVisibility(String visibility) { - this.visibility = visibility; - } - - /** - * Gets the size of this code element (e.g. the number of lines of code). - * - * @return the size, as a long - */ - public long getSize() { - return size; - } - - /** - * Sets the size of this code element. - * - * @param size the size, as a long - */ - public void setSize(long size) { - this.size = size; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - CodeElement that = (CodeElement) o; - - return type.equals(that.type); - } - - @Override - public int hashCode() { - return type.hashCode(); - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/CodeElementRole.java b/structurizr-core/src/com/structurizr/model/CodeElementRole.java deleted file mode 100644 index 0cc30c106..000000000 --- a/structurizr-core/src/com/structurizr/model/CodeElementRole.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.structurizr.model; - -/** - * Used to represent the role of a code element. A component can have - * one primary code element, and zero or more supporting code elements - * associated with it. - */ -public enum CodeElementRole { - - Primary, - Supporting - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Component.java b/structurizr-core/src/com/structurizr/model/Component.java deleted file mode 100644 index 64c4127d7..000000000 --- a/structurizr-core/src/com/structurizr/model/Component.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.structurizr.model; - -import com.fasterxml.jackson.annotation.JsonIgnore; - -import java.util.*; - -/** - * Represents a "component" in the C4 model. - */ -public final class Component extends StaticStructureElement { - - private Container parent; - - private String technology; - private Set codeElements = new HashSet<>(); - private long size; - - Component() { - } - - @Override - @JsonIgnore - public Element getParent() { - return parent; - } - - @JsonIgnore - public Container getContainer() { - return parent; - } - - void setParent(Container parent) { - this.parent = parent; - } - - /** - * Gets the technology associated with this component (e.g. "Spring Bean"). - * - * @return the technology, as a String, - * or null if no technology has been specified - */ - public String getTechnology() { - return technology; - } - - /** - * Sets the technology associated with this component (e.g. "Spring Bean"). - * - * @param technology the technology, as a String - */ - public void setTechnology(String technology) { - this.technology = technology; - } - - /** - * Gets the type of this component (e.g. a fully qualified Java interface/class name). - * - * @return the type, as a String - */ - @JsonIgnore - public CodeElement getType() { - return codeElements.stream().filter(ce -> ce.getRole() == CodeElementRole.Primary).findFirst().orElse(null); - } - - /** - * Sets the type of this component (e.g. a fully qualified Java interface/class name). - * - * @param type the fully qualified type name - * @return the CodeElement that was created - * @throws IllegalArgumentException if the specified type is null - */ - public CodeElement setType(String type) { - Optional optional = codeElements.stream().filter(ce -> ce.getRole() == CodeElementRole.Primary).findFirst(); - optional.ifPresent(codeElement -> codeElements.remove(codeElement)); - - CodeElement codeElement = new CodeElement(type); - codeElement.setRole(CodeElementRole.Primary); - this.codeElements.add(codeElement); - - return codeElement; - } - - /** - * Gets the set of CodeElement objects. - * - * @return a Set, which could be empty - */ - public Set getCode() { - return new HashSet<>(codeElements); - } - - void setCode(Set codeElements) { - this.codeElements = codeElements; - } - - /** - * Adds a supporting type to this Component. - * - * @param type the fully qualified type name - * @return a CodeElement representing the supporting type - * @throws IllegalArgumentException if the specified type is null - */ - public CodeElement addSupportingType(String type) { - CodeElement codeElement = new CodeElement(type); - codeElement.setRole(CodeElementRole.Supporting); - this.codeElements.add(codeElement); - - return codeElement; - } - - /** - * Gets the size of this Component (e.g. number of lines). - * - * @return the size of this component, as a long - */ - public long getSize() { - return size; - } - - /** - * Sets the size of this component (e.g. number of lines). - * - * @param size the size - */ - public void setSize(long size) { - this.size = size; - } - - /** - * Gets the canonical name of this component, in the form "/Software System/Container/Component". - * - * @return the canonical name, as a String - */ - @Override - public String getCanonicalName() { - return new CanonicalNameGenerator().generate(this); - } - - @Override - public Set getDefaultTags() { - return new LinkedHashSet<>(Arrays.asList(Tags.ELEMENT, Tags.COMPONENT)); - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Location.java b/structurizr-core/src/com/structurizr/model/Location.java deleted file mode 100644 index 524bb405c..000000000 --- a/structurizr-core/src/com/structurizr/model/Location.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.structurizr.model; - -/** - * Represents the location of an element with regards to a specific viewpoint. - * For example, "our customers are external to our enterprise". - */ -public enum Location { - - Internal, - External, - Unspecified - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/util/ImageUtils.java b/structurizr-core/src/com/structurizr/util/ImageUtils.java deleted file mode 100644 index 7849f7fe6..000000000 --- a/structurizr-core/src/com/structurizr/util/ImageUtils.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.structurizr.util; - -import javax.annotation.Nonnull; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.net.URLConnection; -import java.util.Base64; - -/** - * Some utility methods for dealing with images. - */ -public class ImageUtils { - - /** - * Gets the content type of the specified file representing an image. - * - * @param file a File pointing to an image - * @return a content type (e.g. "image/png") - * @throws IOException if there is an error reading the file - */ - public static String getContentType(@Nonnull File file) throws IOException { - if (file == null) { - throw new IllegalArgumentException("A file must be specified."); - } else if (!file.exists()) { - throw new IllegalArgumentException(file.getCanonicalPath() + " does not exist."); - } else if (!file.isFile()) { - throw new IllegalArgumentException(file.getCanonicalPath() + " is not a file."); - } - - String contentType = URLConnection.guessContentTypeFromName(file.getName()); - if (contentType == null || !contentType.startsWith("image/")) { - throw new IllegalArgumentException(file.getCanonicalPath() + " is not a supported image file."); - } - - return contentType; - } - - /** - * Gets the content of an image as a Base64 encoded string. - * - * @param file a File pointing to an image - * @return a Base64 encoded version of that image - * @throws IOException if there is an error reading the file - */ - public static String getImageAsBase64(@Nonnull File file) throws IOException { - String contentType = getContentType(file); - BufferedImage bufferedImage = ImageIO.read(file); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ImageIO.write(bufferedImage, contentType.split("/")[1], bos); - byte[] imageBytes = bos.toByteArray(); - - return Base64.getEncoder().encodeToString(imageBytes); - } - - /** - * Gets the content of an image as a data URI; e.g. "data:image/png;base64,iVBORw0KGgoAA..." - * - * @param file a File pointing to an image - * @return a data URI - * @throws IOException if there is an error reading the file - */ - public static String getImageAsDataUri(File file) throws IOException { - String contentType = getContentType(file); - String base64Content = getImageAsBase64(file); - - return "data:" + contentType + ";base64," + base64Content; - } - - public static void validateImage(String url) { - if (StringUtils.isNullOrEmpty(url)) { - return; - } - - url = url.trim(); - - if (Url.isUrl(url)) { - // all good - return; - } - - if (url.startsWith("data:image")) { - if (ImageUtils.isSupportedDataUri(url)) { - // all good - return; - } else { - // it's a data URI, but not supported - throw new IllegalArgumentException("Only PNG and JPG data URIs are supported: " + url); - } - } - - throw new IllegalArgumentException("Expected a URL or data URI"); - } - - public static boolean isSupportedDataUri(String uri) { - return uri.startsWith("data:image/png;base64,") || uri.startsWith("data:image/jpeg;base64,"); - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/util/Url.java b/structurizr-core/src/com/structurizr/util/Url.java deleted file mode 100644 index d77ef9f95..000000000 --- a/structurizr-core/src/com/structurizr/util/Url.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.structurizr.util; - -import java.net.MalformedURLException; -import java.net.URL; - -/** - * Utilities for dealing with URLs. - */ -public class Url { - - /** - * Determines whether the supplied string is a valid URL. - * - * @param urlAsString the URL, as a String - * @return true if the URL is valid, false otherwise - */ - public static boolean isUrl(String urlAsString) { - if (urlAsString != null && urlAsString.trim().length() > 0) { - try { - new URL(urlAsString); - return true; - } catch (MalformedURLException murle) { - return false; - } - } - - return false; - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/AbstractStyle.java b/structurizr-core/src/com/structurizr/view/AbstractStyle.java deleted file mode 100644 index 28ed28f22..000000000 --- a/structurizr-core/src/com/structurizr/view/AbstractStyle.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.structurizr.view; - -import com.structurizr.PropertyHolder; - -import java.util.HashMap; -import java.util.Map; - -public abstract class AbstractStyle implements PropertyHolder { - - private Map properties = new HashMap<>(); - - /** - * Gets the collection of name-value property pairs associated with this workspace, as a Map. - * - * @return a Map (String, String) (empty if there are no properties) - */ - public Map getProperties() { - return new HashMap<>(properties); - } - - /** - * Adds a name-value pair property to this workspace. - * - * @param name the name of the property - * @param value the value of the property - */ - public void addProperty(String name, String value) { - if (name == null || name.trim().length() == 0) { - throw new IllegalArgumentException("A property name must be specified."); - } - - if (value == null || value.trim().length() == 0) { - throw new IllegalArgumentException("A property value must be specified."); - } - - properties.put(name, value); - } - - void setProperties(Map properties) { - if (properties != null) { - this.properties = new HashMap<>(properties); - } - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Color.java b/structurizr-core/src/com/structurizr/view/Color.java deleted file mode 100644 index c224d3f40..000000000 --- a/structurizr-core/src/com/structurizr/view/Color.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.structurizr.view; - -public class Color { - - public static boolean isHexColorCode(String colorAsString) { - return colorAsString != null && colorAsString.matches("^#[A-Fa-f0-9]{6}"); - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/ColorPair.java b/structurizr-core/src/com/structurizr/view/ColorPair.java deleted file mode 100644 index 643501a25..000000000 --- a/structurizr-core/src/com/structurizr/view/ColorPair.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.structurizr.view; - -/** - * Represents a pair of colours: background and foreground. - */ -public final class ColorPair { - - private String background; - private String foreground; - - ColorPair() { - } - - public ColorPair(String background, String foreground) { - setBackground(background); - setForeground(foreground); - } - - public String getBackground() { - return background; - } - - public void setBackground(String background) { - if (Color.isHexColorCode(background)) { - this.background = background.toLowerCase(); - } else { - throw new IllegalArgumentException("'" + background + "' is not a valid hex color code."); - } - } - - public String getForeground() { - return foreground; - } - - public void setForeground(String foreground) { - if (Color.isHexColorCode(foreground)) { - this.foreground = foreground.toLowerCase(); - } else { - throw new IllegalArgumentException("'" + foreground + "' is not a valid hex color code."); - } - } - -} diff --git a/structurizr-core/src/com/structurizr/view/SequenceNumber.java b/structurizr-core/src/com/structurizr/view/SequenceNumber.java deleted file mode 100644 index 027938b6e..000000000 --- a/structurizr-core/src/com/structurizr/view/SequenceNumber.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.structurizr.view; - -class SequenceNumber { - - private SequenceCounter counter = new SequenceCounter(); - - SequenceNumber() { - } - - String getNext() { - counter.increment(); - return counter.toString(); - } - - void startParallelSequence() { - this.counter = new ParallelSequenceCounter(this.counter); - } - - void endParallelSequence(boolean endAllParallelSequencesAndContinueNumbering) { - if (endAllParallelSequencesAndContinueNumbering) { - int sequence = this.counter.getSequence(); - this.counter = this.counter.getParent(); - this.counter.setSequence(sequence); - } else { - this.counter = this.counter.getParent(); - } - } - -} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Styles.java b/structurizr-core/src/com/structurizr/view/Styles.java deleted file mode 100644 index e5e33813b..000000000 --- a/structurizr-core/src/com/structurizr/view/Styles.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.structurizr.view; - -import com.structurizr.Workspace; -import com.structurizr.model.*; -import com.structurizr.util.StringUtils; - -import java.util.*; - -public final class Styles { - - private static final Integer DEFAULT_WIDTH_OF_ELEMENT = 450; - private static final Integer DEFAULT_HEIGHT_OF_ELEMENT = 300; - - private static final Integer DEFAULT_WIDTH_OF_PERSON = 400; - private static final Integer DEFAULT_HEIGHT_OF_PERSON = 400; - - private Collection elements = new LinkedList<>(); - private Collection relationships = new LinkedList<>(); - - private Map themes = new LinkedHashMap<>(); - - public Collection getElements() { - return elements; - } - - public void add(ElementStyle elementStyle) { - if (elementStyle != null) { - if (StringUtils.isNullOrEmpty(elementStyle.getTag())) { - throw new IllegalArgumentException("A tag must be specified."); - } - - if (elements.stream().anyMatch(es -> es.getTag().equals(elementStyle.getTag()))) { - throw new IllegalArgumentException("An element style for the tag \"" + elementStyle.getTag() + "\" already exists."); - } - - this.elements.add(elementStyle); - } - } - - public ElementStyle addElementStyle(String tag) { - ElementStyle elementStyle = new ElementStyle(tag); - add(elementStyle); - - return elementStyle; - } - - /** - * Removes all element styles. - */ - public void clearElementStyles() { - this.elements = new LinkedList<>(); - } - - /** - * Removes all relationship styles. - */ - public void clearRelationshipStyles() { - this.relationships = new LinkedList<>(); - } - - public Collection getRelationships() { - return relationships; - } - - public void add(RelationshipStyle relationshipStyle) { - if (relationshipStyle != null) { - if (StringUtils.isNullOrEmpty(relationshipStyle.getTag())) { - throw new IllegalArgumentException("A tag must be specified."); - } - - if (relationships.stream().anyMatch(es -> es.getTag().equals(relationshipStyle.getTag()))) { - throw new IllegalArgumentException("A relationship style for the tag \"" + relationshipStyle.getTag() + "\" already exists."); - } - - this.relationships.add(relationshipStyle); - } - } - - public RelationshipStyle addRelationshipStyle(String tag) { - RelationshipStyle relationshipStyle = new RelationshipStyle(tag); - add(relationshipStyle); - - return relationshipStyle; - } - - /** - * Finds the element style for the given tag. This method creates an empty style, - * and copies properties from any element styles (from the workspace and any themes) for the given tag. - * - * - * @param tag the tag (a String) - * @return an ElementStyle instance - */ - public ElementStyle findElementStyle(String tag) { - if (tag == null) { - return null; - } - - tag = tag.trim(); - ElementStyle style = new ElementStyle(tag); - - Collection elementStyles = new ArrayList<>(); - for (Theme theme : themes.values()) { - elementStyles.addAll(theme.getElements()); - } - elementStyles.addAll(elements); - - for (ElementStyle elementStyle : elementStyles) { - if (elementStyle != null && elementStyle.getTag().equals(tag)) { - style.copyFrom(elementStyle); - } - } - - return style; - } - - /** - * Finds the relationship style for the given tag. This method creates an empty style, - * and copies properties from any relationship styles (from the workspace and any themes) for the given tag. - * - * - * @param tag the tag (a String) - * @return a RelationshipStyle instance - */ - public RelationshipStyle findRelationshipStyle(String tag) { - if (tag == null) { - return null; - } - - tag = tag.trim(); - RelationshipStyle style = new RelationshipStyle(tag); - - Collection relationshipStyles= new ArrayList<>(); - for (Theme theme : themes.values()) { - relationshipStyles.addAll(theme.getRelationships()); - } - relationshipStyles.addAll(relationships); - - for (RelationshipStyle relationshipStyle : relationshipStyles) { - if (relationshipStyle != null && relationshipStyle.getTag().equals(tag)) { - style.copyFrom(relationshipStyle); - } - } - - return style; - } - - public ElementStyle findElementStyle(Element element) { - ElementStyle style = new ElementStyle("").background("#dddddd").color("#000000").shape(Shape.Box).fontSize(24).border(Border.Solid).opacity(100).metadata(true).description(true); - - if (element instanceof DeploymentNode) { - style.setBackground("#ffffff"); - style.setColor("#000000"); - style.setStroke("#888888"); - } - - if (element != null) { - String tags = element.getTags(); - - if (element instanceof SoftwareSystemInstance) { - SoftwareSystem ss = ((SoftwareSystemInstance)element).getSoftwareSystem(); - tags = ss.getTags() + "," + tags; - } else if (element instanceof ContainerInstance) { - Container c = ((ContainerInstance)element).getContainer(); - tags = c.getTags() + "," + tags; - } - - for (String tag : tags.split(",")) { - if (!StringUtils.isNullOrEmpty(tag)) { - ElementStyle elementStyle = findElementStyle(tag); - if (elementStyle != null) { - style.copyFrom(elementStyle); - } - } - } - } - - if (style.getWidth() == null) { - if (style.getShape() == Shape.Person) { - style.setWidth(DEFAULT_WIDTH_OF_PERSON); - } else { - style.setWidth(DEFAULT_WIDTH_OF_ELEMENT); - } - } - - if (style.getHeight() == null) { - if (style.getShape() == Shape.Person || style.getShape() == Shape.Robot) { - style.setHeight(DEFAULT_HEIGHT_OF_PERSON); - } else { - style.setHeight(DEFAULT_HEIGHT_OF_ELEMENT); - } - } - - if (style.getStroke() == null) { - java.awt.Color color = java.awt.Color.decode(style.getBackground()); - style.setStroke(String.format("#%06X", (0xFFFFFF & color.darker().getRGB()))); - } - - return style; - } - - public RelationshipStyle findRelationshipStyle(Relationship relationship) { - RelationshipStyle style = new RelationshipStyle("").thickness(2).color("#707070").dashed(true).routing(Routing.Direct).fontSize(24).width(200).position(50).opacity(100); - - if (relationship != null) { - String tags = relationship.getTags(); - String linkedRelationshipId = relationship.getLinkedRelationshipId(); - - while (!StringUtils.isNullOrEmpty(linkedRelationshipId)) { - // the "linked relationship ID" is used for: - // - container instance -> container instance relationships - // - implied relationships - Relationship linkedRelationship = relationship.getModel().getRelationship(linkedRelationshipId); - tags = linkedRelationship.getTags() + "," + tags; - linkedRelationshipId = linkedRelationship.getLinkedRelationshipId(); - } - - for (String tag : tags.split(",")) { - if (!StringUtils.isNullOrEmpty(tag)) { - RelationshipStyle relationshipStyle = findRelationshipStyle(tag); - if (relationshipStyle != null) { - style.copyFrom(relationshipStyle); - } - } - } - } - - return style; - } - - void addStylesFromTheme(String url, Collection elements, Collection relationships) { - themes.put(url, new Theme(elements, relationships)); - } - -} diff --git a/structurizr-core/src/com/structurizr/AbstractWorkspace.java b/structurizr-core/src/main/java/com/structurizr/AbstractWorkspace.java similarity index 89% rename from structurizr-core/src/com/structurizr/AbstractWorkspace.java rename to structurizr-core/src/main/java/com/structurizr/AbstractWorkspace.java index 0375ba917..9f067fb6f 100644 --- a/structurizr-core/src/com/structurizr/AbstractWorkspace.java +++ b/structurizr-core/src/main/java/com/structurizr/AbstractWorkspace.java @@ -1,8 +1,10 @@ package com.structurizr; import com.structurizr.configuration.WorkspaceConfiguration; +import com.structurizr.util.StringUtils; import java.lang.reflect.Constructor; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -10,13 +12,12 @@ /** * The superclass for regular and encrypted workspaces. */ -public abstract class AbstractWorkspace { +public abstract class AbstractWorkspace implements PropertyHolder { private long id; private String name; private String description; private String version; - private Long revision; private Date lastModifiedDate; private String lastModifiedUser; private String lastModifiedAgent; @@ -110,24 +111,6 @@ public void setVersion(String version) { } - /** - * Gets the revision number of this workspace. - * - * @return the revision number - */ - public Long getRevision() { - return revision; - } - - /** - * Sets the revision number of this workspace. - * - * @param revision a number - */ - public void setRevision(Long revision) { - this.revision = revision; - } - /** * Gets the last modified date of this workspace. * @@ -228,7 +211,7 @@ private WorkspaceConfiguration createWorkspaceConfiguration() { * Clears the configuration associated with this workspace. */ public void clearConfiguration() { - this.configuration = null; + this.configuration = createWorkspaceConfiguration(); } /** @@ -237,7 +220,7 @@ public void clearConfiguration() { * @return a Map (String, String) (empty if there are no properties) */ public Map getProperties() { - return new HashMap<>(properties); + return Collections.unmodifiableMap(properties); } /** @@ -247,17 +230,30 @@ public Map getProperties() { * @param value the value of the property */ public void addProperty(String name, String value) { - if (name == null || name.trim().length() == 0) { + if (StringUtils.isNullOrEmpty(name)) { throw new IllegalArgumentException("A property name must be specified."); } - if (value == null || value.trim().length() == 0) { + if (StringUtils.isNullOrEmpty(value)) { throw new IllegalArgumentException("A property value must be specified."); } properties.put(name, value); } + /** + * Removes a name-value pair property from this workspace. + * + * @param name the name of the property to remove + */ + public void removeProperty(String name) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A property name must be specified."); + } + + properties.remove(name); + } + void setProperties(Map properties) { if (properties != null) { this.properties = new HashMap<>(properties); diff --git a/structurizr-core/src/main/java/com/structurizr/PerspectivesHolder.java b/structurizr-core/src/main/java/com/structurizr/PerspectivesHolder.java new file mode 100644 index 000000000..1df88a62c --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/PerspectivesHolder.java @@ -0,0 +1,40 @@ +package com.structurizr; + +import com.structurizr.model.Perspective; +import com.structurizr.util.StringUtils; + +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +public interface PerspectivesHolder { + + /** + * Gets the set of perspectives associated with this object. + * + * @return a Set of Perspective objects (empty if there are none) + */ + Set getPerspectives(); + + /** + * Adds a perspective to this object. + * + * @param name the name of the perspective (e.g. "Security", must be unique) + * @param description the description of the perspective + * @return a Perspective object + * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already + */ + Perspective addPerspective(String name, String description); + + /** + * Adds a perspective to this object. + * + * @param name the name of the perspective (e.g. "Technical Debt", must be unique) + * @param description the description of the perspective (e.g. "High") + * @param value the value of the perspective + * @return a Perspective object + * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already + */ + Perspective addPerspective(String name, String description, String value); + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/PropertyHolder.java b/structurizr-core/src/main/java/com/structurizr/PropertyHolder.java similarity index 83% rename from structurizr-core/src/com/structurizr/PropertyHolder.java rename to structurizr-core/src/main/java/com/structurizr/PropertyHolder.java index ec22c5567..b90616a91 100644 --- a/structurizr-core/src/com/structurizr/PropertyHolder.java +++ b/structurizr-core/src/main/java/com/structurizr/PropertyHolder.java @@ -5,14 +5,14 @@ public interface PropertyHolder { /** - * Gets the collection of name-value property pairs associated with this workspace, as a Map. + * Gets the collection of name-value property pairs associated with this object, as a Map. * * @return a Map (String, String) (empty if there are no properties) */ public Map getProperties(); /** - * Adds a name-value pair property to this workspace. + * Adds a name-value pair property to this object. * * @param name the name of the property * @param value the value of the property diff --git a/structurizr-core/src/main/java/com/structurizr/Workspace.java b/structurizr-core/src/main/java/com/structurizr/Workspace.java new file mode 100644 index 000000000..0b785e708 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/Workspace.java @@ -0,0 +1,391 @@ +package com.structurizr; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Documentation; +import com.structurizr.model.*; +import com.structurizr.view.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import javax.annotation.Nonnull; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Represents a Structurizr workspace, which is a wrapper for a + * software architecture model, views and documentation. + */ +public final class Workspace extends AbstractWorkspace implements Documentable { + + private static final Log log = LogFactory.getLog(Workspace.class); + + private Model model = createModel(); + private ViewSet viewSet; + private Documentation documentation; + + Workspace() { + } + + /** + * Creates a new workspace. + * + * @param name the name of the workspace + */ + public Workspace(String name) { + this(name, ""); + } + + /** + * Creates a new workspace. + * + * @param name the name of the workspace + * @param description a short description + */ + public Workspace(String name, String description) { + super(name, description); + + model = createModel(); + viewSet = createViewSet(); + documentation = new Documentation(); + } + + /** + * Gets the software architecture model. + * + * @return a Model instance + */ + public Model getModel() { + return model; + } + + void setModel(Model model) { + this.model = model; + } + + /** + * Gets the set of views onto a software architecture model. + * + * @return a ViewSet instance + */ + public ViewSet getViews() { + return viewSet; + } + + void setViews(ViewSet viewSet) { + this.viewSet = viewSet; + } + + private Model createModel() { + try { + Constructor constructor = Model.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return (Model)constructor.newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ViewSet createViewSet() { + try { + Constructor constructor = ViewSet.class.getDeclaredConstructor(Model.class); + constructor.setAccessible(true); + return (ViewSet)constructor.newInstance(model); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Called when deserialising JSON, to re-create the object graph + * based upon element/relationship IDs. + */ + public void hydrate() { + if (viewSet == null) { + viewSet = createViewSet(); + } + + hydrateModel(); + hydrateViewSet(); + } + + private void hydrateModel() { + try { + Method hydrateMethod = Model.class.getDeclaredMethod("hydrate"); + hydrateMethod.setAccessible(true); + hydrateMethod.invoke(model); + } catch (InvocationTargetException ite) { + if (ite.getCause() != null && ite.getCause() instanceof WorkspaceValidationException) { + throw (WorkspaceValidationException)ite.getCause(); + } else { + throw new RuntimeException(ite.getCause()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void hydrateViewSet() { + try { + Method hydrateMethod = ViewSet.class.getDeclaredMethod("hydrate", Model.class); + hydrateMethod.setAccessible(true); + hydrateMethod.invoke(viewSet, model); + } catch (InvocationTargetException ite) { + if (ite.getCause() != null && ite.getCause() instanceof WorkspaceValidationException) { + throw (WorkspaceValidationException)ite.getCause(); + } else { + throw new RuntimeException(ite.getCause()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Gets the documentation associated with this workspace. + * + * @return a Documentation object + */ + public Documentation getDocumentation() { + return documentation; + } + + /** + * Sets the documentation associated with this workspace. + * + * @param documentation a Documentation object + */ + void setDocumentation(@Nonnull Documentation documentation) { + this.documentation = documentation; + } + + /** + * Determines whether this model is empty. + * + * @return true if the model has no elements, views or documentation; false otherwise + */ + @JsonIgnore + public boolean isEmpty() { + return model.isEmpty() && viewSet.isEmpty() && documentation.isEmpty(); + } + + /** + * Trims the workspace by removing all unused elements. + */ + public void trim() { + for (CustomElement element : model.getCustomElements()) { + remove(element); + } + + for (Person person : model.getPeople()) { + remove(person); + } + + for (SoftwareSystem softwareSystem : model.getSoftwareSystems()) { + remove(softwareSystem); + } + + for (DeploymentNode deploymentNode : model.getDeploymentNodes()) { + remove(deploymentNode); + } + } + + void remove(CustomElement element) { + if (!isElementAssociatedWithAnyViews(element)) { + try { + Method method = Model.class.getDeclaredMethod("remove", CustomElement.class); + method.setAccessible(true); + method.invoke(model, element); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(Person person) { + if (!isElementAssociatedWithAnyViews(person)) { + try { + Method method = Model.class.getDeclaredMethod("remove", Person.class); + method.setAccessible(true); + method.invoke(model, person); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(SoftwareSystem softwareSystem) { + Set softwareSystemInstances = model.getElements().stream().filter(e -> e instanceof SoftwareSystemInstance && ((SoftwareSystemInstance)e).getSoftwareSystem() == softwareSystem).map(e -> (SoftwareSystemInstance)e).collect(Collectors.toSet()); + for (SoftwareSystemInstance softwareSystemInstance : softwareSystemInstances) { + remove(softwareSystemInstance); + } + + for (Container container : softwareSystem.getContainers()) { + remove(container); + } + + boolean hasContainers = softwareSystem.hasContainers(); + boolean hasSoftwareSystemInstances = model.getElements().stream().anyMatch(e -> e instanceof SoftwareSystemInstance && ((SoftwareSystemInstance)e).getSoftwareSystem() == softwareSystem); + if (!hasContainers && !hasSoftwareSystemInstances && !isElementAssociatedWithAnyViews(softwareSystem)) { + try { + Method method = Model.class.getDeclaredMethod("remove", SoftwareSystem.class); + method.setAccessible(true); + method.invoke(model, softwareSystem); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(Container container) { + for (Component component : container.getComponents()) { + remove(component); + } + + if (!isElementAssociatedWithAnyViews(container)) { + Set containerInstances = model.getElements().stream().filter(e -> e instanceof ContainerInstance && ((ContainerInstance)e).getContainer() == container).map(e -> (ContainerInstance)e).collect(Collectors.toSet()); + for (ContainerInstance containerInstance : containerInstances) { + remove(containerInstance); + } + + boolean hasComponents = container.hasComponents(); + boolean hasContainerInstances = model.getElements().stream().anyMatch(e -> e instanceof ContainerInstance && ((ContainerInstance)e).getContainer() == container); + if (!hasComponents && !hasContainerInstances && !isElementAssociatedWithAnyViews(container)) { + try { + Method method = Model.class.getDeclaredMethod("remove", Container.class); + method.setAccessible(true); + method.invoke(model, container); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + } + + void remove(Component component) { + if (!isElementAssociatedWithAnyViews(component)) { + try { + Method method = Model.class.getDeclaredMethod("remove", Component.class); + method.setAccessible(true); + method.invoke(model, component); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(SoftwareSystemInstance softwareSystemInstance) { + if (!isElementAssociatedWithAnyViews(softwareSystemInstance)) { + try { + Method method = Model.class.getDeclaredMethod("remove", SoftwareSystemInstance.class); + method.setAccessible(true); + method.invoke(model, softwareSystemInstance); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(ContainerInstance containerInstance) { + if (!isElementAssociatedWithAnyViews(containerInstance)) { + try { + Method method = Model.class.getDeclaredMethod("remove", ContainerInstance.class); + method.setAccessible(true); + method.invoke(model, containerInstance); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + void remove(DeploymentNode deploymentNode) { + if (deploymentNode.hasChildren()) { + for (DeploymentNode child : deploymentNode.getChildren()) { + remove(child); + } + } + + if (!deploymentNode.hasChildren() && !deploymentNode.hasSoftwareSystemInstances() && !deploymentNode.hasContainerInstances()) { + try { + Method method = Model.class.getDeclaredMethod("remove", DeploymentNode.class); + method.setAccessible(true); + method.invoke(model, deploymentNode); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + /** + * Removes a relationship from the workspace. + * + * @param relationship the Relationship to remove + */ + public void remove(Relationship relationship) { + if (relationship == null) { + throw new IllegalArgumentException("A relationship must be specified."); + } + + // remove the relationship from views + for (View view : viewSet.getViews()) { + if (view instanceof ModelView) { + ModelView modelView = (ModelView)view; + if (modelView.isRelationshipInView(relationship)) { + modelView.remove(relationship); + } + } + } + + // now remove the relationship itself + try { + Method method = Model.class.getDeclaredMethod("remove", Relationship.class); + method.setAccessible(true); + method.invoke(model, relationship); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isElementAssociatedWithAnyViews(Element element) { + boolean result = false; + + // is the element used in any views + for (View view : viewSet.getViews()) { + if (view instanceof ModelView) { + ModelView modelView = (ModelView)view; + result = result | modelView.isElementInView(element); + } + } + + // is the element the scope of any views? + for (SystemContextView view : viewSet.getSystemContextViews()) { + result = result | view.getSoftwareSystem() == element; + } + + for (ContainerView view : viewSet.getContainerViews()) { + result = result | view.getSoftwareSystem() == element; + } + + for (ComponentView view : viewSet.getComponentViews()) { + result = result | view.getContainer() == element; + } + + for (DynamicView view : viewSet.getDynamicViews()) { + result = result | view.getElement() == element; + } + + for (DeploymentView view : viewSet.getDeploymentViews()) { + result = result | view.getSoftwareSystem() == element; + } + + for (ImageView view : viewSet.getImageViews()) { + result = result | view.getElement() == element; + } + + return result; + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/WorkspaceValidationException.java b/structurizr-core/src/main/java/com/structurizr/WorkspaceValidationException.java similarity index 100% rename from structurizr-core/src/com/structurizr/WorkspaceValidationException.java rename to structurizr-core/src/main/java/com/structurizr/WorkspaceValidationException.java diff --git a/structurizr-core/src/com/structurizr/configuration/Role.java b/structurizr-core/src/main/java/com/structurizr/configuration/Role.java similarity index 100% rename from structurizr-core/src/com/structurizr/configuration/Role.java rename to structurizr-core/src/main/java/com/structurizr/configuration/Role.java diff --git a/structurizr-core/src/com/structurizr/configuration/User.java b/structurizr-core/src/main/java/com/structurizr/configuration/User.java similarity index 70% rename from structurizr-core/src/com/structurizr/configuration/User.java rename to structurizr-core/src/main/java/com/structurizr/configuration/User.java index efad4930c..c903452da 100644 --- a/structurizr-core/src/com/structurizr/configuration/User.java +++ b/structurizr-core/src/main/java/com/structurizr/configuration/User.java @@ -1,9 +1,11 @@ package com.structurizr.configuration; +import com.structurizr.util.StringUtils; + /** * Represents a user, and the role-based access they have to a workspace. */ -public final class User { +public final class User implements Comparable { private String username; private Role role; @@ -11,7 +13,15 @@ public final class User { User() { } - User(String username, Role role) { + public User(String username, Role role) { + if (StringUtils.isNullOrEmpty(username)) { + throw new IllegalArgumentException("A username must be specified."); + } + + if (role == null) { + throw new IllegalArgumentException("A role must be specified."); + } + setUsername(username); setRole(role); } @@ -65,4 +75,9 @@ public String toString() { '}'; } + @Override + public int compareTo(User user) { + return getUsername().compareTo(user.getUsername()); + } + } \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/configuration/Visibility.java b/structurizr-core/src/main/java/com/structurizr/configuration/Visibility.java new file mode 100644 index 000000000..a61fff520 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/configuration/Visibility.java @@ -0,0 +1,8 @@ +package com.structurizr.configuration; + +public enum Visibility { + + Private, + Public + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceConfiguration.java b/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceConfiguration.java new file mode 100644 index 000000000..a38edf28c --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceConfiguration.java @@ -0,0 +1,95 @@ +package com.structurizr.configuration; + +import java.util.Set; +import java.util.TreeSet; + +/** + * A wrapper for configuration options related to the workspace. + */ +public final class WorkspaceConfiguration { + + private WorkspaceScope scope = null; + private Visibility visibility = null; + private Set users = new TreeSet<>(); + + WorkspaceConfiguration() { + } + + /** + * Gets the scope of this workspace + * + * @return a WorkspaceScope enum, or null if undefined + */ + public WorkspaceScope getScope() { + return scope; + } + + /** + * Sets the workspace scope. + * + * @param scope a WorkspaceScope enum, or null if undefined + */ + public void setScope(WorkspaceScope scope) { + this.scope = scope; + } + + /** + * Gets the visibility of this workspace (private or public). + * + * @return a Visibility enum + */ + public Visibility getVisibility() { + return visibility; + } + + /** + * Sets the visibility of this workspace (private or public). + * + * @param visibility a Visibility enum, or null to indicate that no changes should be made + */ + public void setVisibility(Visibility visibility) { + this.visibility = visibility; + } + + /** + * Gets the set of users should have read-write or read-only access to the workspace. + * + * @return a Set of User objects (could be empty) + */ + public Set getUsers() { + return new TreeSet<>(users); + } + + void setUsers(Set users) { + if (users != null) { + this.users = new TreeSet<>(users); + } + } + + /** + * Adds a user. + * + * @param user a User object representing the username and role + */ + public void addUser(User user) { + users.add(user); + } + + /** + * Adds a user, with the specified username and role. + * + * @param username the username (e.g. an e-mail address) + * @param role the user's role + */ + public void addUser(String username, Role role) { + users.add(new User(username, role)); + } + + /** + * Clears all configured users. + */ + public void clearUsers() { + this.users = new TreeSet<>(); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceScope.java b/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceScope.java new file mode 100644 index 000000000..8d271c504 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/configuration/WorkspaceScope.java @@ -0,0 +1,8 @@ +package com.structurizr.configuration; + +public enum WorkspaceScope { + + Landscape, + SoftwareSystem + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/documentation/Decision.java b/structurizr-core/src/main/java/com/structurizr/documentation/Decision.java similarity index 85% rename from structurizr-core/src/com/structurizr/documentation/Decision.java rename to structurizr-core/src/main/java/com/structurizr/documentation/Decision.java index bb0ee574b..dac0969bd 100644 --- a/structurizr-core/src/com/structurizr/documentation/Decision.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/Decision.java @@ -3,19 +3,20 @@ import com.structurizr.util.StringUtils; import java.util.Date; -import java.util.HashSet; import java.util.Set; +import java.util.TreeSet; /** * Represents a single (architecture) decision, as described at http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions */ -public final class Decision extends DocumentationContent { +public final class Decision extends DocumentationContent implements Comparable { private String id; + private String title; private Date date; private String status; - private Set links = new HashSet<>(); + private Set links = new TreeSet<>(); Decision() { } @@ -37,6 +38,19 @@ void setId(String id) { this.id = id; } + /** + * Gets the title. + * + * @return the title, as a String + */ + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + /** * Gets the date of this decision. * @@ -69,7 +83,7 @@ public void setStatus(String status) { * @return a Set of Link objects */ public Set getLinks() { - return new HashSet<>(links); + return new TreeSet<>(links); } void setLinks(Set links) { @@ -126,7 +140,7 @@ public int hashCode() { /** * Represents a link between two decisions. */ - public static final class Link { + public static final class Link implements Comparable { private String id; private String description = ""; @@ -185,6 +199,17 @@ public int hashCode() { result = 31 * result + id.hashCode(); return result; } + + @Override + public int compareTo(Link link) { + return getId().compareTo(link.getId()); + } + + } + + @Override + public int compareTo(Decision decision) { + return getId().compareTo(decision.getId()); } } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/documentation/Documentable.java b/structurizr-core/src/main/java/com/structurizr/documentation/Documentable.java similarity index 81% rename from structurizr-core/src/com/structurizr/documentation/Documentable.java rename to structurizr-core/src/main/java/com/structurizr/documentation/Documentable.java index 9ed7a6705..495310feb 100644 --- a/structurizr-core/src/com/structurizr/documentation/Documentable.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/Documentable.java @@ -1,7 +1,5 @@ package com.structurizr.documentation; -import com.structurizr.documentation.Documentation; - /** * Marker interface for items that can have documentation attached (i.e. workspaces and software systems). */ diff --git a/structurizr-core/src/com/structurizr/documentation/Documentation.java b/structurizr-core/src/main/java/com/structurizr/documentation/Documentation.java similarity index 70% rename from structurizr-core/src/com/structurizr/documentation/Documentation.java rename to structurizr-core/src/main/java/com/structurizr/documentation/Documentation.java index 0155113f5..1b3548066 100644 --- a/structurizr-core/src/com/structurizr/documentation/Documentation.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/Documentation.java @@ -3,22 +3,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.structurizr.util.StringUtils; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.Set; +import java.util.*; /** - * Represents the documentation within a workspace or software system - a collection of - * content in Markdown or AsciiDoc format, optionally with attached images. + * Represents the documentation within a workspace, software system, container, or component; + * a collection of content in Markdown or AsciiDoc format, optionally with attached images. * - * See Documentation - * on the Structurizr website for more details. + * See Documentation + * and Decisions for more details. */ public final class Documentation { - private Set
sections = new HashSet<>(); - private Set decisions = new HashSet<>(); - private Set images = new HashSet<>(); + private List
sections = new ArrayList<>(); + private Set decisions = new TreeSet<>(); + private Set images = new TreeSet<>(); public Documentation() { } @@ -29,9 +27,6 @@ public Documentation() { * @param section a Section object */ public void addSection(Section section) { - checkTitleIsSpecified(section.getTitle()); - checkContentIsSpecified(section.getContent()); - checkSectionIsUnique(section.getTitle()); checkFormatIsSpecified(section.getFormat()); section.setOrder(calculateOrder()); @@ -56,14 +51,6 @@ private void checkFormatIsSpecified(Format format) { } } - private void checkSectionIsUnique(String title) { - for (Section section : sections) { - if (title.equals(section.getTitle())) { - throw new IllegalArgumentException("A section with a title of " + title + " already exists in this scope."); - } - } - } - private int calculateOrder() { return sections.size() + 1; } @@ -73,13 +60,13 @@ private int calculateOrder() { * * @return a Set of {@link Section} objects */ - public Set
getSections() { - return new HashSet<>(sections); + public Collection
getSections() { + return new ArrayList<>(sections); } - void setSections(Set
sections) { + void setSections(Collection
sections) { if (sections != null) { - this.sections = new LinkedHashSet<>(sections); + this.sections = new ArrayList<>(sections); } } @@ -89,12 +76,12 @@ void setSections(Set
sections) { * @return a Set of Decision objects */ public Set getDecisions() { - return new HashSet<>(decisions); + return new TreeSet<>(decisions); } void setDecisions(Set decisions) { if (decisions != null) { - this.decisions = new HashSet<>(decisions); + this.decisions = new TreeSet<>(decisions); } } @@ -149,12 +136,12 @@ public void addImage(Image image) { * @return a Set of {@link Image} objects */ public Set getImages() { - return new HashSet<>(images); + return new TreeSet<>(images); } void setImages(Set images) { if (images != null) { - this.images = new HashSet<>(images); + this.images = new TreeSet<>(images); } } @@ -167,9 +154,9 @@ public boolean isEmpty() { * Removes all documentation, decisions, and images. */ public void clear() { - sections = new HashSet<>(); - decisions = new HashSet<>(); - images = new HashSet<>(); + sections = new ArrayList<>(); + decisions = new TreeSet<>(); + images = new TreeSet<>(); } } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/documentation/DocumentationContent.java b/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java similarity index 82% rename from structurizr-core/src/com/structurizr/documentation/DocumentationContent.java rename to structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java index 5013ce3a7..1759f81df 100644 --- a/structurizr-core/src/com/structurizr/documentation/DocumentationContent.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/DocumentationContent.java @@ -8,7 +8,6 @@ public abstract class DocumentationContent { // elementId is here for backwards compatibility private String elementId; - private String title; private String content; private Format format; @@ -29,19 +28,6 @@ void setElementId(String elementId) { this.elementId = elementId; } - /** - * Gets the title. - * - * @return the title, as a String - */ - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - /** * Gets the content. * diff --git a/structurizr-core/src/com/structurizr/documentation/Format.java b/structurizr-core/src/main/java/com/structurizr/documentation/Format.java similarity index 100% rename from structurizr-core/src/com/structurizr/documentation/Format.java rename to structurizr-core/src/main/java/com/structurizr/documentation/Format.java diff --git a/structurizr-core/src/com/structurizr/documentation/Image.java b/structurizr-core/src/main/java/com/structurizr/documentation/Image.java similarity index 81% rename from structurizr-core/src/com/structurizr/documentation/Image.java rename to structurizr-core/src/main/java/com/structurizr/documentation/Image.java index b76845f13..b28404215 100644 --- a/structurizr-core/src/com/structurizr/documentation/Image.java +++ b/structurizr-core/src/main/java/com/structurizr/documentation/Image.java @@ -3,7 +3,7 @@ /** * Represents a base64 encoded image (png/jpg/gif). */ -public final class Image { +public final class Image implements Comparable { private String name; private String content; @@ -42,4 +42,9 @@ void setType(String type) { this.type = type; } + @Override + public int compareTo(Image image) { + return getName().compareTo(image.getName()); + } + } \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/documentation/Section.java b/structurizr-core/src/main/java/com/structurizr/documentation/Section.java new file mode 100644 index 000000000..007e53fa3 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/documentation/Section.java @@ -0,0 +1,57 @@ +package com.structurizr.documentation; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * A documentation section. + */ +public final class Section extends DocumentationContent { + + private String filename; + private int order; + + public Section() { + } + + public Section(Format format, String content) { + setFormat(format); + setContent(content); + } + + /** + * This method is retained for backwards compatibility. + */ + @JsonGetter + @JsonInclude(JsonInclude.Include.ALWAYS) + public String getTitle() { + return ""; + } + + /** + * Gets the filename of this section. + * + * @return the filename, as a String + */ + public String getFilename() { + return filename; + } + + /** + * Sets the filename of this section (e.g. where this section was imported from). + * + * @param filename the filename, as a String + */ + public void setFilename(String filename) { + this.filename = filename; + } + + public int getOrder() { + return order; + } + + void setOrder(int order) { + this.order = order; + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/AbstractImpliedRelationshipsStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/AbstractImpliedRelationshipsStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/AbstractImpliedRelationshipsStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/AbstractImpliedRelationshipsStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/CanonicalNameGenerator.java b/structurizr-core/src/main/java/com/structurizr/model/CanonicalNameGenerator.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/CanonicalNameGenerator.java rename to structurizr-core/src/main/java/com/structurizr/model/CanonicalNameGenerator.java diff --git a/structurizr-core/src/main/java/com/structurizr/model/Component.java b/structurizr-core/src/main/java/com/structurizr/model/Component.java new file mode 100644 index 000000000..a1b63db61 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/model/Component.java @@ -0,0 +1,92 @@ +package com.structurizr.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Documentation; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Represents a "component" in the C4 model. + */ +public final class Component extends StaticStructureElement implements Documentable { + + private Container parent; + + private String technology; + + private Documentation documentation = new Documentation(); + + Component() { + } + + @Override + @JsonIgnore + public Element getParent() { + return parent; + } + + @JsonIgnore + public Container getContainer() { + return parent; + } + + void setParent(Container parent) { + this.parent = parent; + } + + /** + * Gets the technology associated with this component (e.g. "Spring Bean"). + * + * @return the technology, as a String, + * or null if no technology has been specified + */ + public String getTechnology() { + return technology; + } + + /** + * Sets the technology associated with this component (e.g. "Spring Bean"). + * + * @param technology the technology, as a String + */ + public void setTechnology(String technology) { + this.technology = technology; + } + + /** + * Gets the canonical name of this component, in the form "/Software System/Container/Component". + * + * @return the canonical name, as a String + */ + @Override + public String getCanonicalName() { + return new CanonicalNameGenerator().generate(this); + } + + @Override + public Set getDefaultTags() { + return new LinkedHashSet<>(Arrays.asList(Tags.ELEMENT, Tags.COMPONENT)); + } + + /** + * Gets the documentation associated with this component. + * + * @return a Documentation object + */ + public Documentation getDocumentation() { + return documentation; + } + + /** + * Sets the documentation associated with this component. + * + * @param documentation a Documentation object + */ + void setDocumentation(Documentation documentation) { + this.documentation = documentation; + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/model/Constants.java b/structurizr-core/src/main/java/com/structurizr/model/Constants.java new file mode 100644 index 000000000..54a2aa8b2 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/model/Constants.java @@ -0,0 +1,7 @@ +package com.structurizr.model; + +public final class Constants { + + public static final String GROUP_SEPARATOR_PROPERTY_NAME = "structurizr.groupSeparator"; + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Container.java b/structurizr-core/src/main/java/com/structurizr/model/Container.java similarity index 64% rename from structurizr-core/src/com/structurizr/model/Container.java rename to structurizr-core/src/main/java/com/structurizr/model/Container.java index a00e5a538..5e7f799f0 100644 --- a/structurizr-core/src/com/structurizr/model/Container.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Container.java @@ -1,18 +1,23 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Documentation; +import javax.annotation.Nonnull; import java.util.*; /** * Represents a "container" in the C4 model. */ -public final class Container extends StaticStructureElement { +public final class Container extends StaticStructureElement implements Documentable { private SoftwareSystem parent; private String technology; - private Set components = new LinkedHashSet<>(); + private Set components = new TreeSet<>(); + + private Documentation documentation = new Documentation(); Container() { } @@ -94,35 +99,7 @@ public Component addComponent(String name, String description) { * @throws IllegalArgumentException if the component name is null or empty, or a component with the same name already exists */ public Component addComponent(String name, String description, String technology) { - return this.addComponent(name, (String)null, description, technology); - } - - /** - * Adds a component to this container. - * - * @param name the name of the component - * @param type a Class instance representing the primary type of the component - * @param description a description of the component - * @param technology the technology of the component - * @return the resulting Component instance - * @throws IllegalArgumentException if the component name is null or empty, or a component with the same name already exists - */ - public Component addComponent(String name, Class type, String description, String technology) { - return this.addComponent(name, type.getCanonicalName(), description, technology); - } - - /** - * Adds a component to this container. - * - * @param name the name of the component - * @param type a String describing the fully qualified name of the primary type of the component - * @param description a description of the component - * @param technology the technology of the component - * @return the resulting Component instance - * @throws IllegalArgumentException if the component name is null or empty, or a component with the same name already exists - */ - public Component addComponent(String name, String type, String description, String technology) { - return getModel().addComponentOfType(this, name, type, description, technology); + return getModel().addComponent(this, name, description, technology); } void add(Component component) { @@ -131,21 +108,35 @@ void add(Component component) { } } + void remove(Component component) { + components.remove(component); + } + /** * Gets the set of components within this software system. * * @return a Set of Component objects */ public Set getComponents() { - return new HashSet<>(components); + return new TreeSet<>(components); } void setComponents(Set components) { if (components != null) { - this.components = new HashSet<>(components); + this.components = new TreeSet<>(components); } } + /** + * Determines whether this container has any components. + * + * @return true if it has components, false otherwise + */ + @JsonIgnore + public boolean hasComponents() { + return !components.isEmpty(); + } + /** * Gets the component with the specified name. * @@ -162,22 +153,6 @@ public Component getComponentWithName(String name) { return component.orElse(null); } - /** - * Gets the component of the specified type. - * - * @param type the fully qualified type of the component - * @return the Component instance, or null if a component with the specified type does not exist - * @throws IllegalArgumentException if the type is null or empty - */ - public Component getComponentOfType(String type) { - if (type == null || type.trim().length() == 0) { - throw new IllegalArgumentException("A component type must be provided."); - } - - Optional component = components.stream().filter(c -> type.equals(c.getType().getType())).findFirst(); - return component.orElse(null); - } - /** * Gets the canonical name of this container, in the form "/Software System/Container". * @@ -193,4 +168,22 @@ public Set getDefaultTags() { return new LinkedHashSet<>(Arrays.asList(Tags.ELEMENT, Tags.CONTAINER)); } + /** + * Gets the documentation associated with this container. + * + * @return a Documentation object + */ + public Documentation getDocumentation() { + return documentation; + } + + /** + * Sets the documentation associated with this container. + * + * @param documentation a Documentation object + */ + void setDocumentation(@Nonnull Documentation documentation) { + this.documentation = documentation; + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/ContainerInstance.java b/structurizr-core/src/main/java/com/structurizr/model/ContainerInstance.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/ContainerInstance.java rename to structurizr-core/src/main/java/com/structurizr/model/ContainerInstance.java diff --git a/structurizr-core/src/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/CustomElement.java b/structurizr-core/src/main/java/com/structurizr/model/CustomElement.java similarity index 98% rename from structurizr-core/src/com/structurizr/model/CustomElement.java rename to structurizr-core/src/main/java/com/structurizr/model/CustomElement.java index 0b745c3f7..53105607a 100644 --- a/structurizr-core/src/com/structurizr/model/CustomElement.java +++ b/structurizr-core/src/main/java/com/structurizr/model/CustomElement.java @@ -1,7 +1,5 @@ package com.structurizr.model; -import com.structurizr.util.StringUtils; - import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.Collections; diff --git a/structurizr-core/src/com/structurizr/model/DefaultImpliedRelationshipsStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/DefaultImpliedRelationshipsStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/DefaultImpliedRelationshipsStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/DefaultImpliedRelationshipsStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/DeploymentElement.java b/structurizr-core/src/main/java/com/structurizr/model/DeploymentElement.java similarity index 93% rename from structurizr-core/src/com/structurizr/model/DeploymentElement.java rename to structurizr-core/src/main/java/com/structurizr/model/DeploymentElement.java index 120a63af7..91d55cd21 100644 --- a/structurizr-core/src/com/structurizr/model/DeploymentElement.java +++ b/structurizr-core/src/main/java/com/structurizr/model/DeploymentElement.java @@ -5,7 +5,7 @@ /** * This is the superclass for model elements that describe deployment nodes, infrastructure nodes, and container instances. */ -public abstract class DeploymentElement extends Element { +public abstract class DeploymentElement extends GroupableElement { public static final String DEFAULT_DEPLOYMENT_ENVIRONMENT = "Default"; public static final String DEFAULT_DEPLOYMENT_GROUP = "Default"; diff --git a/structurizr-core/src/com/structurizr/model/DeploymentNode.java b/structurizr-core/src/main/java/com/structurizr/model/DeploymentNode.java similarity index 80% rename from structurizr-core/src/com/structurizr/model/DeploymentNode.java rename to structurizr-core/src/main/java/com/structurizr/model/DeploymentNode.java index 020b8fff2..41f872174 100644 --- a/structurizr-core/src/com/structurizr/model/DeploymentNode.java +++ b/structurizr-core/src/main/java/com/structurizr/model/DeploymentNode.java @@ -1,6 +1,7 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; import java.util.*; @@ -22,12 +23,12 @@ public final class DeploymentNode extends DeploymentElement { private String technology; - private int instances = 1; + private String instances = "1"; - private Set children = new HashSet<>(); - private Set infrastructureNodes = new HashSet<>(); - private Set softwareSystemInstances = new HashSet<>(); - private Set containerInstances = new HashSet<>(); + private Set children = new TreeSet<>(); + private Set infrastructureNodes = new TreeSet<>(); + private Set softwareSystemInstances = new TreeSet<>(); + private Set containerInstances = new TreeSet<>(); /** * Adds a software system instance to this deployment node, replicating relationships. @@ -53,6 +54,14 @@ public SoftwareSystemInstance add(SoftwareSystem softwareSystem, String... deplo return softwareSystemInstance; } + void remove(SoftwareSystemInstance softwareSystemInstance) { + this.softwareSystemInstances.remove(softwareSystemInstance); + } + + void remove(ContainerInstance containerInstance) { + this.containerInstances.remove(containerInstance); + } + /** * Adds a container instance to this deployment node, replicating relationships. * @@ -130,6 +139,10 @@ public DeploymentNode addDeploymentNode(String name, String description, String return deploymentNode; } + void remove(DeploymentNode deploymentNode) { + children.remove(deploymentNode); + } + /** * Gets the DeploymentNode with the specified name. * @@ -293,12 +306,12 @@ public Relationship uses(InfrastructureNode destination, String description, Str * @return a Set of DeploymentNode objects */ public Set getChildren() { - return new HashSet<>(children); + return new TreeSet<>(children); } void setChildren(Set children) { if (children != null) { - this.children = new HashSet<>(children); + this.children = new TreeSet<>(children); } } @@ -308,32 +321,67 @@ void setChildren(Set children) { * @return a Set of InfrastructureNode objects */ public Set getInfrastructureNodes() { - return new HashSet<>(infrastructureNodes); + return new TreeSet<>(infrastructureNodes); } void setInfrastructureNodes(Set infrastructureNodes) { if (infrastructureNodes != null) { - this.infrastructureNodes = new HashSet<>(infrastructureNodes); + this.infrastructureNodes = new TreeSet<>(infrastructureNodes); } } + /** + * Determines whether this deployment node has any infrastructure nodes. + * + * @return true if it has infrastructure nodes, false otherwise + */ + @JsonIgnore + public boolean hasInfrastructureNodes() { + return !infrastructureNodes.isEmpty(); + } + + /** + * Determines whether this deployment node has any child deployment nodes. + * + * @return true if it has child deployment nodes, false otherwise + */ @JsonIgnore public boolean hasChildren() { return !children.isEmpty(); } + /** + * Determines whether this deployment node has any software system instances. + * + * @return true if it has software system instances, false otherwise + */ + @JsonIgnore + public boolean hasSoftwareSystemInstances() { + return !softwareSystemInstances.isEmpty(); + } + + /** + * Determines whether this deployment node has any container instances. + * + * @return true if it has container instances, false otherwise + */ + @JsonIgnore + public boolean hasContainerInstances() { + return !containerInstances.isEmpty(); + } + /** * Gets the set of software system instances associated with this deployment node. * * @return a Set of SoftwareSystemInstance objects */ public Set getSoftwareSystemInstances() { - return new HashSet<>(softwareSystemInstances); + return new TreeSet<>(softwareSystemInstances); } void setSoftwareSystemInstances(Set softwareSystemInstances) { if (softwareSystemInstances != null) { - this.softwareSystemInstances = new HashSet<>(softwareSystemInstances); + this.softwareSystemInstances = new TreeSet<>(softwareSystemInstances); } } @@ -343,12 +391,12 @@ void setSoftwareSystemInstances(Set softwareSystemInstan * @return a Set of ContainerInstance objects */ public Set getContainerInstances() { - return new HashSet<>(containerInstances); + return new TreeSet<>(containerInstances); } void setContainerInstances(Set containerInstances) { if (containerInstances != null) { - this.containerInstances = new HashSet<>(containerInstances); + this.containerInstances = new TreeSet<>(containerInstances); } } @@ -360,13 +408,34 @@ public void setTechnology(String technology) { this.technology = technology; } - public int getInstances() { + public String getInstances() { return instances; } public void setInstances(int instances) { - if (instances < 1) { - throw new IllegalArgumentException("Number of instances must be a positive integer."); + setInstances("" + instances); + } + + @JsonSetter + public void setInstances(String instances) { + try { + int instancesAsInteger = Integer.parseInt(instances); + if (instancesAsInteger < 1) { + throw new IllegalArgumentException("Number of instances must be a positive integer or a range."); + } + } catch (NumberFormatException nfe) { + if (instances.matches("\\d*\\.\\.\\d*")) { + String[] range = instances.split("\\.\\."); + if (Integer.parseInt(range[0]) > Integer.parseInt(range[1])) { + throw new IllegalArgumentException("Range upper bound must be greater than the lower bound."); + } + } else if (instances.matches("\\d*..N")) { + // okay + } else if (instances.matches("\\d*..\\*")) { + // okay + } else { + throw new IllegalArgumentException("Number of instances must be a positive integer or a range."); + } } this.instances = instances; diff --git a/structurizr-core/src/com/structurizr/model/Element.java b/structurizr-core/src/main/java/com/structurizr/model/Element.java similarity index 94% rename from structurizr-core/src/com/structurizr/model/Element.java rename to structurizr-core/src/main/java/com/structurizr/model/Element.java index 01937349a..7294b30bd 100644 --- a/structurizr-core/src/com/structurizr/model/Element.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Element.java @@ -1,11 +1,11 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.structurizr.util.Url; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.*; +import java.util.Set; +import java.util.TreeSet; /** * This is the superclass for all model elements. @@ -17,7 +17,7 @@ public abstract class Element extends ModelItem { private String name; private String description; - private Set relationships = new LinkedHashSet<>(); + private Set relationships = new TreeSet<>(); protected Element() { } @@ -84,12 +84,12 @@ public void setDescription(String description) { * @return a Set of Relationship objects, or an empty set if none exist */ public Set getRelationships() { - return new LinkedHashSet<>(relationships); + return new TreeSet<>(relationships); } void setRelationships(Set relationships) { if (relationships != null) { - this.relationships = new LinkedHashSet<>(relationships); + this.relationships = new TreeSet<>(relationships); } } @@ -154,7 +154,7 @@ public Relationship getEfferentRelationshipWith(Element element) { * @return a Set of Relationship objects; empty if no relationships exist */ public Set getEfferentRelationshipsWith(Element element) { - Set set = new HashSet<>(); + Set set = new TreeSet<>(); if (element != null) { for (Relationship relationship : relationships) { @@ -193,13 +193,17 @@ public Relationship getEfferentRelationshipWith(Element element, String descript } boolean has(Relationship relationship) { - return relationships.stream().anyMatch(r -> r.getDestination().equals(relationship.getDestination()) && r.getDescription().equalsIgnoreCase(relationship.getDescription())); + return relationships.stream().anyMatch(r -> r.getDestination().equals(relationship.getDestination()) && r.getDescription().equals(relationship.getDescription())); } - void addRelationship(Relationship relationship) { + void add(Relationship relationship) { relationships.add(relationship); } + void remove(Relationship relationship) { + relationships.remove(relationship); + } + /** * Adds a unidirectional "uses" style relationship between this element and the specified custom element. * diff --git a/structurizr-core/src/com/structurizr/model/Enterprise.java b/structurizr-core/src/main/java/com/structurizr/model/Enterprise.java similarity index 94% rename from structurizr-core/src/com/structurizr/model/Enterprise.java rename to structurizr-core/src/main/java/com/structurizr/model/Enterprise.java index a5a6c0257..ff841e0fb 100644 --- a/structurizr-core/src/com/structurizr/model/Enterprise.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Enterprise.java @@ -16,7 +16,8 @@ public final class Enterprise { * @param name the name, as a String * @throws IllegalArgumentException if the name is not specified */ - public Enterprise(String name) { + @Deprecated + Enterprise(String name) { if (name == null || name.trim().length() == 0) { throw new IllegalArgumentException("Name must be specified."); } diff --git a/structurizr-core/src/com/structurizr/model/GroupableElement.java b/structurizr-core/src/main/java/com/structurizr/model/GroupableElement.java similarity index 85% rename from structurizr-core/src/com/structurizr/model/GroupableElement.java rename to structurizr-core/src/main/java/com/structurizr/model/GroupableElement.java index 3ddcf516b..cae1cb4b8 100644 --- a/structurizr-core/src/com/structurizr/model/GroupableElement.java +++ b/structurizr-core/src/main/java/com/structurizr/model/GroupableElement.java @@ -2,12 +2,6 @@ import com.structurizr.util.StringUtils; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; - /** * Represents an element that can be included in a group. */ diff --git a/structurizr-core/src/com/structurizr/model/HttpHealthCheck.java b/structurizr-core/src/main/java/com/structurizr/model/HttpHealthCheck.java similarity index 87% rename from structurizr-core/src/com/structurizr/model/HttpHealthCheck.java rename to structurizr-core/src/main/java/com/structurizr/model/HttpHealthCheck.java index 525e615c5..4ff1620f0 100644 --- a/structurizr-core/src/com/structurizr/model/HttpHealthCheck.java +++ b/structurizr-core/src/main/java/com/structurizr/model/HttpHealthCheck.java @@ -2,11 +2,12 @@ import java.util.HashMap; import java.util.Map; +import java.util.TreeMap; /** * Describes a HTTP based health check. */ -public final class HttpHealthCheck { +public final class HttpHealthCheck implements Comparable { /** a name for the health check */ private String name; @@ -15,7 +16,7 @@ public final class HttpHealthCheck { private String url; /** the headers that should be sent in the HTTP request */ - private Map headers = new HashMap<>(); + private final Map headers = new TreeMap<>(); /** the polling interval, in seconds */ private int interval; @@ -130,4 +131,15 @@ public int hashCode() { return result; } + @Override + public int compareTo(HttpHealthCheck healthCheck) { + int result = getName().compareTo(healthCheck.getName()); + + if (result == 0) { + result = getUrl().compareTo(healthCheck.getUrl()); + } + + return result; + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/IdGenerator.java b/structurizr-core/src/main/java/com/structurizr/model/IdGenerator.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/IdGenerator.java rename to structurizr-core/src/main/java/com/structurizr/model/IdGenerator.java diff --git a/structurizr-core/src/com/structurizr/model/ImpliedRelationshipsStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/ImpliedRelationshipsStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/ImpliedRelationshipsStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/ImpliedRelationshipsStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/InfrastructureNode.java b/structurizr-core/src/main/java/com/structurizr/model/InfrastructureNode.java similarity index 97% rename from structurizr-core/src/com/structurizr/model/InfrastructureNode.java rename to structurizr-core/src/main/java/com/structurizr/model/InfrastructureNode.java index 1910241be..c9c293c18 100644 --- a/structurizr-core/src/com/structurizr/model/InfrastructureNode.java +++ b/structurizr-core/src/main/java/com/structurizr/model/InfrastructureNode.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; -import java.util.*; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; /** *

diff --git a/structurizr-core/src/com/structurizr/model/InteractionStyle.java b/structurizr-core/src/main/java/com/structurizr/model/InteractionStyle.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/InteractionStyle.java rename to structurizr-core/src/main/java/com/structurizr/model/InteractionStyle.java diff --git a/structurizr-core/src/main/java/com/structurizr/model/Location.java b/structurizr-core/src/main/java/com/structurizr/model/Location.java new file mode 100644 index 000000000..29b0193bc --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/model/Location.java @@ -0,0 +1,9 @@ +package com.structurizr.model; + +public enum Location { + + Internal, + External, + Unspecified + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Model.java b/structurizr-core/src/main/java/com/structurizr/model/Model.java similarity index 83% rename from structurizr-core/src/com/structurizr/model/Model.java rename to structurizr-core/src/main/java/com/structurizr/model/Model.java index dcb001a87..da7058f49 100644 --- a/structurizr-core/src/com/structurizr/model/Model.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Model.java @@ -1,7 +1,9 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.PropertyHolder; import com.structurizr.WorkspaceValidationException; +import com.structurizr.util.StringUtils; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -11,41 +13,26 @@ /** * Represents a software architecture model, into which all model elements are added. */ -public final class Model { +public final class Model implements PropertyHolder { private IdGenerator idGenerator = new SequentialIntegerIdGeneratorStrategy(); + private final Set elements = new TreeSet<>(); private final Map elementsById = new HashMap<>(); - private final Map relationshipsById = new HashMap<>(); - private Enterprise enterprise; + private final Set relationships = new TreeSet<>(); + private final Map relationshipsById = new HashMap<>(); - private Set people = new LinkedHashSet<>(); - private Set softwareSystems = new LinkedHashSet<>(); - private Set deploymentNodes = new LinkedHashSet<>(); - private Set customElements = new LinkedHashSet<>(); + private Set people = new TreeSet<>(); + private Set softwareSystems = new TreeSet<>(); + private Set deploymentNodes = new TreeSet<>(); + private Set customElements = new TreeSet<>(); private ImpliedRelationshipsStrategy impliedRelationshipsStrategy = new DefaultImpliedRelationshipsStrategy(); - Model() { - } - - /** - * Gets the enterprise associated with this model. - * - * @return an Enterprise instance, or null if one has not been set - */ - public Enterprise getEnterprise() { - return enterprise; - } + private Map properties = new HashMap<>(); - /** - * Sets the enterprise associated with this model. - * - * @param enterprise an Enterprise instance - */ - public void setEnterprise(Enterprise enterprise) { - this.enterprise = enterprise; + Model() { } /** @@ -59,38 +46,22 @@ public SoftwareSystem addSoftwareSystem(@Nonnull String name) { return addSoftwareSystem(name, ""); } - /** - * Creates a software system (with an unspecified location) and adds it to the model. - * - * @param name the name of the software system - * @param description a short description of the software system - * @return the SoftwareSystem instance created and added to the model (or null) - * @throws IllegalArgumentException if a software system with the same name already exists - */ - public SoftwareSystem addSoftwareSystem(@Nonnull String name, @Nullable String description) { - return addSoftwareSystem(Location.Unspecified, name, description); - } - /** * Creates a software system and adds it to the model. * - * @param location the location of the software system (e.g. internal, external, etc) * @param name the name of the software system * @param description a short description of the software system * @return the SoftwareSystem instance created and added to the model (or null) * @throws IllegalArgumentException if a software system with the same name already exists */ - @Nonnull - public SoftwareSystem addSoftwareSystem(@Nullable Location location, @Nonnull String name, @Nullable String description) { + public SoftwareSystem addSoftwareSystem(@Nonnull String name, @Nullable String description) { if (getSoftwareSystemWithName(name) == null) { SoftwareSystem softwareSystem = new SoftwareSystem(); - softwareSystem.setLocation(location); softwareSystem.setName(name); softwareSystem.setDescription(description); + softwareSystem.setId(idGenerator.generateId(softwareSystem)); softwareSystems.add(softwareSystem); - - softwareSystem.setId(idGenerator.generateId(softwareSystem)); addElementToInternalStructures(softwareSystem); return softwareSystem; @@ -111,39 +82,23 @@ public Person addPerson(@Nonnull String name) { return addPerson(name, ""); } - /** - * Creates a person (with an unspecified location) and adds it to the model. - * - * @param name the name of the person (e.g. "Admin User" or "Bob the Business User") - * @param description a short description of the person - * @return the Person instance created and added to the model (or null) - * @throws IllegalArgumentException if a person with the same name already exists - */ - @Nonnull - public Person addPerson(@Nonnull String name, @Nullable String description) { - return addPerson(Location.Unspecified, name, description); - } - /** * Creates a person and adds it to the model. * - * @param location the location of the person (e.g. internal, external, etc) * @param name the name of the person (e.g. "Admin User" or "Bob the Business User") * @param description a short description of the person * @return the Person instance created and added to the model (or null) * @throws IllegalArgumentException if a person with the same name already exists */ @Nonnull - public Person addPerson(Location location, @Nonnull String name, @Nullable String description) { + public Person addPerson(@Nonnull String name, @Nullable String description) { if (getPersonWithName(name) == null) { Person person = new Person(); - person.setLocation(location); person.setName(name); person.setDescription(description); + person.setId(idGenerator.generateId(person)); people.add(person); - - person.setId(idGenerator.generateId(person)); addElementToInternalStructures(person); return person; @@ -192,8 +147,6 @@ public CustomElement addCustomElement(@Nonnull String name, @Nullable String met } } - - @Nonnull Container addContainer(SoftwareSystem parent, @Nonnull String name, @Nullable String description, @Nullable String technology) { if (parent.getContainerWithName(name) == null) { @@ -201,11 +154,11 @@ Container addContainer(SoftwareSystem parent, @Nonnull String name, @Nullable St container.setName(name); container.setDescription(description); container.setTechnology(technology); + container.setId(idGenerator.generateId(container)); container.setParent(parent); parent.add(container); - container.setId(idGenerator.generateId(container)); addElementToInternalStructures(container); return container; @@ -214,21 +167,17 @@ Container addContainer(SoftwareSystem parent, @Nonnull String name, @Nullable St } } - Component addComponentOfType(Container parent, String name, String type, String description, String technology) { + Component addComponent(Container parent, String name, String description, String technology) { if (parent.getComponentWithName(name) == null) { Component component = new Component(); component.setName(name); component.setDescription(description); component.setTechnology(technology); - - if (type != null && type.trim().length() > 0) { - component.setType(type); - } + component.setId(idGenerator.generateId(component)); component.setParent(parent); parent.add(component); - component.setId(idGenerator.generateId(component)); addElementToInternalStructures(component); return component; @@ -302,7 +251,7 @@ private boolean isChildOf(Element e1, Element e2) { private boolean addRelationship(Relationship relationship) { if (!relationship.getSource().has(relationship)) { relationship.setId(idGenerator.generateId(relationship)); - relationship.getSource().addRelationship(relationship); + relationship.getSource().add(relationship); addRelationshipToInternalStructures(relationship); return true; @@ -318,6 +267,7 @@ private void addElementToInternalStructures(Element element) { } elementsById.put(element.getId(), element); + elements.add(element); element.setModel(this); idGenerator.found(element.getId()); } @@ -329,10 +279,16 @@ private void addRelationshipToInternalStructures(Relationship relationship) { } relationshipsById.put(relationship.getId(), relationship); + relationships.add(relationship); relationship.setModel(this); idGenerator.found(relationship.getId()); } + private void removeRelationshipFromInternalStructures(Relationship relationship) { + relationshipsById.remove(relationship.getId()); + relationships.remove(relationship); + } + /** * Gets the set of all elements in this model. * @@ -341,7 +297,7 @@ private void addRelationshipToInternalStructures(Relationship relationship) { @JsonIgnore @Nonnull public Set getElements() { - return new HashSet<>(this.elementsById.values()); + return new TreeSet<>(elements); } /** @@ -368,7 +324,7 @@ public Element getElement(@Nonnull String id) { @JsonIgnore @Nonnull public Set getRelationships() { - return new HashSet<>(this.relationshipsById.values()); + return new TreeSet<>(this.relationships); } /** @@ -394,12 +350,12 @@ public Relationship getRelationship(@Nonnull String id) { */ @Nonnull public Set getCustomElements() { - return new LinkedHashSet<>(customElements); + return new TreeSet<>(customElements); } void setCustomElements(Set customElements) { if (customElements != null) { - this.customElements = new LinkedHashSet<>(customElements); + this.customElements = new TreeSet<>(customElements); } } @@ -410,12 +366,12 @@ void setCustomElements(Set customElements) { */ @Nonnull public Set getPeople() { - return new LinkedHashSet<>(people); + return new TreeSet<>(people); } void setPeople(Set people) { if (people != null) { - this.people = new LinkedHashSet<>(people); + this.people = new TreeSet<>(people); } } @@ -426,12 +382,12 @@ void setPeople(Set people) { */ @Nonnull public Set getSoftwareSystems() { - return new LinkedHashSet<>(softwareSystems); + return new TreeSet<>(softwareSystems); } void setSoftwareSystems(Set softwareSystems) { if (softwareSystems != null) { - this.softwareSystems = new LinkedHashSet<>(softwareSystems); + this.softwareSystems = new TreeSet<>(softwareSystems); } } @@ -442,12 +398,12 @@ void setSoftwareSystems(Set softwareSystems) { */ @Nonnull public Set getDeploymentNodes() { - return new LinkedHashSet<>(deploymentNodes); + return new TreeSet<>(deploymentNodes); } void setDeploymentNodes(Set deploymentNodes) { if (deploymentNodes != null) { - this.deploymentNodes = new LinkedHashSet<>(deploymentNodes); + this.deploymentNodes = new TreeSet<>(deploymentNodes); } } @@ -544,14 +500,14 @@ private void hydrateDeploymentNode(DeploymentNode deploymentNode, DeploymentNode } private void checkNameIsUnique(Collection elements, String name, String errorMessage) { - if (elements.stream().filter(e -> e.getName().equalsIgnoreCase(name)).count() != 1) { + if (elements.stream().filter(e -> e.getName().equals(name)).count() != 1) { throw new WorkspaceValidationException( String.format(errorMessage, name)); } } private void checkNameIsUnique(Collection deploymentNodes, String name, String environment, String errorMessage) { - if (deploymentNodes.stream().filter(dn -> dn.getName().equalsIgnoreCase(name) && dn.getEnvironment().equals(environment)).count() != 1) { + if (deploymentNodes.stream().filter(dn -> dn.getName().equals(name) && dn.getEnvironment().equals(environment)).count() != 1) { throw new WorkspaceValidationException( String.format(errorMessage, name)); } @@ -568,7 +524,7 @@ private void checkChildNamesAreUnique(DeploymentNode deploymentNode) { } private void checkDescriptionIsUnique(Collection relationships, Relationship relationship) { - if (relationships.stream().filter(r -> r.getDestination().equals(relationship.getDestination()) && r.getDescription().equalsIgnoreCase(relationship.getDescription())).count() != 1) { + if (relationships.stream().filter(r -> r.getDestination().equals(relationship.getDestination()) && r.getDescription().equals(relationship.getDescription())).count() != 1) { throw new WorkspaceValidationException( String.format( "A relationship with the description \"%s\" already exists between \"%s\" and \"%s\".", @@ -587,11 +543,21 @@ private void hydrateRelationships(Element element) { /** * Determines whether this model contains the specified element. * - * @param element any element + * @param element an element * @return true, if the element is contained in this model */ public boolean contains(Element element) { - return elementsById.values().contains(element); + return elements.contains(element); + } + + /** + * Determines whether this model contains the specified relationship. + * + * @param relationship a relationship + * @return true, if the relationship is contained in this model + */ + public boolean contains(Relationship relationship) { + return relationships.contains(relationship); } /** @@ -812,6 +778,8 @@ DeploymentNode addDeploymentNode(DeploymentNode parent, @Nullable String environ deploymentNode.setParent(parent); deploymentNode.setInstances(instances); deploymentNode.setEnvironment(environment); + deploymentNode.setId(idGenerator.generateId(deploymentNode)); + if (properties != null) { deploymentNode.setProperties(properties); } @@ -820,7 +788,6 @@ DeploymentNode addDeploymentNode(DeploymentNode parent, @Nullable String environ deploymentNodes.add(deploymentNode); } - deploymentNode.setId(idGenerator.generateId(deploymentNode)); addElementToInternalStructures(deploymentNode); return deploymentNode; @@ -842,11 +809,12 @@ InfrastructureNode addInfrastructureNode(DeploymentNode parent, @Nonnull String infrastructureNode.setTechnology(technology); infrastructureNode.setParent(parent); infrastructureNode.setEnvironment(parent.getEnvironment()); + infrastructureNode.setId(idGenerator.generateId(infrastructureNode)); + if (properties != null) { infrastructureNode.setProperties(properties); } - infrastructureNode.setId(idGenerator.generateId(infrastructureNode)); addElementToInternalStructures(infrastructureNode); return infrastructureNode; @@ -922,12 +890,11 @@ private void replicateElementRelationships(StaticStructureElementInstance elemen StaticStructureElement element = elementInstance.getElement(); // find all StaticStructureElementInstance objects in the same deployment environment and deployment group - Set elementInstances = getElements().stream() + TreeSet elementInstances = getElements().stream() .filter(e -> e instanceof StaticStructureElementInstance) - .map(e -> (StaticStructureElementInstance)e) + .map(e -> (StaticStructureElementInstance) e) .filter(ssei -> ssei.getEnvironment().equals(elementInstance.getEnvironment())) - .filter(ssei -> ssei.inSameDeploymentGroup(elementInstance)) - .collect(Collectors.toSet()); + .filter(ssei -> ssei.inSameDeploymentGroup(elementInstance)).collect(Collectors.toCollection(TreeSet::new)); // and replicate the relationships to/from the element instance for (StaticStructureElementInstance ssei : elementInstances) { @@ -958,12 +925,12 @@ private void replicateElementRelationships(StaticStructureElementInstance elemen /** * Gets the element with the specified canonical name. * - * @param canonicalName the canonical name (e.g. /SoftwareSystem/Container) + * @param canonicalName the canonical name * @return the Element with the given canonical name, or null if one doesn't exist * @throws IllegalArgumentException if the canonical name is null or empty */ public Element getElementWithCanonicalName(String canonicalName) { - if (canonicalName == null || canonicalName.trim().length() == 0) { + if (StringUtils.isNullOrEmpty(canonicalName)) { throw new IllegalArgumentException("A canonical name must be specified."); } @@ -976,6 +943,27 @@ public Element getElementWithCanonicalName(String canonicalName) { return null; } + /** + * Gets the relationship with the specified canonical name. + * + * @param canonicalName the canonical name + * @return the Relationship with the given canonical name, or null if one doesn't exist + * @throws IllegalArgumentException if the canonical name is null or empty + */ + public Relationship getRelationshipWithCanonicalName(String canonicalName) { + if (StringUtils.isNullOrEmpty(canonicalName)) { + throw new IllegalArgumentException("A canonical name must be specified."); + } + + for (Relationship relationship : getRelationships()) { + if (relationship.getCanonicalName().equals(canonicalName)) { + return relationship; + } + } + + return null; + } + /** * Sets the ID generator associated with this model. * @@ -1037,4 +1025,156 @@ public void setImpliedRelationshipsStrategy(ImpliedRelationshipsStrategy implied } } + /** + * Gets the collection of name-value property pairs, as a Map. + * + * @return a Map (String, String) (empty if there are no properties) + */ + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Adds a name-value pair property. + * + * @param name the name of the property + * @param value the value of the property + */ + public void addProperty(String name, String value) { + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException("A property name must be specified."); + } + + if (value == null || value.trim().length() == 0) { + throw new IllegalArgumentException("A property value must be specified."); + } + + properties.put(name, value); + } + + void setProperties(Map properties) { + if (properties != null) { + this.properties = new HashMap<>(properties); + } + } + + /** + * Removes a custom element from the model. + * + * @param element the CustomElement object to remove + */ + void remove(CustomElement element) { + removeElement(element); + customElements.remove(element); + } + + /** + * Removes a person from the model. + * + * @param person the Person object to remove + */ + void remove(Person person) { + removeElement(person); + people.remove(person); + } + + /** + * Removes a software system from the model. + * + * @param softwareSystem the SoftwareSystem object to remove + */ + void remove(SoftwareSystem softwareSystem) { + removeElement(softwareSystem); + softwareSystems.remove(softwareSystem); + } + + /** + * Removes a container from the model. + * + * @param container the Container object to remove + */ + void remove(Container container) { + removeElement(container); + container.getSoftwareSystem().remove(container); + } + + /** + * Removes a component from the model. + * + * @param component the Component object to remove + */ + void remove(Component component) { + removeElement(component); + component.getContainer().remove(component); + } + + /** + * Removes a software system instance from the model. + * + * @param softwareSystemInstance the SoftwareSystemInstance object to remove + */ + void remove(SoftwareSystemInstance softwareSystemInstance) { + removeElement(softwareSystemInstance); + + Set deploymentNodes = getElements().stream().filter(e -> e instanceof DeploymentNode).map(e -> (DeploymentNode)e).collect(Collectors.toSet()); + for (DeploymentNode deploymentNode : deploymentNodes) { + deploymentNode.remove(softwareSystemInstance); + } + } + + /** + * Removes a container instance from the model. + * + * @param containerInstance the ContainerInstance object to remove + */ + void remove(ContainerInstance containerInstance) { + removeElement(containerInstance); + + Set deploymentNodes = getElements().stream().filter(e -> e instanceof DeploymentNode).map(e -> (DeploymentNode)e).collect(Collectors.toSet()); + for (DeploymentNode deploymentNode : deploymentNodes) { + deploymentNode.remove(containerInstance); + } + } + + /** + * Removes a deployment node from the model. + * + * @param deploymentNode the DeploymentNode object to remove + */ + void remove(DeploymentNode deploymentNode) { + removeElement(deploymentNode); + + if (deploymentNode.getParent() == null) { + deploymentNodes.remove(deploymentNode); + } else { + ((DeploymentNode)deploymentNode.getParent()).remove(deploymentNode); + } + } + + private void removeElement(Element element) { + if (element == null) { + throw new IllegalArgumentException("An element must be specified."); + } + + // remove any relationships to/from the element + for (Relationship relationship : getRelationships()) { + if (relationship.getSource() == element || relationship.getDestination() == element) { + remove(relationship); + } + } + + elementsById.remove(element.getId()); + elements.remove(element); + } + + /** + * Removes a relationship from the model. + * + * @param relationship the Relationship to remove + */ + void remove(Relationship relationship) { + removeRelationshipFromInternalStructures(relationship); + relationship.getSource().remove(relationship); + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/ModelItem.java b/structurizr-core/src/main/java/com/structurizr/model/ModelItem.java similarity index 62% rename from structurizr-core/src/com/structurizr/model/ModelItem.java rename to structurizr-core/src/main/java/com/structurizr/model/ModelItem.java index 3ebc76107..9cba7e71a 100644 --- a/structurizr-core/src/com/structurizr/model/ModelItem.java +++ b/structurizr-core/src/main/java/com/structurizr/model/ModelItem.java @@ -1,8 +1,10 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.PerspectivesHolder; import com.structurizr.PropertyHolder; import com.structurizr.util.StringUtils; +import com.structurizr.util.TagUtils; import com.structurizr.util.Url; import java.util.*; @@ -10,14 +12,14 @@ /** * The base class for elements and relationships. */ -public abstract class ModelItem implements PropertyHolder { +public abstract class ModelItem implements PropertyHolder, PerspectivesHolder, Comparable { private String id = ""; - private Set tags = new LinkedHashSet<>(); + private final Set tags = new LinkedHashSet<>(); private String url; private Map properties = new HashMap<>(); - private Set perspectives = new HashSet<>(); + private final Set perspectives = new TreeSet<>(); @JsonIgnore public abstract String getCanonicalName(); @@ -34,7 +36,7 @@ public String getId() { return id; } - void setId(String id) { + protected void setId(String id) { this.id = id; } @@ -45,20 +47,7 @@ void setId(String id) { * or an empty string if there are no tags */ public String getTags() { - Set setOfTags = getTagsAsSet(); - - if (setOfTags.isEmpty()) { - return ""; - } - - StringBuilder buf = new StringBuilder(); - for (String tag : setOfTags) { - buf.append(tag); - buf.append(","); - } - - String tagsAsString = buf.toString(); - return tagsAsString.substring(0, tagsAsString.length()-1); + return TagUtils.toString(getTagsAsSet()); } @JsonIgnore @@ -116,6 +105,17 @@ public boolean hasTag(String tag) { return getTagsAsSet().contains(tag.trim()); } + /** + * Determines whether this model item has the given property with the given value. + * + * @param name the name of the property + * @param value the value of the property + * @return true if the named property is present with the given value, false otherwise + */ + public boolean hasProperty(String name, String value) { + return getProperties().containsKey(name) && getProperties().get(name).equals(value); + } + /** * Gets the URL where more information about this item can be found. * @@ -134,10 +134,16 @@ public String getUrl() { public void setUrl(String url) { if (StringUtils.isNullOrEmpty(url)) { this.url = null; - } else if (Url.isUrl(url)) { - this.url = url; } else { - throw new IllegalArgumentException(url + " is not a valid URL."); + if (url.startsWith(Url.INTRA_WORKSPACE_URL_PREFIX)) { + this.url = url; + } else if (url.matches(Url.INTER_WORKSPACE_URL_REGEX)) { + this.url = url; + } else if (Url.isUrl(url)) { + this.url = url; + } else { + throw new IllegalArgumentException(url + " is not a valid URL."); + } } } @@ -147,7 +153,7 @@ public void setUrl(String url) { * @return a Map (String, String) (empty if there are no properties) */ public Map getProperties() { - return new HashMap<>(properties); + return Collections.unmodifiableMap(properties); } /** @@ -168,6 +174,17 @@ public void addProperty(String name, String value) { properties.put(name, value); } + /** + * Adds a collection of name-value pair properties to this model item. + * + * @param properties Map of properties + */ + public void addProperties(Map properties) { + for (String key : properties.keySet()) { + this.addProperty(key, properties.get(key)); + } + } + void setProperties(Map properties) { if (properties != null) { this.properties = new HashMap<>(properties); @@ -180,7 +197,7 @@ void setProperties(Map properties) { * @return a Set of Perspective objects (empty if there are none) */ public Set getPerspectives() { - return new HashSet<>(perspectives); + return new TreeSet<>(perspectives); } void setPerspectives(Set perspectives) { @@ -197,11 +214,24 @@ void setPerspectives(Set perspectives) { * Adds a perspective to this model item. * * @param name the name of the perspective (e.g. "Security", must be unique) - * @param description a description of the perspective + * @param description the description of the perspective * @return a Perspective object * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already */ public Perspective addPerspective(String name, String description) { + return addPerspective(name, description, ""); + } + + /** + * Adds a perspective to this model item. + * + * @param name the name of the perspective (e.g. "Technical Debt", must be unique) + * @param description the description of the perspective (e.g. "High") + * @param value the value of the perspective + * @return a Perspective object + * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already + */ + public Perspective addPerspective(String name, String description, String value) { if (StringUtils.isNullOrEmpty(name)) { throw new IllegalArgumentException("A name must be specified."); } @@ -210,14 +240,37 @@ public Perspective addPerspective(String name, String description) { throw new IllegalArgumentException("A description must be specified."); } - if (perspectives.stream().filter(p -> p.getName().equals(name)).count() > 0) { + if (perspectives.stream().anyMatch(p -> p.getName().equals(name))) { throw new IllegalArgumentException("A perspective named \"" + name + "\" already exists."); } - Perspective perspective = new Perspective(name, description); + Perspective perspective = new Perspective(name, description, value); perspectives.add(perspective); return perspective; } + /** + * Adds a collection of name-value pair properties to this model item. + * + * @param perspectives Set of Perspective objects + */ + public void addPerspectives(Set perspectives) { + for (Perspective perspective : perspectives) { + addPerspective(perspective.getName(), perspective.getDescription(), perspective.getValue()); + } + } + + @Override + public int compareTo(ModelItem modelItem) { + try { + int id1 = Integer.parseInt(getId()); + int id2 = Integer.parseInt(modelItem.getId()); + + return id1 - id2; + } catch (NumberFormatException nfe) { + return getId().compareTo(modelItem.getId()); + } + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Person.java b/structurizr-core/src/main/java/com/structurizr/model/Person.java similarity index 87% rename from structurizr-core/src/com/structurizr/model/Person.java rename to structurizr-core/src/main/java/com/structurizr/model/Person.java index d4f67d3ba..84d445b1c 100644 --- a/structurizr-core/src/com/structurizr/model/Person.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Person.java @@ -12,8 +12,6 @@ */ public final class Person extends StaticStructureElement { - private Location location = Location.Unspecified; - @Override @JsonIgnore public Element getParent() { @@ -23,23 +21,6 @@ public Element getParent() { Person() { } - /** - * Gets the location of this person. - * - * @return a Location - */ - public Location getLocation() { - return location; - } - - public void setLocation(Location location) { - if (location != null) { - this.location = location; - } else { - this.location = Location.Unspecified; - } - } - @Override public String getCanonicalName() { return new CanonicalNameGenerator().generate(this); diff --git a/structurizr-core/src/com/structurizr/model/Perspective.java b/structurizr-core/src/main/java/com/structurizr/model/Perspective.java similarity index 69% rename from structurizr-core/src/com/structurizr/model/Perspective.java rename to structurizr-core/src/main/java/com/structurizr/model/Perspective.java index 985944674..fdb9e95f5 100644 --- a/structurizr-core/src/com/structurizr/model/Perspective.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Perspective.java @@ -4,18 +4,19 @@ * Represents an architectural perspective, that can be applied to elements and relationships. * See https://www.viewpoints-and-perspectives.info/home/perspectives/ for more details of this concept. */ -public final class Perspective { +public final class Perspective implements Comparable { private String name; private String description; - // todo link this perspective to architecture decision records + private String value; Perspective() { } - Perspective(String name, String description) { + public Perspective(String name, String description, String value) { this.name = name; this.description = description; + this.value = value; } /** @@ -44,6 +45,19 @@ void setDescription(String description) { this.description = description; } + /** + * Gets the value of this perspective. + * + * @return the value of this perspective, as a String + */ + public String getValue() { + return value; + } + + void setValue(String value) { + this.value = value; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -59,4 +73,9 @@ public int hashCode() { return getName().hashCode(); } + @Override + public int compareTo(Perspective perspective) { + return getName().compareTo(perspective.getName()); + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/model/Relationship.java b/structurizr-core/src/main/java/com/structurizr/model/Relationship.java similarity index 98% rename from structurizr-core/src/com/structurizr/model/Relationship.java rename to structurizr-core/src/main/java/com/structurizr/model/Relationship.java index 61b3521e0..e8e0fee0f 100644 --- a/structurizr-core/src/com/structurizr/model/Relationship.java +++ b/structurizr-core/src/main/java/com/structurizr/model/Relationship.java @@ -1,7 +1,6 @@ package com.structurizr.model; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.structurizr.util.Url; import java.util.Collections; import java.util.LinkedHashSet; @@ -114,7 +113,7 @@ void setDescription(String description) { } /** - * Gets the technology associated with this relationship (e.g. HTTPS, JDBC, etc). + * Gets the technology associated with this relationship (e.g. HTTPS, etc). * * @return the technology as a String, * or null if a technology is not specified diff --git a/structurizr-core/src/com/structurizr/model/SequentialIntegerIdGeneratorStrategy.java b/structurizr-core/src/main/java/com/structurizr/model/SequentialIntegerIdGeneratorStrategy.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/SequentialIntegerIdGeneratorStrategy.java rename to structurizr-core/src/main/java/com/structurizr/model/SequentialIntegerIdGeneratorStrategy.java diff --git a/structurizr-core/src/com/structurizr/model/SoftwareSystem.java b/structurizr-core/src/main/java/com/structurizr/model/SoftwareSystem.java similarity index 88% rename from structurizr-core/src/com/structurizr/model/SoftwareSystem.java rename to structurizr-core/src/main/java/com/structurizr/model/SoftwareSystem.java index b59ae5075..13d7a450c 100644 --- a/structurizr-core/src/com/structurizr/model/SoftwareSystem.java +++ b/structurizr-core/src/main/java/com/structurizr/model/SoftwareSystem.java @@ -7,18 +7,16 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.Arrays; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; +import java.util.TreeSet; /** * Represents a "software system" in the C4 model. */ public final class SoftwareSystem extends StaticStructureElement implements Documentable { - private Location location = Location.Unspecified; - - private Set containers = new LinkedHashSet<>(); + private Set containers = new TreeSet<>(); private Documentation documentation = new Documentation(); @@ -36,28 +34,6 @@ public Element getParent() { SoftwareSystem() { } - /** - * Gets the location of this software system. - * - * @return a Location instance - */ - public Location getLocation() { - return location; - } - - /** - * Sets the location of this software system. - * - * @param location a Location instance - */ - public void setLocation(Location location) { - if (location != null) { - this.location = location; - } else { - this.location = Location.Unspecified; - } - } - void add(Container container) { containers.add(container); } @@ -69,15 +45,25 @@ void add(Container container) { */ @Nonnull public Set getContainers() { - return new HashSet<>(containers); + return new TreeSet<>(containers); } void setContainers(Set containers) { if (containers != null) { - this.containers = new HashSet<>(containers); + this.containers = new TreeSet<>(containers); } } + /** + * Determines whether this software system has any containers. + * + * @return true if it has containers, false otherwise + */ + @JsonIgnore + public boolean hasContainers() { + return !containers.isEmpty(); + } + /** * Adds a container with the specified name. * @@ -117,6 +103,10 @@ public Container addContainer(@Nonnull String name, String description, String t return getModel().addContainer(this, name, description, technology); } + void remove(Container container) { + containers.remove(container); + } + /** * Gets the container with the specified name. * diff --git a/structurizr-core/src/com/structurizr/model/SoftwareSystemInstance.java b/structurizr-core/src/main/java/com/structurizr/model/SoftwareSystemInstance.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/SoftwareSystemInstance.java rename to structurizr-core/src/main/java/com/structurizr/model/SoftwareSystemInstance.java diff --git a/structurizr-core/src/com/structurizr/model/StaticStructureElement.java b/structurizr-core/src/main/java/com/structurizr/model/StaticStructureElement.java similarity index 99% rename from structurizr-core/src/com/structurizr/model/StaticStructureElement.java rename to structurizr-core/src/main/java/com/structurizr/model/StaticStructureElement.java index 56d7b055e..ff6566249 100644 --- a/structurizr-core/src/com/structurizr/model/StaticStructureElement.java +++ b/structurizr-core/src/main/java/com/structurizr/model/StaticStructureElement.java @@ -1,7 +1,5 @@ package com.structurizr.model; -import com.structurizr.util.StringUtils; - import javax.annotation.Nonnull; import javax.annotation.Nullable; diff --git a/structurizr-core/src/com/structurizr/model/StaticStructureElementInstance.java b/structurizr-core/src/main/java/com/structurizr/model/StaticStructureElementInstance.java similarity index 95% rename from structurizr-core/src/com/structurizr/model/StaticStructureElementInstance.java rename to structurizr-core/src/main/java/com/structurizr/model/StaticStructureElementInstance.java index e79c7dab4..d7c5d3ae7 100644 --- a/structurizr-core/src/com/structurizr/model/StaticStructureElementInstance.java +++ b/structurizr-core/src/main/java/com/structurizr/model/StaticStructureElementInstance.java @@ -5,10 +5,9 @@ import com.structurizr.util.Url; import javax.annotation.Nonnull; -import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.Set; +import java.util.TreeSet; /** * Represents a deployment instance of a {@link SoftwareSystem} or {@link Container}, which can be added to a {@link DeploymentNode}. @@ -18,9 +17,9 @@ public abstract class StaticStructureElementInstance extends DeploymentElement { private static final int DEFAULT_HEALTH_CHECK_INTERVAL_IN_SECONDS = 60; private static final long DEFAULT_HEALTH_CHECK_TIMEOUT_IN_MILLISECONDS = 0; - private Set deploymentGroups = new HashSet<>(); + private Set deploymentGroups = new TreeSet<>(); private int instanceId; - private Set healthChecks = new HashSet<>(); + private Set healthChecks = new TreeSet<>(); StaticStructureElementInstance() { } @@ -54,15 +53,15 @@ public Set getDeploymentGroups() { if (deploymentGroups.isEmpty()) { return Collections.singleton(DEFAULT_DEPLOYMENT_GROUP); } else { - return new HashSet<>(deploymentGroups); + return new TreeSet<>(deploymentGroups); } } void setDeploymentGroups(Set deploymentGroups) { if (deploymentGroups != null) { - this.deploymentGroups = new HashSet<>(deploymentGroups); + this.deploymentGroups = new TreeSet<>(deploymentGroups); } else { - this.deploymentGroups = new HashSet<>(); + this.deploymentGroups = new TreeSet<>(); } } @@ -124,7 +123,7 @@ public void setName(String name) { */ @Nonnull public Set getHealthChecks() { - return new HashSet<>(healthChecks); + return new TreeSet<>(healthChecks); } void setHealthChecks(Set healthChecks) { diff --git a/structurizr-core/src/com/structurizr/model/Tags.java b/structurizr-core/src/main/java/com/structurizr/model/Tags.java similarity index 100% rename from structurizr-core/src/com/structurizr/model/Tags.java rename to structurizr-core/src/main/java/com/structurizr/model/Tags.java diff --git a/structurizr-core/src/main/java/com/structurizr/util/FeatureNotEnabledException.java b/structurizr-core/src/main/java/com/structurizr/util/FeatureNotEnabledException.java new file mode 100644 index 000000000..9e75481c7 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/util/FeatureNotEnabledException.java @@ -0,0 +1,13 @@ +package com.structurizr.util; + +public final class FeatureNotEnabledException extends RuntimeException { + + public FeatureNotEnabledException(String feature) { + super("Feature " + feature + " is not enabled"); + } + + public FeatureNotEnabledException(String feature, String message) { + super(String.format("%s (feature %s is not enabled)", message, feature)); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/util/Features.java b/structurizr-core/src/main/java/com/structurizr/util/Features.java new file mode 100644 index 000000000..44c615cfd --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/util/Features.java @@ -0,0 +1,26 @@ +package com.structurizr.util; + +import java.util.HashMap; +import java.util.Map; + +public class Features { + + private final Map features = new HashMap<>(); + + public void enable(String feature) { + features.put(feature, true); + } + + public void disable(String feature) { + features.put(feature, false); + } + + public void configure(String feature, boolean enabled) { + features.put(feature, enabled); + } + + public boolean isEnabled(String feature) { + return features.getOrDefault(feature, false); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/util/ImageUtils.java b/structurizr-core/src/main/java/com/structurizr/util/ImageUtils.java new file mode 100644 index 000000000..65a9f8260 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/util/ImageUtils.java @@ -0,0 +1,165 @@ +package com.structurizr.util; + +import javax.annotation.Nonnull; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URLConnection; +import java.nio.file.Files; +import java.util.Base64; + +/** + * Some utility methods for dealing with images as files and data URIs. + */ +public class ImageUtils { + + public static final String DATA_URI_PREFIX = "data:"; + public static final String DATA_URI_IMAGE_PNG = "data:image/png;base64,"; + public static final String DATA_URI_IMAGE_JPG = "data:image/jpeg;base64,"; + public static final String DATA_URI_IMAGE_SVG = "data:image/svg+xml;"; + + public static final String CONTENT_TYPE_IMAGE_PNG = "image/png"; + public static final String CONTENT_TYPE_IMAGE_JPG = "image/jpeg"; + public static final String CONTENT_TYPE_IMAGE_SVG = "image/svg+xml"; + + /** + * Gets the content type of the specified file representing an image. + * + * @param file a File pointing to an image + * @return a content type (e.g. "image/png") + * @throws IOException if there is an error reading the file + */ + public static String getContentType(@Nonnull File file) throws IOException { + if (file == null) { + throw new IllegalArgumentException("A file must be specified."); + } else if (!file.exists()) { + throw new IllegalArgumentException(file.getCanonicalPath() + " does not exist."); + } else if (!file.isFile()) { + throw new IllegalArgumentException(file.getCanonicalPath() + " is not a file."); + } + + String contentType = URLConnection.guessContentTypeFromName(file.getName()); + if (contentType == null || !contentType.startsWith("image/")) { + throw new IllegalArgumentException(file.getCanonicalPath() + " is not a supported image file."); + } + + return contentType; + } + + /** + * Gets the content type of the specified data URI representing an image. + * + * @param dataUri a data URI representing an image + * @return a content type (e.g. "image/png") + */ + public static String getContentTypeFromDataUri(String dataUri) { + if (StringUtils.isNullOrEmpty(dataUri)) { + throw new IllegalArgumentException("A data URI must be specified."); + } + + if (dataUri.startsWith(DATA_URI_IMAGE_PNG)) { + return CONTENT_TYPE_IMAGE_PNG; + } else if (dataUri.startsWith(DATA_URI_IMAGE_JPG)) { + return CONTENT_TYPE_IMAGE_JPG; + } else if (dataUri.startsWith(DATA_URI_IMAGE_SVG)) { + return CONTENT_TYPE_IMAGE_SVG; + } + + return null; + } + + /** + * Gets the content of an image as a Base64 encoded string. + * + * @param file a File pointing to an image + * @return a Base64 encoded version of that image + * @throws IOException if there is an error reading the file + */ + public static String getImageAsBase64(@Nonnull File file) throws IOException { + String contentType = getContentType(file); + + if (ImageUtils.CONTENT_TYPE_IMAGE_SVG.equalsIgnoreCase(contentType)) { + return Base64.getEncoder().encodeToString(Files.readAllBytes(file.toPath())); + } + + BufferedImage bufferedImage = ImageIO.read(file); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(bufferedImage, contentType.split("/")[1], bos); + byte[] imageBytes = bos.toByteArray(); + + return Base64.getEncoder().encodeToString(imageBytes); + } + + /** + * Gets the content of an image as a data URI; e.g. "data:image/png;base64,iVBORw0KGgoAA..." + * + * @param file a File pointing to an image + * @return a data URI + * @throws IOException if there is an error reading the file + */ + public static String getImageAsDataUri(File file) throws IOException { + String contentType = getContentType(file); + String base64Content = getImageAsBase64(file); + + return DATA_URI_PREFIX + contentType + ";base64," + base64Content; + } + + /** + * Converts an SVG string to a data URI. + * + * @param svg the SVG string + * @return a data URI + */ + public static String getSvgAsDataUri(String svg) { + return DATA_URI_PREFIX + CONTENT_TYPE_IMAGE_SVG + ";base64," + Base64.getEncoder().encodeToString(svg.getBytes()); + } + + /** + * Converts an PNG to a data URI. + * + * @param png the PNG as a byte array + * @return a data URI + */ + public static String getPngAsDataUri(byte[] png) { + return DATA_URI_PREFIX + CONTENT_TYPE_IMAGE_PNG + ";base64," + Base64.getEncoder().encodeToString(png); + } + + public static void validateImage(String imageDescriptor) { + if (StringUtils.isNullOrEmpty(imageDescriptor)) { + return; + } + + imageDescriptor = imageDescriptor.trim(); + + if (Url.isUrl(imageDescriptor)) { + // all good + return; + } + + if (imageDescriptor.toLowerCase().endsWith(".png") || imageDescriptor.toLowerCase().endsWith(".jpg") || imageDescriptor.toLowerCase().endsWith(".jpeg") || imageDescriptor.toLowerCase().endsWith(".gif") || imageDescriptor.toLowerCase().endsWith(".svg")) { + // it's just a filename + return; + } + + if (imageDescriptor.startsWith(DATA_URI_PREFIX)) { + if (ImageUtils.isSupportedDataUri(imageDescriptor)) { + // it's a PNG/JPG/SVG data URI + return; + } else { + // it's a data URI, but not supported + throw new IllegalArgumentException("Only PNG, JPG, and SVG data URIs are supported: " + imageDescriptor); + } + } + + throw new IllegalArgumentException("Expected a URL or data URI"); + } + + public static boolean isSupportedDataUri(String uri) { + return uri.startsWith(DATA_URI_IMAGE_PNG) || + uri.startsWith(DATA_URI_IMAGE_JPG) || + uri.startsWith(DATA_URI_IMAGE_SVG); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/util/MapUtils.java b/structurizr-core/src/main/java/com/structurizr/util/MapUtils.java similarity index 100% rename from structurizr-core/src/com/structurizr/util/MapUtils.java rename to structurizr-core/src/main/java/com/structurizr/util/MapUtils.java diff --git a/structurizr-core/src/com/structurizr/util/StringUtils.java b/structurizr-core/src/main/java/com/structurizr/util/StringUtils.java similarity index 100% rename from structurizr-core/src/com/structurizr/util/StringUtils.java rename to structurizr-core/src/main/java/com/structurizr/util/StringUtils.java diff --git a/structurizr-core/src/main/java/com/structurizr/util/TagUtils.java b/structurizr-core/src/main/java/com/structurizr/util/TagUtils.java new file mode 100644 index 000000000..c41056584 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/util/TagUtils.java @@ -0,0 +1,22 @@ +package com.structurizr.util; + +import java.util.Collection; + +public class TagUtils { + + public static String toString(Collection tags) { + if (tags.isEmpty()) { + return ""; + } + + StringBuilder buf = new StringBuilder(); + for (String tag : tags) { + buf.append(tag); + buf.append(","); + } + + String tagsAsString = buf.toString(); + return tagsAsString.substring(0, tagsAsString.length()-1); + } + +} diff --git a/structurizr-core/src/main/java/com/structurizr/util/Url.java b/structurizr-core/src/main/java/com/structurizr/util/Url.java new file mode 100644 index 000000000..ee26a38bb --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/util/Url.java @@ -0,0 +1,56 @@ +package com.structurizr.util; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Utilities for dealing with URLs. + */ +public class Url { + + private static final String HTTPS_PROTOCOL = "https://"; + private static final String HTTP_PROTOCOL = "http://"; + + public static final String INTRA_WORKSPACE_URL_PREFIX = "{workspace}"; + public static final String INTER_WORKSPACE_URL_REGEX = "\\{workspace:\\d+\\}.*"; + + /** + * Determines whether the supplied string is a valid URL. + * + * @param urlAsString the URL, as a String + * @return true if the URL is valid, false otherwise + */ + public static boolean isUrl(String urlAsString) { + if (!StringUtils.isNullOrEmpty(urlAsString)) { + try { + new URL(urlAsString); + return true; + } catch (MalformedURLException murle) { + return false; + } + } + + return false; + } + + /** + * Determines whether the supplied string is a valid HTTPS URL. + * + * @param urlAsString the URL, as a String + * @return true if the URL is valid, false otherwise + */ + public static boolean isHttpsUrl(String urlAsString) { + return isUrl(urlAsString) && urlAsString.toLowerCase().startsWith(HTTPS_PROTOCOL); + } + + /** + * Determines whether the supplied string is a valid HTTP URL. + * + * @param urlAsString the URL, as a String + * @return true if the URL is valid, false otherwise + */ + public static boolean isHttpUrl(String urlAsString) { + return isUrl(urlAsString) && urlAsString.toLowerCase().startsWith(HTTP_PROTOCOL); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/LandscapeWorkspaceScopeValidator.java b/structurizr-core/src/main/java/com/structurizr/validation/LandscapeWorkspaceScopeValidator.java new file mode 100644 index 000000000..feb6b4abd --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/LandscapeWorkspaceScopeValidator.java @@ -0,0 +1,26 @@ +package com.structurizr.validation; + +import com.structurizr.Workspace; +import com.structurizr.model.Model; +import com.structurizr.model.SoftwareSystem; + +/** + * Validates that the workspace does not define containers and software system level documentation. + */ +public class LandscapeWorkspaceScopeValidator implements WorkspaceScopeValidator { + + @Override + public void validate(Workspace workspace) throws WorkspaceScopeValidationException { + Model model = workspace.getModel(); + for (SoftwareSystem softwareSystem : model.getSoftwareSystems()) { + if (softwareSystem.getContainers().size() > 0) { + throw new WorkspaceScopeValidationException("Workspace is landscape scoped, but the software system named " + softwareSystem.getName() + " has containers."); + } + + if (!softwareSystem.getDocumentation().isEmpty()) { + throw new WorkspaceScopeValidationException("Workspace is landscape scoped, but the software system named " + softwareSystem.getName() + " has documentation."); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/SoftwareSystemWorkspaceScopeValidator.java b/structurizr-core/src/main/java/com/structurizr/validation/SoftwareSystemWorkspaceScopeValidator.java new file mode 100644 index 000000000..d2f7ce32b --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/SoftwareSystemWorkspaceScopeValidator.java @@ -0,0 +1,21 @@ +package com.structurizr.validation; + +import com.structurizr.Workspace; +import com.structurizr.model.Model; + +/** + * Validates that the workspace only defines detail (containers and documentation) for a single software system. + */ +public class SoftwareSystemWorkspaceScopeValidator implements WorkspaceScopeValidator { + + @Override + public void validate(Workspace workspace) throws WorkspaceScopeValidationException { + Model model = workspace.getModel(); + long softwareSystemsWithContainersOrDocumentation = model.getSoftwareSystems().stream().filter(ss -> ss.getContainers().size() > 0 || !ss.getDocumentation().isEmpty()).count(); + + if (softwareSystemsWithContainersOrDocumentation > 1) { + throw new WorkspaceScopeValidationException("Workspace is software system scoped, but multiple software systems have containers and/or documentation defined."); + } + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/UndefinedWorkspaceScopeValidator.java b/structurizr-core/src/main/java/com/structurizr/validation/UndefinedWorkspaceScopeValidator.java new file mode 100644 index 000000000..ffd4d3865 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/UndefinedWorkspaceScopeValidator.java @@ -0,0 +1,12 @@ +package com.structurizr.validation; + +import com.structurizr.Workspace; + +public class UndefinedWorkspaceScopeValidator implements WorkspaceScopeValidator { + + @Override + public void validate(Workspace workspace) throws WorkspaceScopeValidationException { + // no-op + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidationException.java b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidationException.java new file mode 100644 index 000000000..5183f3d3b --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidationException.java @@ -0,0 +1,9 @@ +package com.structurizr.validation; + +public class WorkspaceScopeValidationException extends Exception { + + public WorkspaceScopeValidationException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidator.java b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidator.java new file mode 100644 index 000000000..c54361736 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidator.java @@ -0,0 +1,9 @@ +package com.structurizr.validation; + +import com.structurizr.Workspace; + +public interface WorkspaceScopeValidator { + + void validate(Workspace workspace) throws WorkspaceScopeValidationException; + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidatorFactory.java b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidatorFactory.java new file mode 100644 index 000000000..ce011e601 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/validation/WorkspaceScopeValidatorFactory.java @@ -0,0 +1,18 @@ +package com.structurizr.validation; + +import com.structurizr.Workspace; +import com.structurizr.configuration.WorkspaceScope; + +public final class WorkspaceScopeValidatorFactory { + + public static WorkspaceScopeValidator getValidator(Workspace workspace) { + if (workspace.getConfiguration().getScope() == WorkspaceScope.Landscape) { + return new LandscapeWorkspaceScopeValidator(); + } else if (workspace.getConfiguration().getScope() == WorkspaceScope.SoftwareSystem) { + return new SoftwareSystemWorkspaceScopeValidator(); + } else { + return new UndefinedWorkspaceScopeValidator(); + } + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/view/AbstractStyle.java b/structurizr-core/src/main/java/com/structurizr/view/AbstractStyle.java new file mode 100644 index 000000000..168519a8e --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/AbstractStyle.java @@ -0,0 +1,114 @@ +package com.structurizr.view; + +import com.structurizr.PropertyHolder; +import com.structurizr.util.StringUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public abstract class AbstractStyle implements PropertyHolder, Comparable { + + private String tag; + private ColorScheme colorScheme = null; + private Map properties = new HashMap<>(); + + AbstractStyle() { + } + + AbstractStyle(String tag) { + this.tag = tag; + } + + AbstractStyle(String tag, ColorScheme colorScheme) { + this.tag = tag; + this.colorScheme = colorScheme; + } + + /** + * The tag to which this style applies. + * + * @return the tag, as a String + */ + public String getTag() { + return tag; + } + + void setTag(String tag) { + this.tag = tag; + } + + /** + * Gets the color scheme of this style. + * + * @return a ColorScheme, or null if not specified (i.e. applies to light and dark). + */ + public ColorScheme getColorScheme() { + return colorScheme; + } + + /** + * Sets the color scheme of this style. + * + * @param colorScheme a ColorScheme, or null if not specified (i.e. applies to light and dark). + */ + void setColorScheme(ColorScheme colorScheme) { + this.colorScheme = colorScheme; + } + + /** + * Gets the collection of name-value property pairs associated with this workspace, as a Map. + * + * @return a Map (String, String) (empty if there are no properties) + */ + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Adds a name-value pair property to this workspace. + * + * @param name the name of the property + * @param value the value of the property + */ + public void addProperty(String name, String value) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A property name must be specified."); + } + + if (StringUtils.isNullOrEmpty(value)) { + throw new IllegalArgumentException("A property value must be specified."); + } + + properties.put(name, value); + } + + void setProperties(Map properties) { + if (properties != null) { + this.properties = new HashMap<>(properties); + } + } + + @Override + public String toString() { + return this.tag + " (" + this.colorScheme + ")"; + } + + @Override + public int compareTo(AbstractStyle other) { + if (this.colorScheme == null && other.colorScheme == null) { + return this.tag.compareTo(other.tag); + } + + if (this.colorScheme == null) { + return -1; + } + + if (other.colorScheme == null) { + return 1; + } + + return (this.colorScheme + "/" + this.tag).compareTo(other.colorScheme + "/" + other.tag); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/view/AnimatedView.java b/structurizr-core/src/main/java/com/structurizr/view/AnimatedView.java new file mode 100644 index 000000000..6d0fa9e2c --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/AnimatedView.java @@ -0,0 +1,11 @@ +package com.structurizr.view; + +import javax.annotation.Nonnull; +import java.util.List; + +public interface AnimatedView { + + @Nonnull + List getAnimations(); + +} diff --git a/structurizr-core/src/com/structurizr/view/Animation.java b/structurizr-core/src/main/java/com/structurizr/view/Animation.java similarity index 75% rename from structurizr-core/src/com/structurizr/view/Animation.java rename to structurizr-core/src/main/java/com/structurizr/view/Animation.java index c15f89dd0..0e08bb070 100644 --- a/structurizr-core/src/com/structurizr/view/Animation.java +++ b/structurizr-core/src/main/java/com/structurizr/view/Animation.java @@ -3,8 +3,8 @@ import com.structurizr.model.Element; import com.structurizr.model.Relationship; -import java.util.HashSet; import java.util.Set; +import java.util.TreeSet; /** * A wrapper for a collection of animation steps. @@ -12,8 +12,8 @@ public final class Animation { private int order; - private Set elements = new HashSet<>(); - private Set relationships = new HashSet<>(); + private Set elements = new TreeSet<>(); + private Set relationships = new TreeSet<>(); Animation() { } @@ -39,22 +39,22 @@ void setOrder(int order) { } public Set getElements() { - return new HashSet<>(elements); + return new TreeSet<>(elements); } void setElements(Set elements) { if (elements != null) { - this.elements = new HashSet<>(elements); + this.elements = new TreeSet<>(elements); } } public Set getRelationships() { - return new HashSet<>(relationships); + return new TreeSet<>(relationships); } void setRelationships(Set relationships) { if (relationships != null) { - this.relationships = new HashSet<>(relationships); + this.relationships = new TreeSet<>(relationships); } } diff --git a/structurizr-core/src/com/structurizr/view/AutomaticLayout.java b/structurizr-core/src/main/java/com/structurizr/view/AutomaticLayout.java similarity index 86% rename from structurizr-core/src/com/structurizr/view/AutomaticLayout.java rename to structurizr-core/src/main/java/com/structurizr/view/AutomaticLayout.java index 8cf63a1c6..5b373d038 100644 --- a/structurizr-core/src/com/structurizr/view/AutomaticLayout.java +++ b/structurizr-core/src/main/java/com/structurizr/view/AutomaticLayout.java @@ -11,6 +11,7 @@ public final class AutomaticLayout { private int nodeSeparation; private int edgeSeparation; private boolean vertices; + private boolean applied; AutomaticLayout() { } @@ -22,6 +23,7 @@ public final class AutomaticLayout { setNodeSeparation(nodeSeparation); setEdgeSeparation(edgeSeparation); setVertices(vertices); + setApplied(false); } /** @@ -118,6 +120,24 @@ void setVertices(boolean vertices) { this.vertices = vertices; } + /** + * Returns whether automatic layout has been applied. + * + * @return true if automatic layout has been applied, false otherwise + */ + public boolean isApplied() { + return applied; + } + + /** + * Sets whether automatic layout has been applied. + * + * @param applied true if automatic layout has been applied, false otherwise + */ + public void setApplied(boolean applied) { + this.applied = applied; + } + public enum Implementation { Graphviz, Dagre diff --git a/structurizr-core/src/com/structurizr/view/Border.java b/structurizr-core/src/main/java/com/structurizr/view/Border.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Border.java rename to structurizr-core/src/main/java/com/structurizr/view/Border.java diff --git a/structurizr-core/src/com/structurizr/view/Branding.java b/structurizr-core/src/main/java/com/structurizr/view/Branding.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Branding.java rename to structurizr-core/src/main/java/com/structurizr/view/Branding.java diff --git a/structurizr-core/src/main/java/com/structurizr/view/Color.java b/structurizr-core/src/main/java/com/structurizr/view/Color.java new file mode 100644 index 000000000..c876ca294 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/Color.java @@ -0,0 +1,173 @@ +package com.structurizr.view; + +import java.util.HashMap; +import java.util.Map; + +public class Color { + + private static final Map NAMED_COLORS = new HashMap<>(); + + public static boolean isHexColorCode(String colorAsString) { + return colorAsString != null && colorAsString.matches("^#[A-Fa-f0-9]{6}"); + } + + public static String fromColorNameToHexColorCode(String name) { + if (name != null) { + return NAMED_COLORS.getOrDefault(name.toLowerCase(), null); + } else { + return null; + } + } + + static { + NAMED_COLORS.put("aliceblue", "#F0F8FF"); + NAMED_COLORS.put("antiquewhite", "#FAEBD7"); + NAMED_COLORS.put("aqua", "#00FFFF"); + NAMED_COLORS.put("aquamarine", "#7FFFD4"); + NAMED_COLORS.put("azure", "#F0FFFF"); + NAMED_COLORS.put("beige", "#F5F5DC"); + NAMED_COLORS.put("bisque", "#FFE4C4"); + NAMED_COLORS.put("black", "#000000"); + NAMED_COLORS.put("blanchedalmond", "#FFEBCD"); + NAMED_COLORS.put("blue", "#0000FF"); + NAMED_COLORS.put("blueviolet", "#8A2BE2"); + NAMED_COLORS.put("brown", "#A52A2A"); + NAMED_COLORS.put("burlywood", "#DEB887"); + NAMED_COLORS.put("cadetblue", "#5F9EA0"); + NAMED_COLORS.put("chartreuse", "#7FFF00"); + NAMED_COLORS.put("chocolate", "#D2691E"); + NAMED_COLORS.put("coral", "#FF7F50"); + NAMED_COLORS.put("cornflowerblue", "#6495ED"); + NAMED_COLORS.put("cornsilk", "#FFF8DC"); + NAMED_COLORS.put("crimson", "#DC143C"); + NAMED_COLORS.put("cyan", "#00FFFF"); + NAMED_COLORS.put("darkblue", "#00008B"); + NAMED_COLORS.put("darkcyan", "#008B8B"); + NAMED_COLORS.put("darkgoldenrod", "#B8860B"); + NAMED_COLORS.put("darkgray", "#A9A9A9"); + NAMED_COLORS.put("darkgreen", "#006400"); + NAMED_COLORS.put("darkgrey", "#A9A9A9"); + NAMED_COLORS.put("darkkhaki", "#BDB76B"); + NAMED_COLORS.put("darkmagenta", "#8B008B"); + NAMED_COLORS.put("darkolivegreen", "#556B2F"); + NAMED_COLORS.put("darkorange", "#FF8C00"); + NAMED_COLORS.put("darkorchid", "#9932CC"); + NAMED_COLORS.put("darkred", "#8B0000"); + NAMED_COLORS.put("darksalmon", "#E9967A"); + NAMED_COLORS.put("darkseagreen", "#8FBC8F"); + NAMED_COLORS.put("darkslateblue", "#483D8B"); + NAMED_COLORS.put("darkslategray", "#2F4F4F"); + NAMED_COLORS.put("darkslategrey", "#2F4F4F"); + NAMED_COLORS.put("darkturquoise", "#00CED1"); + NAMED_COLORS.put("darkviolet", "#9400D3"); + NAMED_COLORS.put("deeppink", "#FF1493"); + NAMED_COLORS.put("deepskyblue", "#00BFFF"); + NAMED_COLORS.put("dimgray", "#696969"); + NAMED_COLORS.put("dimgrey", "#696969"); + NAMED_COLORS.put("dodgerblue", "#1E90FF"); + NAMED_COLORS.put("firebrick", "#B22222"); + NAMED_COLORS.put("floralwhite", "#FFFAF0"); + NAMED_COLORS.put("forestgreen", "#228B22"); + NAMED_COLORS.put("fuchsia", "#FF00FF"); + NAMED_COLORS.put("gainsboro", "#DCDCDC"); + NAMED_COLORS.put("ghostwhite", "#F8F8FF"); + NAMED_COLORS.put("gold", "#FFD700"); + NAMED_COLORS.put("goldenrod", "#DAA520"); + NAMED_COLORS.put("gray", "#808080"); + NAMED_COLORS.put("green", "#008000"); + NAMED_COLORS.put("greenyellow", "#ADFF2F"); + NAMED_COLORS.put("grey", "#808080"); + NAMED_COLORS.put("honeydew", "#F0FFF0"); + NAMED_COLORS.put("hotpink", "#FF69B4"); + NAMED_COLORS.put("indianred", "#CD5C5C"); + NAMED_COLORS.put("indigo", "#4B0082"); + NAMED_COLORS.put("ivory", "#FFFFF0"); + NAMED_COLORS.put("khaki", "#F0E68C"); + NAMED_COLORS.put("lavender", "#E6E6FA"); + NAMED_COLORS.put("lavenderblush", "#FFF0F5"); + NAMED_COLORS.put("lawngreen", "#7CFC00"); + NAMED_COLORS.put("lemonchiffon", "#FFFACD"); + NAMED_COLORS.put("lightblue", "#ADD8E6"); + NAMED_COLORS.put("lightcoral", "#F08080"); + NAMED_COLORS.put("lightcyan", "#E0FFFF"); + NAMED_COLORS.put("lightgoldenrodyellow", "#FAFAD2"); + NAMED_COLORS.put("lightgray", "#D3D3D3"); + NAMED_COLORS.put("lightgreen", "#90EE90"); + NAMED_COLORS.put("lightgrey", "#D3D3D3"); + NAMED_COLORS.put("lightpink", "#FFB6C1"); + NAMED_COLORS.put("lightsalmon", "#FFA07A"); + NAMED_COLORS.put("lightseagreen", "#20B2AA"); + NAMED_COLORS.put("lightskyblue", "#87CEFA"); + NAMED_COLORS.put("lightslategray", "#778899"); + NAMED_COLORS.put("lightslategrey", "#778899"); + NAMED_COLORS.put("lightsteelblue", "#B0C4DE"); + NAMED_COLORS.put("lightyellow", "#FFFFE0"); + NAMED_COLORS.put("lime", "#00FF00"); + NAMED_COLORS.put("limegreen", "#32CD32"); + NAMED_COLORS.put("linen", "#FAF0E6"); + NAMED_COLORS.put("magenta", "#FF00FF"); + NAMED_COLORS.put("maroon", "#800000"); + NAMED_COLORS.put("mediumaquamarine", "#66CDAA"); + NAMED_COLORS.put("mediumblue", "#0000CD"); + NAMED_COLORS.put("mediumorchid", "#BA55D3"); + NAMED_COLORS.put("mediumpurple", "#9370DB"); + NAMED_COLORS.put("mediumseagreen", "#3CB371"); + NAMED_COLORS.put("mediumslateblue", "#7B68EE"); + NAMED_COLORS.put("mediumspringgreen", "#00FA9A"); + NAMED_COLORS.put("mediumturquoise", "#48D1CC"); + NAMED_COLORS.put("mediumvioletred", "#C71585"); + NAMED_COLORS.put("midnightblue", "#191970"); + NAMED_COLORS.put("mintcream", "#F5FFFA"); + NAMED_COLORS.put("mistyrose", "#FFE4E1"); + NAMED_COLORS.put("moccasin", "#FFE4B5"); + NAMED_COLORS.put("navajowhite", "#FFDEAD"); + NAMED_COLORS.put("navy", "#000080"); + NAMED_COLORS.put("oldlace", "#FDF5E6"); + NAMED_COLORS.put("olive", "#808000"); + NAMED_COLORS.put("olivedrab", "#6B8E23"); + NAMED_COLORS.put("orange", "#FFA500"); + NAMED_COLORS.put("orangered", "#FF4500"); + NAMED_COLORS.put("orchid", "#DA70D6"); + NAMED_COLORS.put("palegoldenrod", "#EEE8AA"); + NAMED_COLORS.put("palegreen", "#98FB98"); + NAMED_COLORS.put("paleturquoise", "#AFEEEE"); + NAMED_COLORS.put("palevioletred", "#DB7093"); + NAMED_COLORS.put("papayawhip", "#FFEFD5"); + NAMED_COLORS.put("peachpuff", "#FFDAB9"); + NAMED_COLORS.put("peru", "#CD853F"); + NAMED_COLORS.put("pink", "#FFC0CB"); + NAMED_COLORS.put("plum", "#DDA0DD"); + NAMED_COLORS.put("powderblue", "#B0E0E6"); + NAMED_COLORS.put("purple", "#800080"); + NAMED_COLORS.put("rebeccapurple", "#663399"); + NAMED_COLORS.put("red", "#FF0000"); + NAMED_COLORS.put("rosybrown", "#BC8F8F"); + NAMED_COLORS.put("royalblue", "#4169E1"); + NAMED_COLORS.put("saddlebrown", "#8B4513"); + NAMED_COLORS.put("salmon", "#FA8072"); + NAMED_COLORS.put("sandybrown", "#F4A460"); + NAMED_COLORS.put("seagreen", "#2E8B57"); + NAMED_COLORS.put("seashell", "#FFF5EE"); + NAMED_COLORS.put("sienna", "#A0522D"); + NAMED_COLORS.put("silver", "#C0C0C0"); + NAMED_COLORS.put("skyblue", "#87CEEB"); + NAMED_COLORS.put("slateblue", "#6A5ACD"); + NAMED_COLORS.put("slategray", "#708090"); + NAMED_COLORS.put("slategrey", "#708090"); + NAMED_COLORS.put("snow", "#FFFAFA"); + NAMED_COLORS.put("springgreen", "#00FF7F"); + NAMED_COLORS.put("steelblue", "#4682B4"); + NAMED_COLORS.put("tan", "#D2B48C"); + NAMED_COLORS.put("teal", "#008080"); + NAMED_COLORS.put("thistle", "#D8BFD8"); + NAMED_COLORS.put("tomato", "#FF6347"); + NAMED_COLORS.put("turquoise", "#40E0D0"); + NAMED_COLORS.put("violet", "#EE82EE"); + NAMED_COLORS.put("wheat", "#F5DEB3"); + NAMED_COLORS.put("white", "#FFFFFF"); + NAMED_COLORS.put("whitesmoke", "#F5F5F5"); + NAMED_COLORS.put("yellow", "#FFFF00"); + NAMED_COLORS.put("yellowgreen", "#9ACD32"); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/view/ColorScheme.java b/structurizr-core/src/main/java/com/structurizr/view/ColorScheme.java new file mode 100644 index 000000000..4548594bc --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/ColorScheme.java @@ -0,0 +1,11 @@ +package com.structurizr.view; + +/** + * Represents light or dark mode color schemes. + */ +public enum ColorScheme { + + Light, + Dark + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/ComponentView.java b/structurizr-core/src/main/java/com/structurizr/view/ComponentView.java similarity index 93% rename from structurizr-core/src/com/structurizr/view/ComponentView.java rename to structurizr-core/src/main/java/com/structurizr/view/ComponentView.java index 4dcf9a35c..c9168cee6 100644 --- a/structurizr-core/src/com/structurizr/view/ComponentView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ComponentView.java @@ -148,7 +148,7 @@ public void remove(Component component) { */ @Override public String getName() { - return getSoftwareSystem().getName() + " - " + getContainer().getName() + " - Components"; + return "Component View: " + getSoftwareSystem().getName() + " - " + getContainer().getName(); } /** @@ -156,6 +156,15 @@ public String getName() { */ @Override public void addDefaultElements() { + addDefaultElements(true); + } + + /** + * Adds the default set of elements and relationships to this view. + * + * @param greedy true (add all relationships) or false (adds relationships to/from the components in the scoped container only) + */ + public void addDefaultElements(boolean greedy) { for (Component component : getContainer().getComponents()) { add(component); @@ -169,6 +178,10 @@ public void addDefaultElements() { addNearestNeighbours(component, Person.class); addNearestNeighbours(component, SoftwareSystem.class); } + + if (!greedy) { + removeRelationshipsNotConnectedToElements(getContainer().getComponents()); + } } /** @@ -294,21 +307,13 @@ protected boolean canBeRemoved(Element element) { return true; } - /** - * Determines whether container boundaries should be visible for "external" components (those outside the container in scope). - * - * @return true if external container boundaries are visible, false otherwise - */ + @Deprecated public boolean getExternalContainerBoundariesVisible() { return externalContainerBoundariesVisible; } - /** - * Sets whether container boundaries should be visible for "external" components (those outside the container in scope). - * - * @param externalContainerBoundariesVisible true if external container boundaries should be visible, false otherwise - */ - public void setExternalSoftwareSystemBoundariesVisible(boolean externalContainerBoundariesVisible) { + @Deprecated + void setExternalSoftwareSystemBoundariesVisible(boolean externalContainerBoundariesVisible) { this.externalContainerBoundariesVisible = externalContainerBoundariesVisible; } diff --git a/structurizr-core/src/com/structurizr/view/Configuration.java b/structurizr-core/src/main/java/com/structurizr/view/Configuration.java similarity index 91% rename from structurizr-core/src/com/structurizr/view/Configuration.java rename to structurizr-core/src/main/java/com/structurizr/view/Configuration.java index dc5dfdd75..267c79ba3 100644 --- a/structurizr-core/src/com/structurizr/view/Configuration.java +++ b/structurizr-core/src/main/java/com/structurizr/view/Configuration.java @@ -1,15 +1,11 @@ package com.structurizr.view; import com.fasterxml.jackson.annotation.JsonGetter; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import com.structurizr.PropertyHolder; import com.structurizr.util.Url; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; /** * Configuration associated with how information in the workspace is rendered. @@ -38,16 +34,6 @@ public Styles getStyles() { return styles; } - @JsonIgnore - @Deprecated - public String getTheme() { - if (themes == null || themes.size() == 0) { - return null; - } - - return themes.get(0); - } - /** * Sets the theme used to render views. * @@ -209,16 +195,16 @@ public void setViewSortOrder(ViewSortOrder viewSortOrder) { } /** - * Gets the collection of name-value property pairs associated with this workspace, as a Map. + * Gets the collection of name-value property pairs, as a Map. * * @return a Map (String, String) (empty if there are no properties) */ public Map getProperties() { - return new HashMap<>(properties); + return Collections.unmodifiableMap(properties); } /** - * Adds a name-value pair property to this workspace. + * Adds a name-value pair property. * * @param name the name of the property * @param value the value of the property diff --git a/structurizr-core/src/com/structurizr/view/ContainerView.java b/structurizr-core/src/main/java/com/structurizr/view/ContainerView.java similarity index 91% rename from structurizr-core/src/com/structurizr/view/ContainerView.java rename to structurizr-core/src/main/java/com/structurizr/view/ContainerView.java index 8c8cad219..a5b3e4bf0 100644 --- a/structurizr-core/src/com/structurizr/view/ContainerView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ContainerView.java @@ -77,7 +77,7 @@ public void remove(Container container) { */ @Override public String getName() { - return getSoftwareSystem().getName() + " - Containers"; + return "Container View: " + getSoftwareSystem().getName(); } /** @@ -85,12 +85,25 @@ public String getName() { */ @Override public void addDefaultElements() { + addDefaultElements(true); + } + + /** + * Adds the default set of elements and relationships to this view. + * + * @param greedy true (add all relationships) or false (adds relationships to/from the containers in the scoped software system only) + */ + public void addDefaultElements(boolean greedy) { for (Container container : getSoftwareSystem().getContainers()) { add(container); addNearestNeighbours(container, CustomElement.class); addNearestNeighbours(container, Person.class); addNearestNeighbours(container, SoftwareSystem.class); } + + if (!greedy) { + removeRelationshipsNotConnectedToElements(getSoftwareSystem().getContainers()); + } } /** @@ -200,21 +213,13 @@ protected boolean canBeRemoved(Element element) { return true; } - /** - * Determines whether software system boundaries should be visible for "external" containers (those outside the software system in scope). - * - * @return true if external software system boundaries are visible, false otherwise - */ + @Deprecated public boolean getExternalSoftwareSystemBoundariesVisible() { return externalSoftwareSystemBoundariesVisible; } - /** - * Sets whether software system boundaries should be visible for "external" containers (those outside the software system in scope). - * - * @param externalSoftwareSystemBoundariesVisible true if external software system boundaries should be visible, false otherwise - */ - public void setExternalSoftwareSystemBoundariesVisible(boolean externalSoftwareSystemBoundariesVisible) { + @Deprecated + void setExternalSoftwareSystemBoundariesVisible(boolean externalSoftwareSystemBoundariesVisible) { this.externalSoftwareSystemBoundariesVisible = externalSoftwareSystemBoundariesVisible; } diff --git a/structurizr-core/src/com/structurizr/view/CustomView.java b/structurizr-core/src/main/java/com/structurizr/view/CustomView.java similarity index 79% rename from structurizr-core/src/com/structurizr/view/CustomView.java rename to structurizr-core/src/main/java/com/structurizr/view/CustomView.java index 025a37b25..35469259a 100644 --- a/structurizr-core/src/com/structurizr/view/CustomView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/CustomView.java @@ -7,6 +7,7 @@ import com.structurizr.model.Relationship; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -15,8 +16,9 @@ /** * Represents a custom view, containing custom elements. */ -public final class CustomView extends View { +public final class CustomView extends ModelView implements AnimatedView { + @Nonnull private List animations = new ArrayList<>(); private Model model; @@ -38,7 +40,7 @@ public final class CustomView extends View { */ @Override public String getName() { - return "Custom - " + getTitle(); + return "Custom View: " + getTitle(); } /** @@ -108,7 +110,7 @@ public void addAnimation(CustomElement... elements) { } } - if (elementsInThisAnimationStep.size() == 0) { + if (elementsInThisAnimationStep.isEmpty()) { throw new IllegalArgumentException("None of the specified elements exist in this view."); } @@ -124,11 +126,13 @@ public void addAnimation(CustomElement... elements) { animations.add(new Animation(animations.size() + 1, elementsInThisAnimationStep, relationshipsInThisAnimationStep)); } + @Nonnull + @Override public List getAnimations() { return new ArrayList<>(animations); } - void setAnimations(List animations) { + void setAnimations(@Nullable List animations) { if (animations != null) { this.animations = new ArrayList<>(animations); } else { @@ -136,6 +140,34 @@ void setAnimations(List animations) { } } + /** + * Adds the given custom element to this view, including relationships to/from that custom element. + * + * @param customElement the CustomElement to add + */ + public void add(@Nonnull CustomElement customElement) { + add(customElement, true); + } + + /** + * Adds the given custom element to this view. + * + * @param customElement the CustomElement to add + * @param addRelationships whether to add relationships to/from the custom element + */ + public void add(@Nonnull CustomElement customElement, boolean addRelationships) { + addElement(customElement, addRelationships); + } + + /** + * Removes the given custom element from this view. + * + * @param customElement the CustomElement to add + */ + public void remove(@Nonnull CustomElement customElement) { + removeElement(customElement); + } + /** * Adds the default set of elements to this view. */ @@ -156,4 +188,4 @@ public void addAllCustomElements() { }); } -} \ No newline at end of file +} diff --git a/structurizr-core/src/com/structurizr/view/DefaultLayoutMergeStrategy.java b/structurizr-core/src/main/java/com/structurizr/view/DefaultLayoutMergeStrategy.java similarity index 79% rename from structurizr-core/src/com/structurizr/view/DefaultLayoutMergeStrategy.java rename to structurizr-core/src/main/java/com/structurizr/view/DefaultLayoutMergeStrategy.java index 0353269e0..e4faeb92c 100644 --- a/structurizr-core/src/com/structurizr/view/DefaultLayoutMergeStrategy.java +++ b/structurizr-core/src/main/java/com/structurizr/view/DefaultLayoutMergeStrategy.java @@ -33,7 +33,7 @@ public class DefaultLayoutMergeStrategy implements LayoutMergeStrategy { * @param viewWithLayoutInformation the source view (e.g. the version stored by the Structurizr service) * @param viewWithoutLayoutInformation the destination View (e.g. the new version, created locally with code) */ - public void copyLayoutInformation(@Nonnull View viewWithLayoutInformation, @Nonnull View viewWithoutLayoutInformation) { + public void copyLayoutInformation(@Nonnull ModelView viewWithLayoutInformation, @Nonnull ModelView viewWithoutLayoutInformation) { setPaperSizeIfNotSpecified(viewWithLayoutInformation, viewWithoutLayoutInformation); setDimensionsIfNotSpecified(viewWithLayoutInformation, viewWithoutLayoutInformation); @@ -69,13 +69,13 @@ public void copyLayoutInformation(@Nonnull View viewWithLayoutInformation, @Nonn } } - private void setPaperSizeIfNotSpecified(@Nonnull View remoteView, @Nonnull View localView) { + private void setPaperSizeIfNotSpecified(@Nonnull ModelView remoteView, @Nonnull ModelView localView) { if (localView.getPaperSize() == null) { localView.setPaperSize(remoteView.getPaperSize()); } } - private void setDimensionsIfNotSpecified(@Nonnull View remoteView, @Nonnull View localView) { + private void setDimensionsIfNotSpecified(@Nonnull ModelView remoteView, @Nonnull ModelView localView) { if (localView.getDimensions() == null) { localView.setDimensions(remoteView.getDimensions()); } @@ -88,7 +88,7 @@ private void setDimensionsIfNotSpecified(@Nonnull View remoteView, @Nonnull View * @param elementWithoutLayoutInformation the Element to find * @return an ElementView */ - protected ElementView findElementView(View viewWithLayoutInformation, Element elementWithoutLayoutInformation) { + protected ElementView findElementView(ModelView viewWithLayoutInformation, Element elementWithoutLayoutInformation) { // see if we can find an element with the same canonical name in the source view ElementView elementView = viewWithLayoutInformation.getElements().stream().filter(ev -> ev.getElement().getCanonicalName().equals(elementWithoutLayoutInformation.getCanonicalName())).findFirst().orElse(null); @@ -112,7 +112,7 @@ protected ElementView findElementView(View viewWithLayoutInformation, Element el return elementView; } - private RelationshipView findRelationshipView(View viewWithLayoutInformation, Relationship relationshipWithoutLayoutInformation, Map elementMap) { + private RelationshipView findRelationshipView(ModelView viewWithLayoutInformation, Relationship relationshipWithoutLayoutInformation, Map elementMap) { if (!elementMap.containsKey(relationshipWithoutLayoutInformation.getSource()) || !elementMap.containsKey(relationshipWithoutLayoutInformation.getDestination())) { return null; } @@ -130,10 +130,21 @@ private RelationshipView findRelationshipView(View viewWithLayoutInformation, Re } } + // if we got this far, perhaps the relationship description was changed, so try matching on ID instead + for (RelationshipView rv : viewWithLayoutInformation.getRelationships()) { + if ( + rv.getRelationship().getSource().equals(sourceElementWithLayoutInformation) && + rv.getRelationship().getDestination().equals(destinationElementWithLayoutInformation) && + rv.getRelationship().getId().equals(relationshipWithoutLayoutInformation.getId()) + ) { + return rv; + } + } + return null; } - private RelationshipView findRelationshipView(View view, RelationshipView relationshipWithoutLayoutInformation, Map elementMap) { + private RelationshipView findRelationshipView(ModelView view, RelationshipView relationshipWithoutLayoutInformation, Map elementMap) { if (!elementMap.containsKey(relationshipWithoutLayoutInformation.getRelationship().getSource()) || !elementMap.containsKey(relationshipWithoutLayoutInformation.getRelationship().getDestination())) { return null; } @@ -151,6 +162,17 @@ private RelationshipView findRelationshipView(View view, RelationshipView relati } } + // if we got this far, perhaps the relationship description was changed, so try matching on ID instead + for (RelationshipView rv : view.getRelationships()) { + if ( + rv.getRelationship().getSource().equals(sourceElementWithLayoutInformation) && + rv.getRelationship().getDestination().equals(destinationElementWithLayoutInformation) && + rv.getRelationship().getId().equals(relationshipWithoutLayoutInformation.getId()) && + rv.getOrder().equals(relationshipWithoutLayoutInformation.getOrder())) { + return rv; + } + } + return null; } diff --git a/structurizr-core/src/com/structurizr/view/DeploymentView.java b/structurizr-core/src/main/java/com/structurizr/view/DeploymentView.java similarity index 91% rename from structurizr-core/src/com/structurizr/view/DeploymentView.java rename to structurizr-core/src/main/java/com/structurizr/view/DeploymentView.java index 425c38829..444bb2250 100644 --- a/structurizr-core/src/com/structurizr/view/DeploymentView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/DeploymentView.java @@ -5,17 +5,19 @@ import com.structurizr.util.StringUtils; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.*; import java.util.stream.Collectors; /** * A deployment view, used to show the mapping of container instances to deployment nodes. */ -public final class DeploymentView extends View { +public final class DeploymentView extends ModelView implements AnimatedView { private Model model; private String environment = DeploymentElement.DEFAULT_DEPLOYMENT_ENVIRONMENT; + @Nonnull private List animations = new ArrayList<>(); DeploymentView() { @@ -246,13 +248,9 @@ public RelationshipView add(@Nonnull Relationship relationship) { public String getName() { String name; if (getSoftwareSystem() != null) { - name = getSoftwareSystem().getName() + " - Deployment"; + name = "Deployment View: " + getSoftwareSystem().getName() + " - " + getEnvironment(); } else { - name = "Deployment"; - } - - if (!StringUtils.isNullOrEmpty(getEnvironment())) { - name = name + " - " + getEnvironment(); + name = "Deployment View: " + getEnvironment(); } return name; @@ -401,14 +399,14 @@ private void addAnimationStep(Element... elements) { } } - if (elementsInThisAnimationStep.size() == 0) { - throw new IllegalArgumentException("None of the specified container instances exist in this view."); + if (elementsInThisAnimationStep.isEmpty()) { + throw new IllegalArgumentException("None of the specified elements exist in this view."); } for (RelationshipView relationshipView : this.getRelationships()) { if ( (elementsInThisAnimationStep.contains(relationshipView.getRelationship().getSource()) && elementIdsInPreviousAnimationSteps.contains(relationshipView.getRelationship().getDestination().getId())) || - (elementIdsInPreviousAnimationSteps.contains(relationshipView.getRelationship().getSource().getId()) && elementsInThisAnimationStep.contains(relationshipView.getRelationship().getDestination())) + (elementIdsInPreviousAnimationSteps.contains(relationshipView.getRelationship().getSource().getId()) && elementsInThisAnimationStep.contains(relationshipView.getRelationship().getDestination())) ) { relationshipsInThisAnimationStep.add(relationshipView.getRelationship()); } @@ -439,11 +437,13 @@ private DeploymentNode findDeploymentNode(Element e) { return null; } + @Nonnull + @Override public List getAnimations() { return new ArrayList<>(animations); } - void setAnimations(List animations) { + void setAnimations(@Nullable List animations) { if (animations != null) { this.animations = new ArrayList<>(animations); } else { @@ -451,4 +451,32 @@ void setAnimations(List animations) { } } -} \ No newline at end of file + /** + * Adds the given custom element to this view, including relationships to/from that custom element. + * + * @param customElement the CustomElement to add + */ + public void add(@Nonnull CustomElement customElement) { + add(customElement, true); + } + + /** + * Adds the given custom element to this view. + * + * @param customElement the CustomElement to add + * @param addRelationships whether to add relationships to/from the custom element + */ + public void add(@Nonnull CustomElement customElement, boolean addRelationships) { + addElement(customElement, addRelationships); + } + + /** + * Removes the given custom element from this view. + * + * @param customElement the CustomElement to add + */ + public void remove(@Nonnull CustomElement customElement) { + removeElement(customElement); + } + +} diff --git a/structurizr-core/src/com/structurizr/view/Dimensions.java b/structurizr-core/src/main/java/com/structurizr/view/Dimensions.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Dimensions.java rename to structurizr-core/src/main/java/com/structurizr/view/Dimensions.java diff --git a/structurizr-core/src/com/structurizr/view/DynamicView.java b/structurizr-core/src/main/java/com/structurizr/view/DynamicView.java similarity index 73% rename from structurizr-core/src/com/structurizr/view/DynamicView.java rename to structurizr-core/src/main/java/com/structurizr/view/DynamicView.java index 9a29dd37b..5f206caca 100644 --- a/structurizr-core/src/com/structurizr/view/DynamicView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/DynamicView.java @@ -6,12 +6,11 @@ import javax.annotation.Nonnull; import java.util.*; -import java.util.stream.Collectors; /** * A dynamic view, used to describe behaviour between static elements at runtime. */ -public final class DynamicView extends View { +public final class DynamicView extends ModelView { private Model model; @@ -101,6 +100,46 @@ public RelationshipView add(@Nonnull StaticStructureElement source, String descr } public RelationshipView add(@Nonnull StaticStructureElement source, String description, String technology, @Nonnull StaticStructureElement destination) { + return addRelationshipViaElements(source, description, technology, destination); + } + + public RelationshipView add(@Nonnull CustomElement source, @Nonnull StaticStructureElement destination) { + return add(source, "", destination); + } + + public RelationshipView add(@Nonnull CustomElement source, String description, @Nonnull StaticStructureElement destination) { + return add(source, description, "", destination); + } + + public RelationshipView add(@Nonnull CustomElement source, String description, String technology, @Nonnull StaticStructureElement destination) { + return addRelationshipViaElements(source, description, technology, destination); + } + + public RelationshipView add(@Nonnull StaticStructureElement source, @Nonnull CustomElement destination) { + return add(source, "", destination); + } + + public RelationshipView add(@Nonnull StaticStructureElement source, String description, @Nonnull CustomElement destination) { + return add(source, description, "", destination); + } + + public RelationshipView add(@Nonnull StaticStructureElement source, String description, String technology, @Nonnull CustomElement destination) { + return addRelationshipViaElements(source, description, technology, destination); + } + + public RelationshipView add(@Nonnull CustomElement source, @Nonnull CustomElement destination) { + return add(source, "", destination); + } + + public RelationshipView add(@Nonnull CustomElement source, String description, @Nonnull CustomElement destination) { + return add(source, description, "", destination); + } + + public RelationshipView add(@Nonnull CustomElement source, String description, String technology, @Nonnull CustomElement destination) { + return addRelationshipViaElements(source, description, technology, destination); + } + + private RelationshipView addRelationshipViaElements(@Nonnull Element source, String description, String technology, @Nonnull Element destination) { if (source == null) { throw new IllegalArgumentException("A source element must be specified."); } @@ -165,7 +204,38 @@ public RelationshipView add(@Nonnull StaticStructureElement source, String descr } } - protected RelationshipView addRelationship(Relationship relationship, String description, String order, boolean response) { + /** + * Adds a specific relationship to this dynamic view, with the original description. + * + * @param relationship the Relationship to add + * @return a RelationshipView + */ + public RelationshipView add(Relationship relationship) { + return add(relationship, ""); + } + + /** + * Adds a specific relationship to this dynamic view, with an overidden description. + * + * @param relationship the Relationship to add + * @param description the overidden description + * @return a RelationshipView + */ + public RelationshipView add(Relationship relationship, String description) { + if (relationship == null) { + throw new IllegalArgumentException("A relationship must be specified."); + } + + checkElementCanBeAdded(relationship.getSource()); + checkElementCanBeAdded(relationship.getDestination()); + + addElement(relationship.getSource(), false); + addElement(relationship.getDestination(), false); + + return addRelationship(relationship, description, sequenceNumber.getNext(), false); + } + + private RelationshipView addRelationship(Relationship relationship, String description, String order, boolean response) { RelationshipView relationshipView = addRelationship(relationship); if (relationshipView != null) { relationshipView.setDescription(description); @@ -184,9 +254,14 @@ protected RelationshipView addRelationship(Relationship relationship, String des @Override public String getName() { if (element != null) { - return element.getName() + " - Dynamic"; + if (element instanceof Container) { + Container container = (Container)element; + return "Dynamic View: " + container.getParent().getName() + " - " + container.getName(); + } else { + return "Dynamic View: " + element.getName(); + } } else { - return "Dynamic"; + return "Dynamic View"; } } @@ -214,6 +289,11 @@ public void endParallelSequence(boolean endAllParallelSequencesAndContinueNumber @Override protected void checkElementCanBeAdded(Element elementToBeAdded) { + if (elementToBeAdded instanceof CustomElement) { + // all good + return; + } + if (!(elementToBeAdded instanceof StaticStructureElement)) { throw new ElementNotPermittedInViewException("Only people, software systems, containers and components can be added to dynamic views."); } @@ -297,21 +377,13 @@ private boolean isNumeric(String str) { } } - /** - * Determines whether software system/container boundaries should be visible for "external" containers/components (those outside the element in scope). - * - * @return true if external boundaries are visible, false otherwise - */ + @Deprecated public boolean getExternalBoundariesVisible() { return externalBoundariesVisible; } - /** - * Sets whether software system/container boundaries should be visible for "external" containers/components (those outside the element in scope). - * - * @param externalBoundariesVisible true if external boundaries should be visible, false otherwise - */ - public void setExternalBoundariesVisible(boolean externalBoundariesVisible) { + @Deprecated + void setExternalBoundariesVisible(boolean externalBoundariesVisible) { this.externalBoundariesVisible = externalBoundariesVisible; } diff --git a/structurizr-core/src/com/structurizr/view/ElementNotPermittedInViewException.java b/structurizr-core/src/main/java/com/structurizr/view/ElementNotPermittedInViewException.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/ElementNotPermittedInViewException.java rename to structurizr-core/src/main/java/com/structurizr/view/ElementNotPermittedInViewException.java diff --git a/structurizr-core/src/com/structurizr/view/ElementStyle.java b/structurizr-core/src/main/java/com/structurizr/view/ElementStyle.java similarity index 75% rename from structurizr-core/src/com/structurizr/view/ElementStyle.java rename to structurizr-core/src/main/java/com/structurizr/view/ElementStyle.java index fc61528e5..2787334b1 100644 --- a/structurizr-core/src/com/structurizr/view/ElementStyle.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ElementStyle.java @@ -1,7 +1,6 @@ package com.structurizr.view; import com.fasterxml.jackson.annotation.JsonInclude; -import com.structurizr.PropertyHolder; import com.structurizr.util.ImageUtils; import com.structurizr.util.StringUtils; @@ -10,11 +9,6 @@ */ public final class ElementStyle extends AbstractStyle { - public static final int DEFAULT_WIDTH = 450; - public static final int DEFAULT_HEIGHT = 300; - - private String tag; - @JsonInclude(value = JsonInclude.Include.NON_NULL) private Integer width; @@ -27,6 +21,9 @@ public final class ElementStyle extends AbstractStyle { @JsonInclude(value = JsonInclude.Include.NON_NULL) private String stroke; + @JsonInclude(value = JsonInclude.Include.NON_NULL) + private Integer strokeWidth; + @JsonInclude(value = JsonInclude.Include.NON_NULL) private String color; @@ -39,6 +36,9 @@ public final class ElementStyle extends AbstractStyle { @JsonInclude(value = JsonInclude.Include.NON_NULL) private String icon; + @JsonInclude(value = JsonInclude.Include.NON_NULL) + private IconPosition iconPosition; + @JsonInclude(value = JsonInclude.Include.NON_NULL) private Border border; @@ -54,8 +54,12 @@ public final class ElementStyle extends AbstractStyle { ElementStyle() { } - ElementStyle(String tag) { - this.tag = tag; + public ElementStyle(String tag) { + super(tag); + } + + ElementStyle(String tag, ColorScheme colorScheme) { + super(tag, colorScheme); } public ElementStyle(String tag, Integer width, Integer height, String background, String color, Integer fontSize) { @@ -63,7 +67,8 @@ public ElementStyle(String tag, Integer width, Integer height, String background } public ElementStyle(String tag, Integer width, Integer height, String background, String color, Integer fontSize, Shape shape) { - this.tag = tag; + super(tag); + this.width = width; this.height = height; setBackground(background); @@ -72,19 +77,6 @@ public ElementStyle(String tag, Integer width, Integer height, String background this.shape = shape; } - /** - * The tag to which this element style applies. - * - * @return the tag, as a String - */ - public String getTag() { - return tag; - } - - public void setTag(String tag) { - this.tag = tag; - } - /** * Gets the width of the element, in pixels. * @@ -130,11 +122,17 @@ public String getBackground() { return background; } - public void setBackground(String background) { - if (Color.isHexColorCode(background)) { - this.background = background.toLowerCase(); + public void setBackground(String color) { + if (Color.isHexColorCode(color)) { + this.background = color.toLowerCase(); } else { - throw new IllegalArgumentException(background + " is not a valid hex colour code."); + String hexColorCode = Color.fromColorNameToHexColorCode(color); + + if (hexColorCode != null) { + this.background = hexColorCode.toLowerCase(); + } else { + throw new IllegalArgumentException(color + " is not a valid hex colour code or HTML colour name."); + } } } @@ -156,7 +154,13 @@ public void setStroke(String color) { if (Color.isHexColorCode(color)) { this.stroke = color.toLowerCase(); } else { - throw new IllegalArgumentException(color + " is not a valid hex colour code."); + String hexColorCode = Color.fromColorNameToHexColorCode(color); + + if (hexColorCode != null) { + this.stroke = hexColorCode.toLowerCase(); + } else { + throw new IllegalArgumentException(color + " is not a valid hex colour code or HTML colour name."); + } } } @@ -165,6 +169,30 @@ public ElementStyle stroke(String color) { return this; } + /** + * Gets the stroke width, in pixels, between 1 and 10. + * + * @return the stroke width + */ + public Integer getStrokeWidth() { + return strokeWidth; + } + + public void setStrokeWidth(Integer strokeWidth) { + if (strokeWidth == null) { + this.strokeWidth = null; + } else if (strokeWidth < 1) { + this.strokeWidth = 1; + } else { + this.strokeWidth = Math.min(10, strokeWidth); + } + } + + public ElementStyle strokeWidth(Integer strokeWidth) { + setStrokeWidth(strokeWidth); + return this; + } + /** * Gets the foreground (text) colour of the element, as a HTML RGB hex string (e.g. #123456). * @@ -178,7 +206,13 @@ public void setColor(String color) { if (Color.isHexColorCode(color)) { this.color = color.toLowerCase(); } else { - throw new IllegalArgumentException(color + " is not a valid hex colour code."); + String hexColorCode = Color.fromColorNameToHexColorCode(color); + + if (hexColorCode != null) { + this.color = hexColorCode.toLowerCase(); + } else { + throw new IllegalArgumentException(color + " is not a valid hex colour code or HTML colour name."); + } } } @@ -246,6 +280,24 @@ public ElementStyle icon(String icon) { return this; } + /** + * Gets the icon position to use when rendering the element. + * + * @return an IconPosition, or null if not specified + */ + public IconPosition getIconPosition() { + return iconPosition; + } + + public void setIconPosition(IconPosition iconPosition) { + this.iconPosition = iconPosition; + } + + public ElementStyle iconPosition(IconPosition iconPosition) { + setIconPosition(iconPosition); + return this; + } + /** * Gets the border used when rendering the element. * @@ -353,6 +405,10 @@ void copyFrom(ElementStyle elementStyle) { this.setStroke(elementStyle.getStroke()); } + if (elementStyle.getStrokeWidth() != null) { + this.setStrokeWidth(elementStyle.getStrokeWidth()); + } + if (!StringUtils.isNullOrEmpty(elementStyle.getColor())) { this.setColor(elementStyle.getColor()); } @@ -369,6 +425,10 @@ void copyFrom(ElementStyle elementStyle) { this.setIcon(elementStyle.getIcon()); } + if (elementStyle.getIconPosition() != null) { + this.setIconPosition(elementStyle.getIconPosition()); + } + if (elementStyle.getBorder() != null) { this.setBorder(elementStyle.getBorder()); } @@ -384,6 +444,10 @@ void copyFrom(ElementStyle elementStyle) { if (elementStyle.getDescription() != null) { this.setDescription(elementStyle.getDescription()); } + + for (String name : elementStyle.getProperties().keySet()) { + this.addProperty(name, elementStyle.getProperties().get(name)); + } } } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/ElementView.java b/structurizr-core/src/main/java/com/structurizr/view/ElementView.java similarity index 82% rename from structurizr-core/src/com/structurizr/view/ElementView.java rename to structurizr-core/src/main/java/com/structurizr/view/ElementView.java index b3a4487a7..09518ebc6 100644 --- a/structurizr-core/src/com/structurizr/view/ElementView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ElementView.java @@ -6,7 +6,7 @@ /** * Represents an instance of an Element in a View. */ -public final class ElementView { +public final class ElementView implements Comparable { private Element element; private String id; @@ -100,4 +100,16 @@ void copyLayoutInformationFrom(ElementView source) { } } + @Override + public int compareTo(ElementView elementView) { + try { + int id1 = Integer.parseInt(getId()); + int id2 = Integer.parseInt(elementView.getId()); + + return id1 - id2; + } catch (NumberFormatException nfe) { + return getId().compareTo(elementView.getId()); + } + } + } diff --git a/structurizr-core/src/com/structurizr/view/FilterMode.java b/structurizr-core/src/main/java/com/structurizr/view/FilterMode.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/FilterMode.java rename to structurizr-core/src/main/java/com/structurizr/view/FilterMode.java diff --git a/structurizr-core/src/com/structurizr/view/FilteredView.java b/structurizr-core/src/main/java/com/structurizr/view/FilteredView.java similarity index 51% rename from structurizr-core/src/com/structurizr/view/FilteredView.java rename to structurizr-core/src/main/java/com/structurizr/view/FilteredView.java index 2a9b9a50c..256bc3763 100644 --- a/structurizr-core/src/com/structurizr/view/FilteredView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/FilteredView.java @@ -3,31 +3,27 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.Arrays; -import java.util.HashSet; import java.util.Set; +import java.util.TreeSet; /** * Represents a view on top of a view, which can be used to include or exclude specific elements. */ -public final class FilteredView { +public final class FilteredView extends View { - private View view; + private ModelView view; private String baseViewKey; - private String key; - private int order; - private String description = ""; - private FilterMode mode = FilterMode.Exclude; - private Set tags = new HashSet<>(); + private final Set tags = new TreeSet<>(); FilteredView() { } - FilteredView(StaticView view, String key, String description, FilterMode mode, String... tags) { + FilteredView(ModelView view, String key, String description, FilterMode mode, String... tags) { this.view = view; - this.key = key; - this.description = description; + setKey(key); + setDescription(description); this.mode = mode; this.tags.addAll(Arrays.asList(tags)); } @@ -37,7 +33,7 @@ public View getView() { return view; } - void setView(View view) { + void setView(ModelView view) { this.view = view; } @@ -53,35 +49,6 @@ void setBaseViewKey(String baseViewKey) { this.baseViewKey = baseViewKey; } - public String getKey() { - return key; - } - - void setKey(String key) { - this.key = key; - } - - /** - * Gets the order of this view. - * - * @return a positive integer - */ - public int getOrder() { - return order; - } - - void setOrder(int order) { - this.order = Math.max(1, order); - } - - public String getDescription() { - return description; - } - - void setDescription(String description) { - this.description = description; - } - public FilterMode getMode() { return mode; } @@ -91,7 +58,12 @@ void setMode(FilterMode mode) { } public Set getTags() { - return new HashSet<>(tags); + return new TreeSet<>(tags); + } + + @Override + public String getName() { + return "Filtered: " + view.getName(); } } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Font.java b/structurizr-core/src/main/java/com/structurizr/view/Font.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Font.java rename to structurizr-core/src/main/java/com/structurizr/view/Font.java diff --git a/structurizr-core/src/main/java/com/structurizr/view/IconPosition.java b/structurizr-core/src/main/java/com/structurizr/view/IconPosition.java new file mode 100644 index 000000000..38ee1ff93 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/IconPosition.java @@ -0,0 +1,9 @@ +package com.structurizr.view; + +public enum IconPosition { + + Top, + Bottom, + Left + +} \ No newline at end of file diff --git a/structurizr-core/src/main/java/com/structurizr/view/ImageView.java b/structurizr-core/src/main/java/com/structurizr/view/ImageView.java new file mode 100644 index 000000000..bd66943d2 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/ImageView.java @@ -0,0 +1,152 @@ +package com.structurizr.view; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.structurizr.model.Element; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; + +/** + * A view that has been rendered elsewhere (e.g. PlantUML, Mermaid, Kroki, etc) as a image (e.g. PNG). + */ +public final class ImageView extends View { + + private Element element; + private String elementId; + private String content; + private String contentLight; + private String contentDark; + private String contentType; + + ImageView() { + } + + ImageView(String key) { + setKey(key); + } + + ImageView(Element element, String key) { + this(key); + setElement(element); + } + + /** + * Gets the ID of the element associated with this view. + * + * @return the ID, as a String, or null if not set + */ + public String getElementId() { + if (this.element != null) { + return element.getId(); + } else { + return this.elementId; + } + } + + void setElementId(String elementId) { + this.elementId = elementId; + } + + @JsonIgnore + public Element getElement() { + return element; + } + + void setElement(Element element) { + this.element = element; + } + + /** + * Gets the content of this view (a URL or a data URI). + * + * @return the content, as a String + */ + public String getContent() { + return content; + } + + /** + * Gets the content of this view (a URL or a data URI), for the light color scheme. + * + * @return the content, as a String + */ + public String getContentLight() { + return contentLight; + } + + /** + * Gets the content of this view (a URL or a data URI), for the dark color scheme. + * + * @return the content, as a String + */ + public String getContentDark() { + return contentDark; + } + + /** + * Sets the content of this image view, which needs to be a URL or a data URI. + * + * @param content the content of this view + */ + public void setContent(String content) { + setContent(content, null); + } + + /** + * Sets the content of this image view, which needs to be a URL or a data URI. + * + * @param content the content of this view + */ + public void setContent(String content, ColorScheme colorScheme) { + if (StringUtils.isNullOrEmpty(content)) { + if (colorScheme == ColorScheme.Dark) { + this.contentDark = null; + } else if (colorScheme == ColorScheme.Light) { + this.contentLight = null; + } else { + this.content = null; + } + } else { + ImageUtils.validateImage(content); + content = content.trim(); + + if (colorScheme == ColorScheme.Dark) { + this.contentDark = content; + } else if (colorScheme == ColorScheme.Light) { + this.contentLight = content; + } else { + this.content = content; + } + } + } + + /** + * Gets the content type of this view (e.g. "image/png"). + * + * @return the content type, as a String + */ + public String getContentType() { + return contentType; + } + + /** + * Sets the content type of this view (e.g. "image/png"). + * + * @param contentType the content type, as a String + */ + public void setContentType(String contentType) { + this.contentType = contentType; + } + + @Override + public String getName() { + return getTitle(); + } + + public boolean hasContent() { + return + !StringUtils.isNullOrEmpty(content) || + !StringUtils.isNullOrEmpty(contentLight) || + !StringUtils.isNullOrEmpty(contentDark); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/LayoutMergeStrategy.java b/structurizr-core/src/main/java/com/structurizr/view/LayoutMergeStrategy.java similarity index 86% rename from structurizr-core/src/com/structurizr/view/LayoutMergeStrategy.java rename to structurizr-core/src/main/java/com/structurizr/view/LayoutMergeStrategy.java index 063f7bf84..ec17d9634 100644 --- a/structurizr-core/src/com/structurizr/view/LayoutMergeStrategy.java +++ b/structurizr-core/src/main/java/com/structurizr/view/LayoutMergeStrategy.java @@ -14,6 +14,6 @@ public interface LayoutMergeStrategy { * @param sourceView the source view (e.g. the version stored by the Structurizr service) * @param destinationView the destination View (e.g. the new version, created locally with code) */ - void copyLayoutInformation(@Nonnull View sourceView, @Nonnull View destinationView); + void copyLayoutInformation(@Nonnull ModelView sourceView, @Nonnull ModelView destinationView); } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/LineStyle.java b/structurizr-core/src/main/java/com/structurizr/view/LineStyle.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/LineStyle.java rename to structurizr-core/src/main/java/com/structurizr/view/LineStyle.java diff --git a/structurizr-core/src/com/structurizr/view/MetadataSymbols.java b/structurizr-core/src/main/java/com/structurizr/view/MetadataSymbols.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/MetadataSymbols.java rename to structurizr-core/src/main/java/com/structurizr/view/MetadataSymbols.java diff --git a/structurizr-core/src/com/structurizr/view/View.java b/structurizr-core/src/main/java/com/structurizr/view/ModelView.java similarity index 84% rename from structurizr-core/src/com/structurizr/view/View.java rename to structurizr-core/src/main/java/com/structurizr/view/ModelView.java index 419163a68..90f0a5bdc 100644 --- a/structurizr-core/src/com/structurizr/view/View.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ModelView.java @@ -7,41 +7,44 @@ import javax.annotation.Nonnull; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; /** - * The superclass for all views (static views, dynamic views and deployment views). + * The superclass for all views that show elements/relationships from the model, namely: + * + * - Custom views + * - System landscape views + * - System context views + * - Container views + * - Dynamic views + * - Deployment views */ -public abstract class View { +public abstract class ModelView extends View { private static final int DEFAULT_RANK_SEPARATION = 300; private static final int DEFAULT_NODE_SEPARATION = 300; private SoftwareSystem softwareSystem; private String softwareSystemId; - private String description = ""; - private String key; - private int order; private PaperSize paperSize = null; private Dimensions dimensions = null; private AutomaticLayout automaticLayout = null; private boolean mergeFromRemote = true; - private String title; - private Set elementViews = new LinkedHashSet<>(); - private Set relationshipViews = new LinkedHashSet<>(); + private Set elementViews = new TreeSet<>(); + private Set relationshipViews = new TreeSet<>(); private LayoutMergeStrategy layoutMergeStrategy = new DefaultLayoutMergeStrategy(); private ViewSet viewSet; - View() { + ModelView() { } - View(SoftwareSystem softwareSystem, String key, String description) { + ModelView(SoftwareSystem softwareSystem, String key, String description) { this.softwareSystem = softwareSystem; if (!StringUtils.isNullOrEmpty(key)) { setKey(key); @@ -92,53 +95,6 @@ void setSoftwareSystemId(String softwareSystemId) { this.softwareSystemId = softwareSystemId; } - /** - * Gets the description of this view. - * - * @return the description, as a String - */ - public String getDescription() { - return description; - } - - public void setDescription(String description) { - if (description == null) { - this.description = ""; - } else { - this.description = description; - } - } - - /** - * Gets the identifier for this view. - * - * @return the identifier, as a String - */ - public String getKey() { - return key; - } - - void setKey(String key) { - if (key != null) { - key = key.replaceAll("/", "_"); - } - - this.key = key; - } - - /** - * Gets the order of this view. - * - * @return a positive integer - */ - public int getOrder() { - return order; - } - - void setOrder(int order) { - this.order = Math.max(1, order); - } - /** * Gets the paper size that should be used to render this view. * @@ -240,32 +196,6 @@ public void setMergeFromRemote(boolean mergeFromRemote) { this.mergeFromRemote = mergeFromRemote; } - /** - * Gets the title of this view, if one has been set. - * - * @return the title, as a String - */ - public String getTitle() { - return title; - } - - /** - * Sets the title for this view. - * - * @param title the title, as a String - */ - public void setTitle(String title) { - this.title = title; - } - - /** - * Gets the (computed) name of this view. - * - * @return the name, as a String - */ - @JsonIgnore - public abstract String getName(); - protected final void addElement(Element element, boolean addRelationships) { if (element == null) { throw new IllegalArgumentException("An element must be specified."); @@ -342,10 +272,26 @@ protected RelationshipView addRelationship(Relationship relationship) { return null; } + /** + * Determines whether the specified element exists in this view. + * + * @param element the Element to look for + * @return true if the element exists in the view, false otherwise + */ public boolean isElementInView(Element element) { return this.elementViews.stream().anyMatch(ev -> ev.getElement().equals(element)); } + /** + * Determines whether the specified relationship exists in this view. + * + * @param relationship the Relationship to look for + * @return true if the relationship exists in the view, false otherwise + */ + public boolean isRelationshipInView(Relationship relationship) { + return this.relationshipViews.stream().anyMatch(rv -> rv.getRelationship().equals(relationship)); + } + /** * Removes a relationship from this view. * @@ -372,18 +318,32 @@ public void removeRelationshipsNotConnectedToElement(Element element) { } } + /** + * Removes relationships that are not connected to the specified elements. + * + * @param elements the Set of Element objects to test against + */ + public void removeRelationshipsNotConnectedToElements(Set elements) { + if (elements != null) { + getRelationships().stream() + .map(RelationshipView::getRelationship) + .filter(r -> !elements.contains(r.getSource()) && !elements.contains(r.getDestination())) + .forEach(this::remove); + } + } + /** * Gets the set of elements in this view. * * @return a Set of ElementView objects */ public Set getElements() { - return new HashSet<>(elementViews); + return new TreeSet<>(elementViews); } void setElements(Set elementViews) { if (elementViews != null) { - this.elementViews = new HashSet<>(elementViews); + this.elementViews = new TreeSet<>(elementViews); } } @@ -393,12 +353,12 @@ void setElements(Set elementViews) { * @return a Set of RelationshipView objects */ public Set getRelationships() { - return new HashSet<>(this.relationshipViews); + return new TreeSet<>(this.relationshipViews); } void setRelationships(Set relationshipViews) { if (relationshipViews != null) { - this.relationshipViews = new HashSet<>(relationshipViews); + this.relationshipViews = new TreeSet<>(relationshipViews); } } @@ -439,7 +399,7 @@ public void setLayoutMergeStrategy(LayoutMergeStrategy layoutMergeStrategy) { * * @param source the source View */ - void copyLayoutInformationFrom(@Nonnull View source) { + public void copyLayoutInformationFrom(@Nonnull ModelView source) { layoutMergeStrategy.copyLayoutInformation(source, this); } @@ -510,25 +470,6 @@ final void checkParentAndChildrenHaveNotAlreadyBeenAdded(StaticStructureElement } } - /** - * Adds the given custom element to this view, including relationships to/from that custom element. - * - * @param customElement the CustomElement to add - */ - public void add(@Nonnull CustomElement customElement) { - add(customElement, true); - } - - /** - * Adds the given custom element to this view. - * - * @param customElement the CustomElement to add - * @param addRelationships whether to add relationships to/from the custom element - */ - public void add(@Nonnull CustomElement customElement, boolean addRelationships) { - addElement(customElement, addRelationships); - } - protected void addNearestNeighbours(Element element, Class typeOfElement) { if (element == null) { return; @@ -562,13 +503,4 @@ protected void addNearestNeighbours(Element element, Class { private static final int START_OF_LINE = 0; private static final int END_OF_LINE = 100; @@ -20,13 +20,18 @@ public final class RelationshipView { private Relationship relationship; private String id; private String description; + private Map properties = new HashMap<>(); + private String url; private String order; private Boolean response; - private Set vertices = new LinkedHashSet<>(); + private List vertices = new ArrayList<>(); @JsonInclude(value = JsonInclude.Include.NON_NULL) private Routing routing; + @JsonInclude(value = JsonInclude.Include.NON_NULL) + private Boolean jump; + @JsonInclude(value = JsonInclude.Include.NON_NULL) private Integer position; @@ -83,10 +88,74 @@ public String getDescription() { * * @param description the description, as a String */ - public void setDescription(String description) { + void setDescription(String description) { this.description = description; } + /** + * Gets the collection of name-value property pairs associated with this relationship view, as a Map. + * + * @return a Map (String, String) (empty if there are no properties) + */ + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Adds a name-value pair property to this relationship view. + * + * @param name the name of the property + * @param value the value of the property + */ + public void addProperty(String name, String value) { + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException("A property name must be specified."); + } + + if (value == null || value.trim().length() == 0) { + throw new IllegalArgumentException("A property value must be specified."); + } + + properties.put(name, value); + } + + void setProperties(Map properties) { + if (properties != null) { + this.properties = new HashMap<>(properties); + } + } + + /** + * Gets the URL where more information about this relationship instance can be found. + * + * @return a URL as a String + */ + public String getUrl() { + return url; + } + + /** + * Sets the URL where more information about this relationship instance can be found. + * + * @param url the URL as a String + * @throws IllegalArgumentException if the URL is not a well-formed URL + */ + public void setUrl(String url) { + if (StringUtils.isNullOrEmpty(url)) { + this.url = null; + } else { + if (url.startsWith(Url.INTRA_WORKSPACE_URL_PREFIX)) { + this.url = url; + } else if (url.matches(Url.INTER_WORKSPACE_URL_REGEX)) { + this.url = url; + } else if (Url.isUrl(url)) { + this.url = url; + } else { + throw new IllegalArgumentException(url + " is not a valid URL."); + } + } + } + /** * Gets the order of this relationship (used in dynamic views only; e.g. 1.0, 1.1, 2.0, etc). * @@ -124,7 +193,7 @@ void setResponse(Boolean response) { * @return a collection of Vertex objects */ public Collection getVertices() { - return new LinkedList<>(vertices); + return new ArrayList<>(vertices); } /** @@ -134,7 +203,7 @@ public Collection getVertices() { */ public void setVertices(Collection vertices) { if (vertices != null) { - this.vertices = new LinkedHashSet<>(vertices); + this.vertices = new ArrayList<>(vertices); } } @@ -156,6 +225,24 @@ public void setRouting(Routing routing) { this.routing = routing; } + /** + * Gets whether this relationship should "jump" when crossing others. + * + * @return true if jumping is enabled, false otherwise + */ + public Boolean getJump() { + return jump; + } + + /** + * Sets whether this relationship should "jump" when crossing others. + * + * @param jump true if enabled, false otherwise + */ + public void setJump(Boolean jump) { + this.jump = jump; + } + /** * Gets the position of the annotation along the line. * @@ -187,6 +274,7 @@ void copyLayoutInformationFrom(RelationshipView source) { setVertices(source.getVertices()); setPosition(source.getPosition()); setRouting(source.getRouting()); + setJump(source.getJump()); } } @@ -220,4 +308,12 @@ public String toString() { return ""; } + @Override + public int compareTo(RelationshipView relationshipView) { + String identifier1 = getId() + "/" + getOrder(); + String identifier2 = relationshipView.getId() + "/" + relationshipView.getOrder(); + + return identifier1.compareTo(identifier2); + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Routing.java b/structurizr-core/src/main/java/com/structurizr/view/Routing.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Routing.java rename to structurizr-core/src/main/java/com/structurizr/view/Routing.java diff --git a/structurizr-core/src/com/structurizr/view/SequenceCounter.java b/structurizr-core/src/main/java/com/structurizr/view/SequenceCounter.java similarity index 82% rename from structurizr-core/src/com/structurizr/view/SequenceCounter.java rename to structurizr-core/src/main/java/com/structurizr/view/SequenceCounter.java index dcfd3590e..8f8d52314 100644 --- a/structurizr-core/src/com/structurizr/view/SequenceCounter.java +++ b/structurizr-core/src/main/java/com/structurizr/view/SequenceCounter.java @@ -4,6 +4,7 @@ class SequenceCounter implements Cloneable { private SequenceCounter parent; private int sequence = 0; + private boolean incremented = false; SequenceCounter() { } @@ -14,6 +15,11 @@ class SequenceCounter implements Cloneable { void increment() { this.sequence++; + incremented = true; + } + + boolean incremented() { + return incremented; } int getSequence() { diff --git a/structurizr-core/src/main/java/com/structurizr/view/SequenceNumber.java b/structurizr-core/src/main/java/com/structurizr/view/SequenceNumber.java new file mode 100644 index 000000000..259bb98a0 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/SequenceNumber.java @@ -0,0 +1,38 @@ +package com.structurizr.view; + +class SequenceNumber { + + private SequenceCounter counter = new SequenceCounter(); + + SequenceNumber() { + } + + String getNext() { + counter.increment(); + return counter.toString(); + } + + void startParallelSequence() { + this.counter = new ParallelSequenceCounter(this.counter); + } + + void endParallelSequence(boolean endAllParallelSequencesAndContinueNumbering) { + if (endAllParallelSequencesAndContinueNumbering) { + if (counter.incremented()) { + // relationships were added in this parallel sequence + int sequence = this.counter.getSequence(); + this.counter = this.counter.getParent(); + this.counter.setSequence(sequence); + } else { + // no relationships were added in this parallel sequence, so treat this as a group of parallel sequences + int sequence = this.counter.getSequence(); + this.counter = this.counter.getParent(); + this.counter.setSequence(sequence); + this.counter.increment(); + } + } else { + this.counter = this.counter.getParent(); + } + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Shape.java b/structurizr-core/src/main/java/com/structurizr/view/Shape.java similarity index 84% rename from structurizr-core/src/com/structurizr/view/Shape.java rename to structurizr-core/src/main/java/com/structurizr/view/Shape.java index b53c35446..40b2c1b34 100644 --- a/structurizr-core/src/com/structurizr/view/Shape.java +++ b/structurizr-core/src/main/java/com/structurizr/view/Shape.java @@ -9,13 +9,17 @@ public enum Shape { Hexagon, Diamond, Cylinder, + Bucket, Pipe, Person, Robot, Folder, WebBrowser, + Window, + Terminal, + Shell, MobileDevicePortrait, MobileDeviceLandscape, Component -} +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/StaticView.java b/structurizr-core/src/main/java/com/structurizr/view/StaticView.java similarity index 84% rename from structurizr-core/src/com/structurizr/view/StaticView.java rename to structurizr-core/src/main/java/com/structurizr/view/StaticView.java index dd4947dae..d5934af05 100644 --- a/structurizr-core/src/com/structurizr/view/StaticView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/StaticView.java @@ -1,11 +1,9 @@ package com.structurizr.view; -import com.structurizr.model.Element; -import com.structurizr.model.Person; -import com.structurizr.model.Relationship; -import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.*; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -14,8 +12,9 @@ /** * The superclass for all static views (system landscape, system context, container and component views). */ -public abstract class StaticView extends View { +public abstract class StaticView extends ModelView implements AnimatedView { + @Nonnull private List animations = new ArrayList<>(); StaticView() { @@ -26,10 +25,17 @@ public abstract class StaticView extends View { } /** - * Adds the default set of elements to this view. + * Adds the default set of elements and relationships to this view. */ public abstract void addDefaultElements(); + /** + * Adds the default set of elements and relationships to this view. + * + * @param greedy true (add all relationships) or false (depends on view type) + */ + public abstract void addDefaultElements(boolean greedy); + /** * Adds all software systems in the model to this view. */ @@ -212,7 +218,7 @@ public void addAnimation(Element... elements) { } } - if (elementsInThisAnimationStep.size() == 0) { + if (elementsInThisAnimationStep.isEmpty()) { throw new IllegalArgumentException("None of the specified elements exist in this view."); } @@ -228,11 +234,13 @@ public void addAnimation(Element... elements) { animations.add(new Animation(animations.size() + 1, elementsInThisAnimationStep, relationshipsInThisAnimationStep)); } + @Nonnull + @Override public List getAnimations() { return new ArrayList<>(animations); } - void setAnimations(List animations) { + void setAnimations(@Nullable List animations) { if (animations != null) { this.animations = new ArrayList<>(animations); } else { @@ -240,4 +248,32 @@ void setAnimations(List animations) { } } -} \ No newline at end of file + /** + * Adds the given custom element to this view, including relationships to/from that custom element. + * + * @param customElement the CustomElement to add + */ + public void add(@Nonnull CustomElement customElement) { + add(customElement, true); + } + + /** + * Adds the given custom element to this view. + * + * @param customElement the CustomElement to add + * @param addRelationships whether to add relationships to/from the custom element + */ + public void add(@Nonnull CustomElement customElement, boolean addRelationships) { + addElement(customElement, addRelationships); + } + + /** + * Removes the given custom element from this view. + * + * @param customElement the CustomElement to add + */ + public void remove(@Nonnull CustomElement customElement) { + removeElement(customElement); + } + +} diff --git a/structurizr-core/src/main/java/com/structurizr/view/Styles.java b/structurizr-core/src/main/java/com/structurizr/view/Styles.java new file mode 100644 index 000000000..2d2db79a7 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/Styles.java @@ -0,0 +1,429 @@ +package com.structurizr.view; + +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.util.TagUtils; + +import java.util.*; + +public final class Styles { + + public static final String DEFAULT_BACKGROUND_LIGHT = "#ffffff"; + public static final String DEFAULT_COLOR_LIGHT = "#444444"; + + public static final String DEFAULT_BACKGROUND_DARK = "#111111"; + public static final String DEFAULT_COLOR_DARK = "#cccccc"; + + private Collection elements = new TreeSet<>(); + private Collection relationships = new TreeSet<>(); + + private final List themes = new ArrayList<>(); + + public Collection getElements() { + return elements; + } + + public void add(ElementStyle elementStyle) { + if (elementStyle != null) { + if (StringUtils.isNullOrEmpty(elementStyle.getTag())) { + throw new IllegalArgumentException("A tag must be specified."); + } + + if (elements.stream().anyMatch(es -> es.getTag().equals(elementStyle.getTag()) && es.getColorScheme() == elementStyle.getColorScheme())) { + if (elementStyle.getColorScheme() == null) { + throw new IllegalArgumentException("An element style for the tag \"" + elementStyle.getTag() + "\" already exists."); + } else { + throw new IllegalArgumentException("An element style for the tag \"" + elementStyle.getTag() + "\" and color scheme " + elementStyle.getColorScheme() + " already exists."); + } + } + + this.elements.add(elementStyle); + } + } + + public ElementStyle addElementStyle(String tag) { + return addElementStyle(tag, null); + } + + public ElementStyle addElementStyle(String tag, ColorScheme colorScheme) { + ElementStyle elementStyle = new ElementStyle(tag, colorScheme); + add(elementStyle); + + return elementStyle; + } + + /** + * Removes all element styles. + */ + public void clearElementStyles() { + this.elements = new LinkedList<>(); + } + + /** + * Removes all relationship styles. + */ + public void clearRelationshipStyles() { + this.relationships = new LinkedList<>(); + } + + public Collection getRelationships() { + return relationships; + } + + public void add(RelationshipStyle relationshipStyle) { + if (relationshipStyle != null) { + if (StringUtils.isNullOrEmpty(relationshipStyle.getTag())) { + throw new IllegalArgumentException("A tag must be specified."); + } + + if (relationships.stream().anyMatch(rs -> rs.getTag().equals(relationshipStyle.getTag()) && rs.getColorScheme() == relationshipStyle.getColorScheme())) { + if (relationshipStyle.getColorScheme() == null) { + throw new IllegalArgumentException("A relationship style for the tag \"" + relationshipStyle.getTag() + "\" already exists."); + } else { + throw new IllegalArgumentException("A relationship style for the tag \"" + relationshipStyle.getTag() + "\" and color scheme " + relationshipStyle.getColorScheme() + " already exists."); + } + } + + this.relationships.add(relationshipStyle); + } + } + + public RelationshipStyle addRelationshipStyle(String tag) { + return addRelationshipStyle(tag, null); + } + + public RelationshipStyle addRelationshipStyle(String tag, ColorScheme colorScheme) { + RelationshipStyle relationshipStyle = new RelationshipStyle(tag, colorScheme); + add(relationshipStyle); + + return relationshipStyle; + } + + /** + * Gets the element style that has been defined (in this workspace) for the given tag (without a color scheme). + * + * @param tag the tag (a String) + * @return an ElementStyle instance, or null if no element style has been defined in this workspace + */ + public ElementStyle getElementStyle(String tag) { + return getElementStyle(tag, null); + } + + /** + * Gets the element style that has been defined (in this workspace) for the given tag and color scheme. + * + * @param tag the tag (a String) + * @param colorScheme the ColorScheme (can be null) + * @return an ElementStyle instance, or null if no element style has been defined in this workspace + */ + public ElementStyle getElementStyle(String tag, ColorScheme colorScheme) { + if (StringUtils.isNullOrEmpty(tag)) { + throw new IllegalArgumentException("A tag must be specified."); + } + + return elements.stream().filter(es -> es.getTag().equals(tag) && es.getColorScheme() == colorScheme).findFirst().orElse(null); + } + + /** + * Finds the element style for the given tag and light color scheme. + * + * @param tag the tag (a String) + * @return an ElementStyle instance, or null if there is no style for the given tag + */ + public ElementStyle findElementStyle(String tag) { + return findElementStyle(tag, ColorScheme.Light); + } + + /** + * Finds the element style for the given tag and color scheme. This method creates an empty style, + * and copies properties from any element styles (from the workspace and any themes) for the given tag. + * + * @param tag the tag (a String) + * @param colorScheme the target color scheme + * @return an ElementStyle instance, or null if there is no style for the given tag + */ + public ElementStyle findElementStyle(String tag, ColorScheme colorScheme) { + if (tag == null) { + return null; + } + + boolean elementStyleExists = false; + tag = tag.trim(); + ElementStyle style = new ElementStyle(tag); + + Collection elementStyles = new ArrayList<>(); + for (Theme theme : themes) { + elementStyles.addAll(theme.getElements()); + } + elementStyles.addAll(elements); + + for (ElementStyle elementStyle : elementStyles) { + if (elementStyle != null && elementStyle.getTag().equals(tag)) { + if (elementStyle.getColorScheme() == null || elementStyle.getColorScheme() == colorScheme) { + elementStyleExists = true; + style.copyFrom(elementStyle); + } + } + } + + if (elementStyleExists) { + return style; + } else { + return null; + } + } + + /** + * Gets the relationship style that has been defined (in this workspace) for the given tag (without a color scheme). + * + * @param tag the tag (a String) + * @return an RelationshipStyle instance, or null if no relationship style has been defined in this workspace + */ + public RelationshipStyle getRelationshipStyle(String tag) { + return getRelationshipStyle(tag, null); + } + + /** + * Gets the relationship style that has been defined (in this workspace) for the given tag and color scheme. + * + * @param tag the tag (a String) + * @param colorScheme the ColorScheme (can be null) + * @return an RelationshipStyle instance, or null if no relationship style has been defined in this workspace + */ + public RelationshipStyle getRelationshipStyle(String tag, ColorScheme colorScheme) { + if (StringUtils.isNullOrEmpty(tag)) { + throw new IllegalArgumentException("A tag must be specified."); + } + + return relationships.stream().filter(rs -> rs.getTag().equals(tag) && rs.getColorScheme() == colorScheme).findFirst().orElse(null); + } + + /** + * Finds the relationship style for the given tag with a light color scheme. + * + * + * @param tag the tag (a String) + * @return a RelationshipStyle instance, or null if there is no style for the given tag + */ + public RelationshipStyle findRelationshipStyle(String tag) { + return findRelationshipStyle(tag, ColorScheme.Light); + } + + /** + * Finds the relationship style for the given tag and color scheme. This method creates an empty style, + * and copies properties from any relationship styles (from the workspace and any themes) for the given tag. + * + * @param tag the tag (a String) + * @param colorScheme the target color scheme + * @return a RelationshipStyle instance, or null if there is no style for the given tag + */ + public RelationshipStyle findRelationshipStyle(String tag, ColorScheme colorScheme) { + if (tag == null) { + return null; + } + + boolean relationshipStyleExists = false; + tag = tag.trim(); + RelationshipStyle style = new RelationshipStyle(tag); + + Collection relationshipStyles= new ArrayList<>(); + for (Theme theme : themes) { + relationshipStyles.addAll(theme.getRelationships()); + } + relationshipStyles.addAll(relationships); + + for (RelationshipStyle relationshipStyle : relationshipStyles) { + if (relationshipStyle != null && relationshipStyle.getTag().equals(tag)) { + if (relationshipStyle.getColorScheme() == null || relationshipStyle.getColorScheme() == colorScheme) { + style.copyFrom(relationshipStyle); + relationshipStyleExists = true; + } + } + } + + if (relationshipStyleExists) { + return style; + } else { + return null; + } + } + + /** + * Finds the element style used to render the specified element with a light color scheme. + * + * @param element an Element object + * @return an ElementStyle object + */ + public ElementStyle findElementStyle(Element element) { + return findElementStyle(element, ColorScheme.Light); + } + + /** + * Finds the element style used to render the specified element and color scheme, according to the following rules: + * + * 1. Start with a default style. + * 2. Calculate set of tags associated with the element. + * 3. Find the style properties for each tag (themes first, followed by workspace styles) + * + * @param element an Element object + * @param colorScheme a ColorScheme (Light or Dark) + * @return an ElementStyle object + */ + public ElementStyle findElementStyle(Element element, ColorScheme colorScheme) { + ElementStyle style = new ElementStyle(Tags.ELEMENT).shape(Shape.Box).fontSize(24).border(Border.Solid).opacity(100).metadata(true).description(true); + + if (element != null) { + Set tagsUsedToComposeStyle = new LinkedHashSet<>(); + tagsUsedToComposeStyle.add(Tags.ELEMENT); + String tags = element.getTags(); + + if (element instanceof SoftwareSystemInstance) { + SoftwareSystem ss = ((SoftwareSystemInstance)element).getSoftwareSystem(); + tags = ss.getTags() + "," + tags; + } else if (element instanceof ContainerInstance) { + Container c = ((ContainerInstance)element).getContainer(); + tags = c.getTags() + "," + tags; + } + + for (String tag : tags.split(",")) { + if (!StringUtils.isNullOrEmpty(tag)) { + ElementStyle elementStyle = findElementStyle(tag, colorScheme); + if (elementStyle != null) { + style.copyFrom(elementStyle); + tagsUsedToComposeStyle.add(elementStyle.getTag()); + } + } + } + + style.setTag(TagUtils.toString(tagsUsedToComposeStyle)); + } + + if (style.getBackground() == null) { + if (colorScheme == ColorScheme.Dark) { + style.background(DEFAULT_BACKGROUND_DARK); + if (style.getStroke() == null) { + style.stroke(DEFAULT_COLOR_DARK); + } + } else { + style.background(DEFAULT_BACKGROUND_LIGHT); + if (style.getStroke() == null) { + style.stroke(DEFAULT_COLOR_LIGHT); + } + } + } else { + if (style.getStroke() == null) { + java.awt.Color color = java.awt.Color.decode(style.getBackground()); + style.setStroke(String.format("#%06X", (0xFFFFFF & color.darker().getRGB()))); + } + } + + if (style.getColor() == null) { + if (colorScheme == ColorScheme.Dark) { + style.color(DEFAULT_COLOR_DARK); + } else { + style.color(DEFAULT_COLOR_LIGHT); + } + } + + + return style; + } + + /** + * Finds the relationship style used to render the specified relationship with a light color scheme. + */ + public RelationshipStyle findRelationshipStyle(Relationship relationship) { + return findRelationshipStyle(relationship, ColorScheme.Light); + } + + /** + * Finds the relationship style used to render the specified relationship and color scheme, according to the following rules: + * + * 1. Start with a default style. + * 2. Calculate set of tags associated with the relationship, and any linked relationship(s). + * 3. Find the style properties for each tag (themes first, followed by workspace styles) + * + * @param relationship a Relationship object + * @param colorScheme a ColorScheme (Light or Dark) + * @return a RelationshipStyle object + */ + public RelationshipStyle findRelationshipStyle(Relationship relationship, ColorScheme colorScheme) { + RelationshipStyle style = new RelationshipStyle(Tags.RELATIONSHIP).thickness(2).style(LineStyle.Dashed).dashed(true).routing(Routing.Direct).fontSize(24).width(200).position(50).opacity(100); + + if (relationship != null) { + Set tagsUsedToComposeStyle = new LinkedHashSet<>(); + tagsUsedToComposeStyle.add(Tags.RELATIONSHIP); + String tags = relationship.getTags(); + String linkedRelationshipId = relationship.getLinkedRelationshipId(); + + while (!StringUtils.isNullOrEmpty(linkedRelationshipId)) { + // the "linked relationship ID" is used for: + // - container instance -> container instance relationships + // - implied relationships + Relationship linkedRelationship = relationship.getModel().getRelationship(linkedRelationshipId); + tags = linkedRelationship.getTags() + "," + tags; + linkedRelationshipId = linkedRelationship.getLinkedRelationshipId(); + } + + for (String tag : tags.split(",")) { + if (!StringUtils.isNullOrEmpty(tag)) { + RelationshipStyle relationshipStyle = findRelationshipStyle(tag, colorScheme); + if (relationshipStyle != null) { + style.copyFrom(relationshipStyle); + tagsUsedToComposeStyle.add(relationshipStyle.getTag()); + } + } + } + + style.setTag(TagUtils.toString(tagsUsedToComposeStyle)); + } + + if (style.getColor() == null) { + if (colorScheme == ColorScheme.Dark) { + style.color(DEFAULT_COLOR_DARK); + } else { + style.color(DEFAULT_COLOR_LIGHT); + } + } + + return style; + } + + /** + * Adds the element/relationship styles from the given theme. + * + * @param theme a Theme object + */ + public void addStylesFromTheme(Theme theme) { + if (theme != null) { + themes.add(theme); + } + } + + /** + * Inlines the element and relationship styles from the specified theme, adding the styles into the workspace + * and overriding any properties already set. + * + * @param theme a Theme object + */ + public void inlineTheme(Theme theme) { + for (ElementStyle elementStyle : theme.getElements()) { + ElementStyle es = getElementStyle(elementStyle.getTag()); + if (es == null) { + es = addElementStyle(elementStyle.getTag()); + } + + es.copyFrom(elementStyle); + } + + for (RelationshipStyle relationshipStyle : theme.getRelationships()) { + RelationshipStyle rs = getRelationshipStyle(relationshipStyle.getTag()); + if (rs == null) { + rs = addRelationshipStyle(relationshipStyle.getTag()); + } + + rs.copyFrom(relationshipStyle); + } + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/SystemContextView.java b/structurizr-core/src/main/java/com/structurizr/view/SystemContextView.java similarity index 80% rename from structurizr-core/src/com/structurizr/view/SystemContextView.java rename to structurizr-core/src/main/java/com/structurizr/view/SystemContextView.java index 7d4ef11a2..3c88717da 100644 --- a/structurizr-core/src/com/structurizr/view/SystemContextView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/SystemContextView.java @@ -31,7 +31,7 @@ public final class SystemContextView extends StaticView { */ @Override public String getName() { - return getSoftwareSystem().getName() + " - System Context"; + return "System Context View: " + getSoftwareSystem().getName(); } /** @@ -39,9 +39,22 @@ public String getName() { */ @Override public void addDefaultElements() { + addDefaultElements(true); + } + + /** + * Adds the default set of elements and relationships to this view. + * + * @param greedy true (add all relationships) or false (adds relationships to/from the scoped software system only) + */ + public void addDefaultElements(boolean greedy) { addNearestNeighbours(getSoftwareSystem(), CustomElement.class); addNearestNeighbours(getSoftwareSystem(), Person.class); addNearestNeighbours(getSoftwareSystem(), SoftwareSystem.class); + + if (!greedy) { + removeRelationshipsNotConnectedToElement(getSoftwareSystem()); + } } /** @@ -72,21 +85,13 @@ public void addNearestNeighbours(@Nonnull Element element) { } } - /** - * Determines whether the enterprise boundary (to differentiate "internal" elements from "external" elements") should be visible on the resulting diagram. - * - * @return true if the enterprise boundary is visible, false otherwise - */ + @Deprecated public boolean isEnterpriseBoundaryVisible() { return enterpriseBoundaryVisible; } - /** - * Sets whether the enterprise boundary (to differentiate "internal" elements from "external" elements") should be visible on the resulting diagram. - * - * @param enterpriseBoundaryVisible true if the enterprise boundary should be visible, false otherwise - */ - public void setEnterpriseBoundaryVisible(boolean enterpriseBoundaryVisible) { + @Deprecated + void setEnterpriseBoundaryVisible(boolean enterpriseBoundaryVisible) { this.enterpriseBoundaryVisible = enterpriseBoundaryVisible; } diff --git a/structurizr-core/src/com/structurizr/view/SystemLandscapeView.java b/structurizr-core/src/main/java/com/structurizr/view/SystemLandscapeView.java similarity index 76% rename from structurizr-core/src/com/structurizr/view/SystemLandscapeView.java rename to structurizr-core/src/main/java/com/structurizr/view/SystemLandscapeView.java index 4cfbcd23d..2284e575c 100644 --- a/structurizr-core/src/com/structurizr/view/SystemLandscapeView.java +++ b/structurizr-core/src/main/java/com/structurizr/view/SystemLandscapeView.java @@ -31,8 +31,7 @@ public final class SystemLandscapeView extends StaticView { */ @Override public String getName() { - Enterprise enterprise = model.getEnterprise(); - return "System Landscape" + (enterprise != null && enterprise.getName().trim().length() > 0 ? " for " + enterprise.getName() : ""); + return "System Landscape View"; } /** @@ -55,6 +54,15 @@ void setModel(Model model) { */ @Override public void addDefaultElements() { + addDefaultElements(true); + } + + /** + * Adds the default set of elements and relationships to this view. + * + * @param greedy true (add all relationships) or false (add all relationships) + */ + public void addDefaultElements(boolean greedy) { addAllSoftwareSystems(); addAllPeople(); @@ -62,8 +70,8 @@ public void addDefaultElements() { } /** - * Adds all software systems and all people to this view. - */ + * Adds all software systems and all people to this view. + */ @Override public void addAllElements() { addAllSoftwareSystems(); @@ -89,21 +97,13 @@ public void addNearestNeighbours(@Nonnull Element element) { } } - /** - * Determines whether the enterprise boundary (to differentiate "internal" elements from "external" elements") should be visible on the resulting diagram. - * - * @return true if the enterprise boundary is visible, false otherwise - */ + @Deprecated public boolean isEnterpriseBoundaryVisible() { return enterpriseBoundaryVisible; } - /** - * Sets whether the enterprise boundary (to differentiate "internal" elements from "external" elements") should be visible on the resulting diagram. - * - * @param enterpriseBoundaryVisible true if the enterprise boundary should be visible, false otherwise - */ - public void setEnterpriseBoundaryVisible(boolean enterpriseBoundaryVisible) { + @Deprecated + void setEnterpriseBoundaryVisible(boolean enterpriseBoundaryVisible) { this.enterpriseBoundaryVisible = enterpriseBoundaryVisible; } diff --git a/structurizr-core/src/com/structurizr/view/Terminology.java b/structurizr-core/src/main/java/com/structurizr/view/Terminology.java similarity index 93% rename from structurizr-core/src/com/structurizr/view/Terminology.java rename to structurizr-core/src/main/java/com/structurizr/view/Terminology.java index 6233ff6c6..575a16a5e 100644 --- a/structurizr-core/src/com/structurizr/view/Terminology.java +++ b/structurizr-core/src/main/java/com/structurizr/view/Terminology.java @@ -40,7 +40,8 @@ public String getEnterprise() { return enterprise; } - public void setEnterprise(String enterprise) { + @Deprecated + void setEnterprise(String enterprise) { this.enterprise = enterprise; } @@ -133,6 +134,9 @@ public String findTerminology(ModelItem modelItem) { return !StringUtils.isNullOrEmpty(getDeploymentNode()) ? getDeploymentNode() : "Deployment Node"; } else if (modelItem instanceof InfrastructureNode) { return !StringUtils.isNullOrEmpty(getInfrastructureNode()) ? getInfrastructureNode() : "Infrastructure Node"; + } else if (modelItem instanceof CustomElement) { + String terminology = ((CustomElement)modelItem).getMetadata(); + return !StringUtils.isNullOrEmpty(terminology) ? terminology : "Element"; } throw new IllegalArgumentException("Unknown model item type."); diff --git a/structurizr-core/src/com/structurizr/view/Theme.java b/structurizr-core/src/main/java/com/structurizr/view/Theme.java similarity index 67% rename from structurizr-core/src/com/structurizr/view/Theme.java rename to structurizr-core/src/main/java/com/structurizr/view/Theme.java index 07484b986..1d30657f6 100644 --- a/structurizr-core/src/com/structurizr/view/Theme.java +++ b/structurizr-core/src/main/java/com/structurizr/view/Theme.java @@ -1,6 +1,8 @@ package com.structurizr.view; import com.fasterxml.jackson.annotation.JsonGetter; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; import java.util.Collection; import java.util.LinkedList; @@ -11,6 +13,8 @@ final class Theme { private String description; private Collection elements = new LinkedList<>(); private Collection relationships = new LinkedList<>(); + private String logo; + private Font font; Theme() { } @@ -61,4 +65,35 @@ void setRelationships(Collection relationships) { this.relationships = relationships; } + public String getLogo() { + return logo; + } + + /** + * Sets the URL of an image representing a logo. + * + * @param logo a URL or data URI as a String + */ + public void setLogo(String logo) { + if (StringUtils.isNullOrEmpty(logo)) { + this.logo = null; + } else { + ImageUtils.validateImage(logo); + this.logo = logo.trim(); + } + } + + public Font getFont() { + return font; + } + + /** + * Sets the font to use. + * + * @param font a Font object + */ + public void setFont(Font font) { + this.font = font; + } + } \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/Vertex.java b/structurizr-core/src/main/java/com/structurizr/view/Vertex.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/Vertex.java rename to structurizr-core/src/main/java/com/structurizr/view/Vertex.java diff --git a/structurizr-core/src/main/java/com/structurizr/view/View.java b/structurizr-core/src/main/java/com/structurizr/view/View.java new file mode 100644 index 000000000..b4132bcd7 --- /dev/null +++ b/structurizr-core/src/main/java/com/structurizr/view/View.java @@ -0,0 +1,178 @@ +package com.structurizr.view; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.structurizr.PropertyHolder; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * The superclass for all views. + */ +public abstract class View implements PropertyHolder, Comparable { + + private String key; + private boolean generatedKey = false; + + private int order; + private String title; + private String description; + private Map properties = new HashMap<>(); + + private ViewSet viewSet; + + View() { + } + + /** + * Gets the description of this view. + * + * @return the description, as a String + */ + public String getDescription() { + return description; + } + + public void setDescription(String description) { + if (description == null) { + this.description = ""; + } else { + this.description = description; + } + } + + /** + * Gets the identifier for this view. + * + * @return the identifier, as a String + */ + public String getKey() { + return key; + } + + void setKey(String key) { + if (key != null) { + key = key.replaceAll("/", "_"); + } + + this.key = key; + } + + /** + * Returns true if this view has an automatically generated view key, false otherwise. + * + * @return true if this view has an automatically generated view key, false otherwise + */ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public boolean isGeneratedKey() { + return generatedKey; + } + + void setGeneratedKey(boolean generatedKey) { + this.generatedKey = generatedKey; + } + + /** + * Gets the order of this view. + * + * @return a positive integer + */ + public int getOrder() { + return order; + } + + /** + * Sets the order of this view. + * + * @param order a positive integer + */ + public void setOrder(int order) { + this.order = Math.max(1, order); + } + + /** + * Gets the title of this view, if one has been set. + * + * @return the title, as a String + */ + public String getTitle() { + return title; + } + + /** + * Sets the title for this view. + * + * @param title the title, as a String + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Gets the (computed) name of this view. + * + * @return the name, as a String + */ + public abstract String getName(); + + void setViewSet(@Nonnull ViewSet viewSet) { + this.viewSet = viewSet; + } + + /** + * Gets the view set that this view belongs to. + * + * @return a ViewSet object + */ + @JsonIgnore + public ViewSet getViewSet() { + return viewSet; + } + + /** + * Gets the collection of name-value property pairs associated with this view, as a Map. + * + * @return a Map (String, String) (empty if there are no properties) + */ + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Adds a name-value pair property to this view. + * + * @param name the name of the property + * @param value the value of the property + */ + public void addProperty(String name, String value) { + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException("A property name must be specified."); + } + + if (value == null || value.trim().length() == 0) { + throw new IllegalArgumentException("A property value must be specified."); + } + + properties.put(name, value); + } + + void setProperties(Map properties) { + if (properties != null) { + this.properties = new HashMap<>(properties); + } + } + + @Override + public int compareTo(View view) { + int result = getOrder() - view.getOrder(); + if (result == 0) { + result = getKey().compareToIgnoreCase(view.getKey()); + } + + return result; + } + +} \ No newline at end of file diff --git a/structurizr-core/src/com/structurizr/view/ViewSet.java b/structurizr-core/src/main/java/com/structurizr/view/ViewSet.java similarity index 61% rename from structurizr-core/src/com/structurizr/view/ViewSet.java rename to structurizr-core/src/main/java/com/structurizr/view/ViewSet.java index d68273004..c91c3c4e3 100644 --- a/structurizr-core/src/com/structurizr/view/ViewSet.java +++ b/structurizr-core/src/main/java/com/structurizr/view/ViewSet.java @@ -8,6 +8,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.util.*; import static com.structurizr.util.StringUtils.isNullOrEmpty; @@ -19,17 +21,28 @@ public final class ViewSet { private static final Log log = LogFactory.getLog(ViewSet.class); + public static final String SYSTEM_LANDSCAPE_VIEW_TYPE = "SystemLandscape"; + public static final String SYSTEM_CONTEXT_VIEW_TYPE = "SystemContext"; + public static final String CONTAINER_VIEW_TYPE = "Container"; + public static final String COMPONENT_VIEW_TYPE = "Component"; + public static final String DYNAMIC_VIEW_TYPE = "Dynamic"; + public static final String DEPLOYMENT_VIEW_TYPE = "Deployment"; + public static final String FILTERED_VIEW_TYPE = "Filtered"; + public static final String IMAGE_VIEW_TYPE = "Image"; + public static final String CUSTOM_VIEW_TYPE = "Custom"; + private Model model; - private Collection customViews = new HashSet<>(); - private Collection systemLandscapeViews = new HashSet<>(); - private Collection systemContextViews = new HashSet<>(); - private Collection containerViews = new HashSet<>(); - private Collection componentViews = new HashSet<>(); - private Collection dynamicViews = new HashSet<>(); - private Collection deploymentViews = new HashSet<>(); + private Collection customViews = new TreeSet<>(); + private Collection systemLandscapeViews = new TreeSet<>(); + private Collection systemContextViews = new TreeSet<>(); + private Collection containerViews = new TreeSet<>(); + private Collection componentViews = new TreeSet<>(); + private Collection dynamicViews = new TreeSet<>(); + private Collection deploymentViews = new TreeSet<>(); + private Collection imageViews = new TreeSet<>(); - private Collection filteredViews = new HashSet<>(); + private Collection filteredViews = new TreeSet<>(); private Configuration configuration = new Configuration(); @@ -41,7 +54,19 @@ public final class ViewSet { } /** - * Creates a custom view view. + * Creates a custom view. + * + * @param key the key for the view (must be unique) + * @param title a title of the view + * @return a CustomView object + * @throws IllegalArgumentException if the key is not unique + */ + public CustomView createCustomView(String key, String title) { + return createCustomView(key, title, ""); + } + + /** + * Creates a custom view. * * @param key the key for the view (must be unique) * @param title a title of the view @@ -50,15 +75,34 @@ public final class ViewSet { * @throws IllegalArgumentException if the key is not unique */ public CustomView createCustomView(String key, String title, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(CUSTOM_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheViewKeyIsSpecifiedAndUnique(key); CustomView view = new CustomView(model, key, title, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); customViews.add(view); return view; } + /** + * Creates a system landscape view. + * + * @param key the key for the view (must be unique) + * @return a SystemLandscapeView object + * @throws IllegalArgumentException if the key is not unique + */ + public SystemLandscapeView createSystemLandscapeView(String key) { + return createSystemLandscapeView(key, ""); + } + /** * Creates a system landscape view. * @@ -68,15 +112,36 @@ public CustomView createCustomView(String key, String title, String description) * @throws IllegalArgumentException if the key is not unique */ public SystemLandscapeView createSystemLandscapeView(String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(SYSTEM_LANDSCAPE_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheViewKeyIsSpecifiedAndUnique(key); SystemLandscapeView view = new SystemLandscapeView(model, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); systemLandscapeViews.add(view); + return view; } + /** + * Creates a system context view, where the scope of the view is the specified software system. + * + * @param softwareSystem the SoftwareSystem object representing the scope of the view + * @param key the key for the view (must be unique) + * @return a SystemContextView object + * @throws IllegalArgumentException if the software system is null or the key is not unique + */ + public SystemContextView createSystemContextView(SoftwareSystem softwareSystem, String key) { + return createSystemContextView(softwareSystem, key, ""); + } + /** * Creates a system context view, where the scope of the view is the specified software system. * @@ -87,16 +152,36 @@ public SystemLandscapeView createSystemLandscapeView(String key, String descript * @throws IllegalArgumentException if the software system is null or the key is not unique */ public SystemContextView createSystemContextView(SoftwareSystem softwareSystem, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(SYSTEM_CONTEXT_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheSoftwareSystemIsNotNull(softwareSystem); assertThatTheViewKeyIsSpecifiedAndUnique(key); SystemContextView view = new SystemContextView(softwareSystem, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); systemContextViews.add(view); return view; } + /** + * Creates a container view, where the scope of the view is the specified software system. + * + * @param softwareSystem the SoftwareSystem object representing the scope of the view + * @param key the key for the view (must be unique) + * @return a ContainerView object + * @throws IllegalArgumentException if the software system is null or the key is not unique + */ + public ContainerView createContainerView(SoftwareSystem softwareSystem, String key) { + return createContainerView(softwareSystem, key, ""); + } + /** * Creates a container view, where the scope of the view is the specified software system. * @@ -107,16 +192,36 @@ public SystemContextView createSystemContextView(SoftwareSystem softwareSystem, * @throws IllegalArgumentException if the software system is null or the key is not unique */ public ContainerView createContainerView(SoftwareSystem softwareSystem, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(CONTAINER_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheSoftwareSystemIsNotNull(softwareSystem); assertThatTheViewKeyIsSpecifiedAndUnique(key); ContainerView view = new ContainerView(softwareSystem, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); containerViews.add(view); return view; } + /** + * Creates a component view, where the scope of the view is the specified container. + * + * @param container the Container object representing the scope of the view + * @param key the key for the view (must be unique) + * @return a ContainerView object + * @throws IllegalArgumentException if the container is null or the key is not unique + */ + public ComponentView createComponentView(Container container, String key) { + return createComponentView(container, key, ""); + } + /** * Creates a component view, where the scope of the view is the specified container. * @@ -127,16 +232,35 @@ public ContainerView createContainerView(SoftwareSystem softwareSystem, String k * @throws IllegalArgumentException if the container is null or the key is not unique */ public ComponentView createComponentView(Container container, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(COMPONENT_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheContainerIsNotNull(container); assertThatTheViewKeyIsSpecifiedAndUnique(key); ComponentView view = new ComponentView(container, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); componentViews.add(view); return view; } + /** + * Creates a dynamic view. + * + * @param key the key for the view (must be unique) + * @return a DynamicView object + * @throws IllegalArgumentException if the key is not unique + */ + public DynamicView createDynamicView(String key) { + return createDynamicView(key, ""); + } + /** * Creates a dynamic view. * @@ -146,15 +270,42 @@ public ComponentView createComponentView(Container container, String key, String * @throws IllegalArgumentException if the key is not unique */ public DynamicView createDynamicView(String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(DYNAMIC_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheViewKeyIsSpecifiedAndUnique(key); DynamicView view = new DynamicView(model, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); dynamicViews.add(view); return view; } + /** + * Creates a dynamic view, where the scope is the specified software system. The following + * elements can be added to the resulting view: + * + *

    + *
  • People
  • + *
  • Software systems
  • + *
  • Containers that reside inside the specified software system
  • + *
+ * + * @param softwareSystem the SoftwareSystem object representing the scope of the view + * @param key the key for the view (must be unique) + * @return a DynamicView object + * @throws IllegalArgumentException if the software system is null or the key is not unique + */ + public DynamicView createDynamicView(SoftwareSystem softwareSystem, String key) { + return createDynamicView(softwareSystem, key, ""); + } + /** * Creates a dynamic view, where the scope is the specified software system. The following * elements can be added to the resulting view: @@ -172,16 +323,44 @@ public DynamicView createDynamicView(String key, String description) { * @throws IllegalArgumentException if the software system is null or the key is not unique */ public DynamicView createDynamicView(SoftwareSystem softwareSystem, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(DYNAMIC_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheSoftwareSystemIsNotNull(softwareSystem); assertThatTheViewKeyIsSpecifiedAndUnique(key); DynamicView view = new DynamicView(softwareSystem, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); dynamicViews.add(view); return view; } + /** + * Creates a dynamic view, where the scope is the specified container. The following + * elements can be added to the resulting view: + * + *
    + *
  • People
  • + *
  • Software systems
  • + *
  • Containers with the same parent software system as the specified container
  • + *
  • Components within the specified container
  • + *
+ * + * @param container the Container object representing the scope of the view + * @param key the key for the view (must be unique) + * @return a DynamicView object + * @throws IllegalArgumentException if the container is null or the key is not unique + */ + public DynamicView createDynamicView(Container container, String key) { + return createDynamicView(container, key, ""); + } + /** * Creates a dynamic view, where the scope is the specified container. The following * elements can be added to the resulting view: @@ -200,16 +379,35 @@ public DynamicView createDynamicView(SoftwareSystem softwareSystem, String key, * @throws IllegalArgumentException if the container is null or the key is not unique */ public DynamicView createDynamicView(Container container, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(DYNAMIC_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheContainerIsNotNull(container); assertThatTheViewKeyIsSpecifiedAndUnique(key); DynamicView view = new DynamicView(container, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); dynamicViews.add(view); return view; } + /** + * Creates a deployment view. + * + * @param key the key for the deployment view (must be unique) + * @return a DeploymentView object + * @throws IllegalArgumentException if the key is not unique + */ + public DeploymentView createDeploymentView(String key) { + return createDeploymentView(key, ""); + } + /** * Creates a deployment view. * @@ -219,15 +417,35 @@ public DynamicView createDynamicView(Container container, String key, String des * @throws IllegalArgumentException if the key is not unique */ public DeploymentView createDeploymentView(String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(DEPLOYMENT_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheViewKeyIsSpecifiedAndUnique(key); DeploymentView view = new DeploymentView(model, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); deploymentViews.add(view); return view; } + /** + * Creates a deployment view, where the scope of the view is the specified software system. + * + * @param softwareSystem the SoftwareSystem object representing the scope of the view + * @param key the key for the deployment view (must be unique) + * @return a DeploymentView object + * @throws IllegalArgumentException if the software system is null or the key is not unique + */ + public DeploymentView createDeploymentView(SoftwareSystem softwareSystem, String key) { + return createDeploymentView(softwareSystem, key, ""); + } + /** * Creates a deployment view, where the scope of the view is the specified software system. * @@ -238,16 +456,37 @@ public DeploymentView createDeploymentView(String key, String description) { * @throws IllegalArgumentException if the software system is null or the key is not unique */ public DeploymentView createDeploymentView(SoftwareSystem softwareSystem, String key, String description) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(DEPLOYMENT_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheSoftwareSystemIsNotNull(softwareSystem); assertThatTheViewKeyIsSpecifiedAndUnique(key); DeploymentView view = new DeploymentView(softwareSystem, key, description); + view.setGeneratedKey(keyIsAutomaticallyGenerated); view.setOrder(getNextOrder()); view.setViewSet(this); deploymentViews.add(view); return view; } + /** + * Creates a FilteredView on top of an existing static view. + * + * @param view the static view to base the FilteredView upon + * @param key the key for the filtered view (must be unique) + * @param mode whether to Include or Exclude elements/relationships based upon their tag + * @param tags the tags to include or exclude + * @return a FilteredView object + */ + public FilteredView createFilteredView(StaticView view, String key, FilterMode mode, String... tags) { + return createFilteredView(view, key, "", mode, tags); + } + /** * Creates a FilteredView on top of an existing static view. * @@ -259,21 +498,102 @@ public DeploymentView createDeploymentView(SoftwareSystem softwareSystem, String * @return a FilteredView object */ public FilteredView createFilteredView(StaticView view, String key, String description, FilterMode mode, String... tags) { + return newFilteredView(view, key, description, mode, tags); + } + + /** + * Creates a FilteredView on top of an existing deployment view. + * + * @param view the deployment view to base the FilteredView upon + * @param key the key for the filtered view (must be unique) + * @param description a description + * @param mode whether to Include or Exclude elements/relationships based upon their tag + * @param tags the tags to include or exclude + * @return a FilteredView object + */ + public FilteredView createFilteredView(DeploymentView view, String key, String description, FilterMode mode, String... tags) { + return newFilteredView(view, key, description, mode, tags); + } + + private FilteredView newFilteredView(ModelView view, String key, String description, FilterMode mode, String... tags) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(FILTERED_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + assertThatTheViewIsNotNull(view); assertThatTheViewKeyIsSpecifiedAndUnique(key); FilteredView filteredView = new FilteredView(view, key, description, mode, tags); + filteredView.setGeneratedKey(keyIsAutomaticallyGenerated); filteredView.setOrder(getNextOrder()); + filteredView.setViewSet(this); filteredViews.add(filteredView); return filteredView; } + /** + * Creates an image view. + * + * @param key the key for the view (must be unique) + * @return an ImageView object + * @throws IllegalArgumentException if the key is not unique + */ + public ImageView createImageView(String key) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(IMAGE_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + + assertThatTheViewKeyIsSpecifiedAndUnique(key); + + ImageView view = new ImageView(key); + view.setGeneratedKey(keyIsAutomaticallyGenerated); + view.setOrder(getNextOrder()); + view.setViewSet(this); + imageViews.add(view); + return view; + } + + /** + * Creates an image view, where the scope is the specified element. + * + * @param element the Element object representing the scope of the view + * @param key the key for the view (must be unique) + * @return an ImageView object + * @throws IllegalArgumentException if the element is null or the key is not unique + */ + public ImageView createImageView(Element element, String key) { + boolean keyIsAutomaticallyGenerated = false; + + if (StringUtils.isNullOrEmpty(key)) { + key = generateViewKey(IMAGE_VIEW_TYPE); + keyIsAutomaticallyGenerated = true; + } + + if (element == null) { + throw new IllegalArgumentException("An element must be specified."); + } + assertThatTheViewKeyIsSpecifiedAndUnique(key); + + ImageView view = new ImageView(element, key); + view.setGeneratedKey(keyIsAutomaticallyGenerated); + view.setOrder(getNextOrder()); + view.setViewSet(this); + imageViews.add(view); + return view; + } + private void assertThatTheViewKeyIsSpecifiedAndUnique(String key) { if (StringUtils.isNullOrEmpty(key)) { throw new IllegalArgumentException("A key must be specified."); } - if (getViewWithKey(key) != null || getFilteredViewWithKey(key) != null) { + if (getViewWithKey(key) != null || getFilteredViewWithKey(key) != null || getImageViewWithKey(key) != null) { throw new IllegalArgumentException("A view with the key " + key + " already exists."); } } @@ -302,21 +622,55 @@ private void assertThatTheViewIsNotNull(View view) { * @param key the key * @return a View object, or null if a view with the specified key could not be found */ - View getViewWithKey(String key) { + public View getViewWithKey(String key) { if (key == null) { throw new IllegalArgumentException("A key must be specified."); } - Set views = new HashSet<>(); - views.addAll(customViews); - views.addAll(systemLandscapeViews); - views.addAll(systemContextViews); - views.addAll(containerViews); - views.addAll(componentViews); - views.addAll(dynamicViews); - views.addAll(deploymentViews); + return getViews().stream().filter(v -> key.equals(v.getKey())).findFirst().orElse(null); + } + + /** + * Removes the view with the specified key. + * + * @param key the key + * @throws IllegalArgumentException if a view with the specified key could not be found + */ + public void removeViewWithKey(String key) { + if (StringUtils.isNullOrEmpty(key)) { + throw new IllegalArgumentException("A view key must be specified."); + } + + View view = getViewWithKey(key); + if (view == null) { + throw new IllegalArgumentException("A view with key \"" + key + "\" does not exist."); + } - return views.stream().filter(v -> key.equals(v.getKey())).findFirst().orElse(null); + for (FilteredView filteredView : filteredViews) { + if (filteredView.getBaseViewKey().equals(key)) { + throw new IllegalArgumentException("A filtered view based upon \"" + key + "\" exists - please remove this first."); + } + } + + if (view instanceof CustomView) { + customViews.remove(view); + } else if (view instanceof SystemLandscapeView) { + systemLandscapeViews.remove(view); + } else if (view instanceof SystemContextView) { + systemContextViews.remove(view); + } else if (view instanceof ContainerView) { + containerViews.remove(view); + } else if (view instanceof ComponentView) { + componentViews.remove(view); + } else if (view instanceof DynamicView) { + dynamicViews.remove(view); + } else if (view instanceof DeploymentView) { + deploymentViews.remove(view); + } else if (view instanceof ImageView) { + imageViews.remove(view); + } else if (view instanceof FilteredView) { + filteredViews.remove(view); + } } /** @@ -333,18 +687,32 @@ FilteredView getFilteredViewWithKey(String key) { return filteredViews.stream().filter(v -> key.equals(v.getKey())).findFirst().orElse(null); } + /** + * Finds the image view with the specified key, or null if the view does not exist. + * + * @param key the key + * @return a ImageView object, or null if a view with the specified key could not be found + */ + ImageView getImageViewWithKey(String key) { + if (key == null) { + throw new IllegalArgumentException("A key must be specified."); + } + + return imageViews.stream().filter(v -> key.equals(v.getKey())).findFirst().orElse(null); + } + /** * Gets the set of custom views. * * @return a Collection of CustomView objects */ public Collection getCustomViews() { - return new HashSet<>(customViews); + return new TreeSet<>(customViews); } void setCustomViews(Set customViews) { if (customViews != null) { - this.customViews = new HashSet<>(customViews); + this.customViews = new TreeSet<>(customViews); } } @@ -354,12 +722,12 @@ void setCustomViews(Set customViews) { * @return a Collection of SystemLandscapeView objects */ public Collection getSystemLandscapeViews() { - return new HashSet<>(systemLandscapeViews); + return new TreeSet<>(systemLandscapeViews); } void setSystemLandscapeViews(Set systemLandscapeViews) { if (systemLandscapeViews != null) { - this.systemLandscapeViews = new HashSet<>(systemLandscapeViews); + this.systemLandscapeViews = new TreeSet<>(systemLandscapeViews); } } @@ -369,7 +737,7 @@ void setSystemLandscapeViews(Set systemLandscapeViews) { @JsonSetter("enterpriseContextViews") void setEnterpriseContextViews(Collection enterpriseContextViews) { if (enterpriseContextViews != null) { - this.systemLandscapeViews = new HashSet<>(enterpriseContextViews); + this.systemLandscapeViews = new TreeSet<>(enterpriseContextViews); } } @@ -379,12 +747,12 @@ void setEnterpriseContextViews(Collection enterpriseContext * @return a Collection of SystemContextView objects */ public Collection getSystemContextViews() { - return new HashSet<>(systemContextViews); + return new TreeSet<>(systemContextViews); } void setSystemContextViews(Set systemContextViews) { if (systemContextViews != null) { - this.systemContextViews = new HashSet<>(systemContextViews); + this.systemContextViews = new TreeSet<>(systemContextViews); } } @@ -394,12 +762,12 @@ void setSystemContextViews(Set systemContextViews) { * @return a Collection of ContainerView objects */ public Collection getContainerViews() { - return new HashSet<>(containerViews); + return new TreeSet<>(containerViews); } void setContainerViews(Set containerViews) { if (containerViews != null) { - this.containerViews = new HashSet<>(containerViews); + this.containerViews = new TreeSet<>(containerViews); } } @@ -409,12 +777,12 @@ void setContainerViews(Set containerViews) { * @return a Collection of ComponentView objects */ public Collection getComponentViews() { - return new HashSet<>(componentViews); + return new TreeSet<>(componentViews); } void setComponentViews(Set componentViews) { if (componentViews != null) { - this.componentViews = new HashSet<>(componentViews); + this.componentViews = new TreeSet<>(componentViews); } } @@ -424,55 +792,73 @@ void setComponentViews(Set componentViews) { * @return a Collection of DynamicView objects */ public Collection getDynamicViews() { - return new HashSet<>(dynamicViews); + return new TreeSet<>(dynamicViews); } void setDynamicViews(Set dynamicViews) { if (dynamicViews != null) { - this.dynamicViews = new HashSet<>(dynamicViews); + this.dynamicViews = new TreeSet<>(dynamicViews); } } public Collection getFilteredViews() { - return new HashSet<>(filteredViews); + return new TreeSet<>(filteredViews); } void setFilteredViews(Set filteredViews) { if (filteredViews != null) { - this.filteredViews = new HashSet<>(filteredViews); + this.filteredViews = new TreeSet<>(filteredViews); } } /** - * Gets the set of dynamic views. + * Gets the set of deployment views. * - * @return a Collection of DynamicView objects + * @return a Collection of DeploymentView objects */ public Collection getDeploymentViews() { - return new HashSet<>(deploymentViews); + return new TreeSet<>(deploymentViews); } void setDeploymentViews(Set deploymentViews) { if (deploymentViews != null) { - this.deploymentViews = new HashSet<>(deploymentViews); + this.deploymentViews = new TreeSet<>(deploymentViews); + } + } + + /** + * Gets the set of image views. + * + * @return a Collection of ImageView objects + */ + public Collection getImageViews() { + return new TreeSet<>(imageViews); + } + + void setImageViews(Set imageViews) { + if (imageViews != null) { + this.imageViews = new TreeSet<>(imageViews); } } /** - * Gets the set of all views (except filtered views). + * Gets the set of all views. * * @return a Collection of View objects */ @JsonIgnore public Collection getViews() { - HashSet views = new HashSet<>(); + Set views = new TreeSet<>(); + views.addAll(getCustomViews()); views.addAll(getSystemLandscapeViews()); views.addAll(getSystemContextViews()); views.addAll(getContainerViews()); views.addAll(getComponentViews()); views.addAll(getDynamicViews()); views.addAll(getDeploymentViews()); + views.addAll(getFilteredViews()); + views.addAll(getImageViews()); return views; } @@ -575,11 +961,32 @@ void hydrate(Model model) { ); } - filteredView.setView(view); + if (view instanceof StaticView || view instanceof DeploymentView) { + filteredView.setView((ModelView)view); + } else { + throw new WorkspaceValidationException( + String.format("The filtered view with key \"%s\" is based upon a view (key=%s), but that view is not a static or deployment view.", + filteredView.getKey(), filteredView.getBaseViewKey()) + ); + } + } + + for (ImageView view : imageViews) { + if (!isNullOrEmpty(view.getElementId())) { + Element element = model.getElement(view.getElementId()); + if (element == null) { + throw new WorkspaceValidationException( + String.format("The image view with key \"%s\" is associated with an element (id=%s), but that element does not exist in the model.", + view.getKey(), view.getElementId()) + ); + } + + view.setElement(element); + } } } - private void hydrateView(View view) { + private void hydrateView(ModelView view) { view.setViewSet(this); for (ElementView elementView : view.getElements()) { @@ -609,45 +1016,18 @@ private void hydrateView(View view) { private void checkViewKeysAreUnique() { Set keys = new HashSet<>(); - Collection views = new ArrayList<>(); - views.addAll(customViews); - views.addAll(systemLandscapeViews); - views.addAll(systemContextViews); - views.addAll(containerViews); - views.addAll(componentViews); - views.addAll(dynamicViews); - views.addAll(deploymentViews); - - for (View view : views) { + + for (View view : getViews()) { if (keys.contains(view.getKey())) { throw new WorkspaceValidationException("A view with the key " + view.getKey() + " already exists."); } else { keys.add(view.getKey()); } } - - for (FilteredView filteredView : filteredViews) { - if (keys.contains(filteredView.getKey())) { - throw new WorkspaceValidationException("A view with the key " + filteredView.getKey() + " already exists."); - } else { - keys.add(filteredView.getKey()); - } - } } private synchronized int getNextOrder() { - int order = 0; - - order = Math.max(order, customViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, systemLandscapeViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, systemContextViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, containerViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, componentViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, dynamicViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, deploymentViews.stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0)); - order = Math.max(order, filteredViews.stream().max(Comparator.comparingInt(FilteredView::getOrder)).map(FilteredView::getOrder).orElse(0)); - - return order + 1; + return getViews().stream().max(Comparator.comparingInt(View::getOrder)).map(View::getOrder).orElse(0) + 1; } /** @@ -762,15 +1142,31 @@ private T findView(Collection views, T sourceView) { @JsonIgnore public boolean isEmpty() { - return customViews.isEmpty() && systemLandscapeViews.isEmpty() && systemContextViews.isEmpty() && containerViews.isEmpty() && componentViews.isEmpty() && dynamicViews.isEmpty() && deploymentViews.isEmpty() && filteredViews.isEmpty(); + return customViews.isEmpty() && systemLandscapeViews.isEmpty() && systemContextViews.isEmpty() && containerViews.isEmpty() && componentViews.isEmpty() && dynamicViews.isEmpty() && deploymentViews.isEmpty() && filteredViews.isEmpty() && imageViews.isEmpty(); + } + + private String generateViewKey(String prefix) { + NumberFormat format = new DecimalFormat("000"); + int counter = 1; + String key = prefix + "-" + format.format(counter); + + while (hasViewWithKey(key)) { + counter++; + key = prefix + "-" + format.format(counter); + } + + return key; + } + + private boolean hasViewWithKey(String key) { + return getViews().stream().anyMatch(view -> view.getKey().equals(key)); } public void createDefaultViews() { // create a single System Landscape diagram containing all people and software systems - SystemLandscapeView systemLandscapeView = createSystemLandscapeView("SystemLandscape", ""); + SystemLandscapeView systemLandscapeView = createSystemLandscapeView("", ""); systemLandscapeView.addDefaultElements(); systemLandscapeView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); - systemLandscapeView.setEnterpriseBoundaryVisible(true); if (!model.getSoftwareSystems().isEmpty()) { List softwareSystems = new ArrayList<>(model.getSoftwareSystems()); @@ -778,29 +1174,23 @@ public void createDefaultViews() { // and a system context view plus container view for each software system for (SoftwareSystem softwareSystem : softwareSystems) { - String systemContextViewKey = removeNonWordCharacters(softwareSystem.getName()) + "-SystemContext"; - SystemContextView systemContextView = createSystemContextView(softwareSystem, systemContextViewKey, ""); + SystemContextView systemContextView = createSystemContextView(softwareSystem, "", ""); systemContextView.addDefaultElements(); systemContextView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); - systemContextView.setEnterpriseBoundaryVisible(true); if (softwareSystem.getContainers().size() > 0) { List containers = new ArrayList<>(softwareSystem.getContainers()); containers.sort(Comparator.comparing(Element::getName)); - String containerViewKey = removeNonWordCharacters(softwareSystem.getName()) + "-Container"; - ContainerView containerView = createContainerView(softwareSystem, containerViewKey, ""); + ContainerView containerView = createContainerView(softwareSystem, "", ""); containerView.addDefaultElements(); containerView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); - containerView.setExternalSoftwareSystemBoundariesVisible(true); for (Container container : containers) { if (container.getComponents().size() > 0) { - String componentViewKey = removeNonWordCharacters(softwareSystem.getName()) + "-" + removeNonWordCharacters(container.getName()) + "-Component"; - ComponentView componentView = createComponentView(container, componentViewKey, ""); + ComponentView componentView = createComponentView(container, "", ""); componentView.addDefaultElements(); componentView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); - componentView.setExternalSoftwareSystemBoundariesVisible(true); } } } @@ -842,8 +1232,7 @@ public void createDefaultViews() { if (softwareSystems.isEmpty()) { // there are no container instances, but perhaps there are infrastructure nodes in this environment if (model.getElements().stream().anyMatch(e -> e instanceof InfrastructureNode && ((InfrastructureNode)e).getEnvironment().equals(deploymentEnvironment))) { - String deploymentViewKey = removeNonWordCharacters(deploymentEnvironment) + "-Deployment"; - DeploymentView deploymentView = createDeploymentView(deploymentViewKey, ""); + DeploymentView deploymentView = createDeploymentView("", ""); deploymentView.setEnvironment(deploymentEnvironment); deploymentView.addDefaultElements(); deploymentView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); @@ -852,8 +1241,7 @@ public void createDefaultViews() { softwareSystems.sort(Comparator.comparing(Element::getName)); for (SoftwareSystem softwareSystem : softwareSystems) { - String deploymentViewKey = removeNonWordCharacters(softwareSystem.getName()) + "-" + removeNonWordCharacters(deploymentEnvironment) + "-Deployment"; - DeploymentView deploymentView = createDeploymentView(softwareSystem, deploymentViewKey, ""); + DeploymentView deploymentView = createDeploymentView(softwareSystem, "", ""); deploymentView.setEnvironment(deploymentEnvironment); deploymentView.addDefaultElements(); deploymentView.enableAutomaticLayout(AutomaticLayout.RankDirection.TopBottom, 300, 300); @@ -862,12 +1250,8 @@ public void createDefaultViews() { } } - private String removeNonWordCharacters(String name) { - return name.replaceAll("\\W", ""); - } - private Set getSoftwareSystemInstances(DeploymentNode deploymentNode) { - Set softwareSystemInstances = new HashSet<>(deploymentNode.getSoftwareSystemInstances()); + Set softwareSystemInstances = new TreeSet<>(deploymentNode.getSoftwareSystemInstances()); for (DeploymentNode child : deploymentNode.getChildren()) { softwareSystemInstances.addAll(getSoftwareSystemInstances(child)); @@ -877,7 +1261,7 @@ private Set getSoftwareSystemInstances(DeploymentNode de } private Set getContainerInstances(DeploymentNode deploymentNode) { - Set containerInstances = new HashSet<>(deploymentNode.getContainerInstances()); + Set containerInstances = new TreeSet<>(deploymentNode.getContainerInstances()); for (DeploymentNode child : deploymentNode.getChildren()) { containerInstances.addAll(getContainerInstances(child)); @@ -890,14 +1274,14 @@ private Set getContainerInstances(DeploymentNode deploymentNo * Removes all views and configuration. */ public void clear() { - customViews = new HashSet<>(); - systemLandscapeViews = new HashSet<>(); - systemContextViews = new HashSet<>(); - containerViews = new HashSet<>(); - componentViews = new HashSet<>(); - dynamicViews = new HashSet<>(); - deploymentViews = new HashSet<>(); - filteredViews = new HashSet<>(); + customViews = new TreeSet<>(); + systemLandscapeViews = new TreeSet<>(); + systemContextViews = new TreeSet<>(); + containerViews = new TreeSet<>(); + componentViews = new TreeSet<>(); + dynamicViews = new TreeSet<>(); + deploymentViews = new TreeSet<>(); + filteredViews = new TreeSet<>(); configuration = new Configuration(); } diff --git a/structurizr-core/src/com/structurizr/view/ViewSortOrder.java b/structurizr-core/src/main/java/com/structurizr/view/ViewSortOrder.java similarity index 100% rename from structurizr-core/src/com/structurizr/view/ViewSortOrder.java rename to structurizr-core/src/main/java/com/structurizr/view/ViewSortOrder.java diff --git a/structurizr-core/src/test/java/com/structurizr/AbstractWorkspaceTestBase.java b/structurizr-core/src/test/java/com/structurizr/AbstractWorkspaceTestBase.java new file mode 100644 index 000000000..91be93b65 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/AbstractWorkspaceTestBase.java @@ -0,0 +1,12 @@ +package com.structurizr; + +import com.structurizr.model.Model; +import com.structurizr.view.ViewSet; + +public class AbstractWorkspaceTestBase { + + protected Workspace workspace = new Workspace("Name", "Description"); + protected Model model = workspace.getModel(); + protected ViewSet views = workspace.getViews(); + +} diff --git a/structurizr-core/src/test/java/com/structurizr/WorkspaceTests.java b/structurizr-core/src/test/java/com/structurizr/WorkspaceTests.java new file mode 100644 index 000000000..ad55331ad --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/WorkspaceTests.java @@ -0,0 +1,331 @@ +package com.structurizr; + +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Format; +import com.structurizr.model.*; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class WorkspaceTests { + + private Workspace workspace = new Workspace("Name", "Description"); + + @Test + void isEmpty_ReturnsTrue_WhenThereAreNoElementsViewsOrDocumentation() { + workspace = new Workspace("Name", "Description"); + assertTrue(workspace.isEmpty()); + } + + @Test + void isEmpty_ReturnsFalse_WhenThereAreElements() { + workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("Name", "Description"); + assertFalse(workspace.isEmpty()); + } + + @Test + void isEmpty_ReturnsFalse_WhenThereAreViews() { + workspace = new Workspace("Name", "Description"); + workspace.getViews().createSystemLandscapeView("key", "Description"); + assertFalse(workspace.isEmpty()); + } + + @Test + void isEmpty_ReturnsFalse_WhenThereIsDocumentation() throws Exception { + workspace = new Workspace("Name", "Description"); + Decision d = new Decision("1"); + d.setTitle("Title"); + d.setContent("Content"); + d.setStatus("Proposed"); + d.setFormat(Format.Markdown); + workspace.getDocumentation().addDecision(d); + assertFalse(workspace.isEmpty()); + } + + @Test + void hydrate_DoesNotCrash() { + Workspace workspace = new Workspace("Name", "Description"); + assertNotNull(workspace.getViews()); + assertNotNull(workspace.getDocumentation()); + + // check that the hydrate method doesn't crash (it includes some method calls via reflection) + workspace.hydrate(); + } + + @Test + void remove_WhenACustomElementIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + CustomElement element = workspace.getModel().addCustomElement("Name"); + workspace.getViews().createCustomView("key", "Title", "Description").addDefaultElements(); + assertEquals(1, workspace.getModel().getCustomElements().size()); + + workspace.remove(element); + assertEquals(1, workspace.getModel().getCustomElements().size()); + } + + @Test + void remove_WhenAPersonIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + Person person = workspace.getModel().addPerson("User"); + workspace.getViews().createSystemLandscapeView("key", "Description").addDefaultElements(); + assertEquals(1, workspace.getModel().getPeople().size()); + + workspace.remove(person); + assertEquals(1, workspace.getModel().getPeople().size()); + } + + @Test + void remove_WhenASoftwareSystemIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getViews().createSystemContextView(softwareSystem, "key", "Description").addDefaultElements(); + assertEquals(1, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(1, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAContainerIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + workspace.getViews().createContainerView(softwareSystem, "key", "Description").addDefaultElements(); + assertEquals(2, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(2, workspace.getModel().getElements().size()); + + workspace.remove(container); + assertEquals(2, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAComponentIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + workspace.getViews().createComponentView(container, "key", "Description").addDefaultElements(); + assertEquals(3, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(3, workspace.getModel().getElements().size()); + + workspace.remove(container); + assertEquals(3, workspace.getModel().getElements().size()); + + workspace.remove(component); + assertEquals(3, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenASoftwareSystemInstanceIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + workspace.getViews().createDeploymentView("key", "Description").addDefaultElements(); + assertEquals(3, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(3, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystemInstance); + assertEquals(3, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAContainerInstanceIsUsedInAView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node"); + ContainerInstance containerInstance = deploymentNode.add(container); + workspace.getViews().createDeploymentView("key", "Description").addDefaultElements(); + assertEquals(4, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(4, workspace.getModel().getElements().size()); + + workspace.remove(container); + assertEquals(4, workspace.getModel().getElements().size()); + + workspace.remove(containerInstance); + assertEquals(4, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAnElementIsTheScopeOfAContainerView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getViews().createContainerView(softwareSystem, "key", "Description"); + assertEquals(1, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(1, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAnElementIsTheScopeOfAComponentView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + workspace.getViews().createComponentView(container, "key", "Description"); + assertEquals(2, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(2, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAnElementIsTheScopeOfADynamicView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getViews().createDynamicView(softwareSystem, "key", "Description"); + assertEquals(1, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(1, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAnElementIsTheScopeOfADeploymentView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getViews().createDeploymentView(softwareSystem, "key", "Description"); + assertEquals(1, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(1, workspace.getModel().getElements().size()); + } + + @Test + void remove_WhenAnElementIsTheScopeOfAnImageView() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getViews().createImageView(softwareSystem, "key"); + assertEquals(1, workspace.getModel().getElements().size()); + + workspace.remove(softwareSystem); + assertEquals(1, workspace.getModel().getElements().size()); + } + + @Test + void trim_WhenAllElementsAreUnused() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy()); + + CustomElement element = workspace.getModel().addCustomElement("Custom Element"); + Person user = workspace.getModel().addPerson("User"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container webapp = softwareSystem.addContainer("Web Application"); + Container database = softwareSystem.addContainer("Database"); + Component component = webapp.addComponent("Component"); + user.uses(component, "uses"); + webapp.uses(database, "uses"); + + DeploymentNode live = workspace.getModel().addDeploymentNode("Live"); + DeploymentNode server1 = live.addDeploymentNode("Server 1"); + DeploymentNode server2 = live.addDeploymentNode("Server 2"); + ContainerInstance webappInstance = server1.add(webapp); + ContainerInstance databaseInstance = server2.add(database); + + DeploymentNode dev = workspace.getModel().addDeploymentNode("Dev"); + SoftwareSystemInstance softwareSystemInstance = dev.add(softwareSystem); + + assertEquals(13, workspace.getModel().getElements().size()); + assertEquals(5, workspace.getModel().getRelationships().size()); + + workspace.trim(); + assertEquals(0, workspace.getModel().getElements().size()); + assertEquals(0, workspace.getModel().getRelationships().size()); + } + + @Test + void trim_WhenSomeElementsAreUnused() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + SoftwareSystem c = workspace.getModel().addSoftwareSystem("C"); + SoftwareSystem d = workspace.getModel().addSoftwareSystem("D"); + + // a -> b -> c -> d + Relationship ab = a.uses(b, "uses"); + Relationship bc = b.uses(c, "uses"); + Relationship cd = c.uses(d, "uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.add(b); + view.add(c); + + assertEquals(4, workspace.getModel().getElements().size()); + assertEquals(3, workspace.getModel().getRelationships().size()); + + workspace.trim(); + assertEquals(2, workspace.getModel().getElements().size()); + assertEquals(1, workspace.getModel().getRelationships().size()); + assertTrue(workspace.getModel().contains(b)); + assertTrue(workspace.getModel().contains(c)); + assertTrue(workspace.getModel().contains(bc)); + } + + @Test + void trim_WhenTheDestinationOfAnElementIsRemoved() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + a.uses(b, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.add(a); + + workspace.trim(); + + assertEquals(0, a.getRelationships().size()); + } + + @Test + void removeRelationship_ThrowsAnException_WhenNoRelationshipIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + try { + workspace.remove((Relationship)null); + fail(); + } catch (Exception e) { + assertEquals("A relationship must be specified.", e.getMessage()); + } + } + + @Test + void removeRelationship() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Uses"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + workspace.remove(relationship); + + assertEquals(0, a.getRelationships().size()); + assertFalse(a.hasEfferentRelationshipWith(b)); + assertFalse(view.isRelationshipInView(relationship)); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/test/java/com/structurizr/configuration/UserTests.java b/structurizr-core/src/test/java/com/structurizr/configuration/UserTests.java new file mode 100644 index 000000000..efacd7398 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/configuration/UserTests.java @@ -0,0 +1,48 @@ +package com.structurizr.configuration; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class UserTests { + + @Test + void construct_ThrowsAnException_WhenANullUsernameIsSpecified() { + try { + new User(null, Role.ReadWrite); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A username must be specified.", iae.getMessage()); + } + } + + @Test + void construct_ThrowsAnException_WhenAnEmptyUsernameIsSpecified() { + try { + new User(" ", Role.ReadWrite); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A username must be specified.", iae.getMessage()); + } + } + + @Test + void construct_ThrowsAnException_WhenANullRoleIsSpecified() { + try { + new User("user@domain.com", null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A role must be specified.", iae.getMessage()); + } + } + + @Test + void comstruct() { + User user = new User("user@domain.com", Role.ReadOnly); + + assertEquals("user@domain.com", user.getUsername()); + assertEquals(Role.ReadOnly, user.getRole()); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/test/java/com/structurizr/configuration/WorkspaceConfigurationTests.java b/structurizr-core/src/test/java/com/structurizr/configuration/WorkspaceConfigurationTests.java new file mode 100644 index 000000000..bdc982e9a --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/configuration/WorkspaceConfigurationTests.java @@ -0,0 +1,92 @@ +package com.structurizr.configuration; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class WorkspaceConfigurationTests { + + @Test + void addUser_ThrowsAnException_WhenANullUsernameIsSpecified() { + try { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + configuration.addUser(null, Role.ReadWrite); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A username must be specified.", iae.getMessage()); + } + } + + @Test + void addUser_ThrowsAnException_WhenAnEmptyUsernameIsSpecified() { + try { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + configuration.addUser(" ", Role.ReadWrite); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A username must be specified.", iae.getMessage()); + } + } + + @Test + void addUser_ThrowsAnException_WhenANullRoleIsSpecified() { + try { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + configuration.addUser("user@example.com", null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A role must be specified.", iae.getMessage()); + } + } + + @Test + void addUser_AddsAUser() { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + configuration.addUser("user1@example.com", Role.ReadOnly); + + assertEquals(1, configuration.getUsers().size()); + User user = configuration.getUsers().stream().filter(u -> u.getUsername().equals("user1@example.com")).findFirst().get(); + assertEquals(Role.ReadOnly, user.getRole()); + + configuration.addUser("user2@example.com", Role.ReadWrite); + + assertEquals(2, configuration.getUsers().size()); + user = configuration.getUsers().stream().filter(u -> u.getUsername().equals("user2@example.com")).findFirst().get(); + assertEquals(Role.ReadWrite, user.getRole()); + } + + @Test + void scope() { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + assertNull(configuration.getScope()); // default scope is undefined + + configuration.setScope(WorkspaceScope.SoftwareSystem); + assertEquals(WorkspaceScope.SoftwareSystem, configuration.getScope()); + + configuration.setScope(null); + assertNull(configuration.getScope()); + } + + @Test + void visibility() { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + assertNull(configuration.getVisibility()); + + configuration.setVisibility(Visibility.Private); + assertEquals(Visibility.Private, configuration.getVisibility()); + + configuration.setVisibility(null); + assertNull(configuration.getVisibility()); + } + + @Test + void clearUsers() { + WorkspaceConfiguration configuration = new WorkspaceConfiguration(); + configuration.addUser("user@domain.com", Role.ReadOnly); + assertEquals(1, configuration.getUsers().size()); + + configuration.clearUsers(); + assertEquals(0, configuration.getUsers().size()); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/documentation/DecisionTests.java b/structurizr-core/src/test/java/com/structurizr/documentation/DecisionTests.java similarity index 69% rename from structurizr-core/test/unit/com/structurizr/documentation/DecisionTests.java rename to structurizr-core/src/test/java/com/structurizr/documentation/DecisionTests.java index 395d33a6e..82efda419 100644 --- a/structurizr-core/test/unit/com/structurizr/documentation/DecisionTests.java +++ b/structurizr-core/src/test/java/com/structurizr/documentation/DecisionTests.java @@ -1,15 +1,15 @@ package com.structurizr.documentation; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class DecisionTests extends AbstractWorkspaceTestBase { @Test - public void test_hasLinkTo() { + void hasLinkTo() { Decision d1 = new Decision("1"); Decision d2 = new Decision("2"); Decision d3 = new Decision("3"); diff --git a/structurizr-core/test/unit/com/structurizr/documentation/DocumentationTests.java b/structurizr-core/src/test/java/com/structurizr/documentation/DocumentationTests.java similarity index 57% rename from structurizr-core/test/unit/com/structurizr/documentation/DocumentationTests.java rename to structurizr-core/src/test/java/com/structurizr/documentation/DocumentationTests.java index 1f1ee97fe..187a85870 100644 --- a/structurizr-core/test/unit/com/structurizr/documentation/DocumentationTests.java +++ b/structurizr-core/src/test/java/com/structurizr/documentation/DocumentationTests.java @@ -1,50 +1,24 @@ package com.structurizr.documentation; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class DocumentationTests extends AbstractWorkspaceTestBase { private Documentation documentation; - @Before + @BeforeEach public void setUp() { documentation = workspace.getDocumentation(); } @Test - public void test_addSection_ThrowsAnException_WhenTheTitleIsNotSpecified() { + void addSection_ThrowsAnException_WhenTheFormatIsNotSpecified() { try { Section section = new Section(); - - documentation.addSection(section); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A title must be specified.", iae.getMessage()); - } - } - - @Test - public void test_addSection_ThrowsAnException_WhenTheContentIsNotSpecified() { - try { - Section section = new Section(); - section.setTitle("Title"); - - documentation.addSection(section); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("Content must be specified.", iae.getMessage()); - } - } - - @Test - public void test_addSection_ThrowsAnException_WhenTheFormatIsNotSpecified() { - try { - Section section = new Section(); - section.setTitle("Title"); section.setContent("Content"); documentation.addSection(section); @@ -55,25 +29,8 @@ public void test_addSection_ThrowsAnException_WhenTheFormatIsNotSpecified() { } @Test - public void test_addSection_ThrowsAnException_WhenASectionExistsWithTheSameTitle() { - try { - Section section = new Section(); - section.setTitle("Title"); - section.setContent("Content"); - section.setFormat(Format.Markdown); - - documentation.addSection(section); - documentation.addSection(section); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A section with a title of Title already exists in this scope.", iae.getMessage()); - } - } - - @Test - public void test_addSection() { + void addSection() { Section section = new Section(); - section.setTitle("Title"); section.setContent("Content"); section.setFormat(Format.Markdown); @@ -81,17 +38,16 @@ public void test_addSection() { assertEquals(1, documentation.getSections().size()); assertTrue(documentation.getSections().contains(section)); - assertEquals("Title", section.getTitle()); assertEquals(Format.Markdown, section.getFormat()); assertEquals("Content", section.getContent()); assertEquals(1, section.getOrder()); } @Test - public void test_addSection_IncrementsTheSectionOrderNumber() { - Section section1 = new Section("Title 1", Format.Markdown, "Content"); - Section section2 = new Section("Title 2", Format.Markdown, "Content"); - Section section3 = new Section("Title 3", Format.Markdown, "Content"); + void addSection_IncrementsTheSectionOrderNumber() { + Section section1 = new Section(Format.Markdown, "Content 1"); + Section section2 = new Section(Format.Markdown, "Content 2"); + Section section3 = new Section(Format.Markdown, "Content 3"); documentation.addSection(section1); documentation.addSection(section2); @@ -103,7 +59,7 @@ public void test_addSection_IncrementsTheSectionOrderNumber() { } @Test - public void test_addDecision_ThrowsAnException_WhenTheTitleIsNotSpecified() { + void addDecision_ThrowsAnException_WhenTheTitleIsNotSpecified() { try { Decision decision = new Decision("1"); @@ -115,7 +71,7 @@ public void test_addDecision_ThrowsAnException_WhenTheTitleIsNotSpecified() { } @Test - public void test_addDecision_ThrowsAnException_WhenTheContentIsNotSpecified() { + void addDecision_ThrowsAnException_WhenTheContentIsNotSpecified() { try { Decision decision = new Decision("1"); decision.setTitle("Title"); @@ -128,7 +84,7 @@ public void test_addDecision_ThrowsAnException_WhenTheContentIsNotSpecified() { } @Test - public void test_addDecision_ThrowsAnException_WhenTheStatusIsNotSpecified() { + void addDecision_ThrowsAnException_WhenTheStatusIsNotSpecified() { try { Decision decision = new Decision("1"); decision.setTitle("Title"); @@ -142,7 +98,7 @@ public void test_addDecision_ThrowsAnException_WhenTheStatusIsNotSpecified() { } @Test - public void test_addDecision_ThrowsAnException_WhenTheFormatIsNotSpecified() { + void addDecision_ThrowsAnException_WhenTheFormatIsNotSpecified() { try { Decision decision = new Decision("1"); decision.setTitle("Title"); @@ -157,7 +113,7 @@ public void test_addDecision_ThrowsAnException_WhenTheFormatIsNotSpecified() { } @Test - public void test_addDecision_ThrowsAnException_WhenADecisionExistsWithTheSameId() { + void addDecision_ThrowsAnException_WhenADecisionExistsWithTheSameId() { try { Decision decision = new Decision("1"); decision.setTitle("Title"); diff --git a/structurizr-core/src/test/java/com/structurizr/documentation/SectionTests.java b/structurizr-core/src/test/java/com/structurizr/documentation/SectionTests.java new file mode 100644 index 000000000..0f1705d44 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/documentation/SectionTests.java @@ -0,0 +1,17 @@ +package com.structurizr.documentation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SectionTests { + + @Test + void construction() { + Section section = new Section(Format.Markdown, "Content"); + + assertEquals(Format.Markdown, section.getFormat()); + assertEquals("Content", section.getContent()); + } + +} \ No newline at end of file diff --git a/structurizr-core/src/test/java/com/structurizr/model/ComponentTests.java b/structurizr-core/src/test/java/com/structurizr/model/ComponentTests.java new file mode 100644 index 000000000..d38adc7ba --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/model/ComponentTests.java @@ -0,0 +1,66 @@ +package com.structurizr.model; + +import com.structurizr.AbstractWorkspaceTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ComponentTests extends AbstractWorkspaceTestBase { + + private SoftwareSystem softwareSystem = model.addSoftwareSystem("System", "Description"); + private Container container = softwareSystem.addContainer("Container", "Description", "Some technology"); + + @Test + void getName_ReturnsTheGivenName_WhenANameIsGiven() { + Component component = new Component(); + component.setName("Some name"); + assertEquals("Some name", component.getName()); + } + + @Test + void getCanonicalName() { + Component component = container.addComponent("Component", "Description"); + assertEquals("Component://System.Container.Component", component.getCanonicalName()); + } + + @Test + void getCanonicalName_WhenNameContainsSlashAndDotCharacters() { + Component component = container.addComponent("Name1/.Name2", "Description"); + assertEquals("Component://System.Container.Name1Name2", component.getCanonicalName()); + } + + @Test + void getParent_ReturnsTheParentContainer() { + Component component = container.addComponent("Component", "Description"); + assertEquals(container, component.getParent()); + } + + @Test + void getContainer_ReturnsTheParentContainer() { + Component component = container.addComponent("Name", "Description"); + assertEquals(container, component.getContainer()); + } + + @Test + void removeTags_DoesNotRemoveRequiredTags() { + Component component = new Component(); + assertTrue(component.getTags().contains(Tags.ELEMENT)); + assertTrue(component.getTags().contains(Tags.COMPONENT)); + + component.removeTag(Tags.COMPONENT); + component.removeTag(Tags.ELEMENT); + + assertTrue(component.getTags().contains(Tags.ELEMENT)); + assertTrue(component.getTags().contains(Tags.COMPONENT)); + } + + @Test + void technologyProperty() { + Component component = new Component(); + assertNull(component.getTechnology()); + + component.setTechnology("Spring Bean"); + assertEquals("Spring Bean", component.getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/ContainerInstanceTests.java b/structurizr-core/src/test/java/com/structurizr/model/ContainerInstanceTests.java similarity index 82% rename from structurizr-core/test/unit/com/structurizr/model/ContainerInstanceTests.java rename to structurizr-core/src/test/java/com/structurizr/model/ContainerInstanceTests.java index 96b306f9a..1f0b150b7 100644 --- a/structurizr-core/test/unit/com/structurizr/model/ContainerInstanceTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/ContainerInstanceTests.java @@ -1,18 +1,18 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ContainerInstanceTests extends AbstractWorkspaceTestBase { - private SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System", "Description"); + private SoftwareSystem softwareSystem = model.addSoftwareSystem("System", "Description"); private Container database = softwareSystem.addContainer("Database Schema", "Stores data", "MySQL"); private DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @Test - public void test_construction() { + void construction() { ContainerInstance instance = deploymentNode.add(database); assertSame(database, instance.getContainer()); @@ -21,7 +21,7 @@ public void test_construction() { } @Test - public void test_getContainerId() { + void getContainerId() { ContainerInstance instance = deploymentNode.add(database); assertEquals(database.getId(), instance.getContainerId()); @@ -31,7 +31,7 @@ public void test_getContainerId() { } @Test - public void test_getName() { + void getName() { ContainerInstance instance = deploymentNode.add(database); assertEquals("Database Schema", instance.getName()); @@ -41,28 +41,28 @@ public void test_getName() { } @Test - public void test_getCanonicalName() { + void getCanonicalName() { ContainerInstance instance = deploymentNode.add(database); assertEquals("ContainerInstance://Default/Deployment Node/System.Database Schema[1]", instance.getCanonicalName()); } @Test - public void test_getParent_ReturnsTheParentDeploymentNode() { + void getParent_ReturnsTheParentDeploymentNode() { ContainerInstance instance = deploymentNode.add(database); assertEquals(deploymentNode, instance.getParent()); } @Test - public void test_getRequiredTags() { + void getRequiredTags() { ContainerInstance instance = deploymentNode.add(database); assertTrue(instance.getDefaultTags().isEmpty()); } @Test - public void test_getTags() { + void getTags() { database.addTags("Database"); ContainerInstance instance = deploymentNode.add(database); instance.addTags("Primary Instance"); @@ -71,7 +71,7 @@ public void test_getTags() { } @Test - public void test_removeTags_DoesNotRemoveAnyTags() { + void removeTags_DoesNotRemoveAnyTags() { ContainerInstance instance = deploymentNode.add(database); assertTrue(instance.getTags().contains(Tags.CONTAINER_INSTANCE)); @@ -82,7 +82,7 @@ public void test_removeTags_DoesNotRemoveAnyTags() { } @Test - public void test_getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { + void getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { ContainerInstance instance = deploymentNode.add(database); assertEquals(1, instance.getDeploymentGroups().size()); @@ -90,7 +90,7 @@ public void test_getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { } @Test - public void test_getDeploymentGroups_WhenOneGroupHasBeenSpecified() { + void getDeploymentGroups_WhenOneGroupHasBeenSpecified() { ContainerInstance instance = deploymentNode.add(database, "Group 1"); assertEquals(1, instance.getDeploymentGroups().size()); @@ -98,7 +98,7 @@ public void test_getDeploymentGroups_WhenOneGroupHasBeenSpecified() { } @Test - public void test_getDeploymentGroups_WhenMultipleGroupsAreSpecified() { + void getDeploymentGroups_WhenMultipleGroupsAreSpecified() { ContainerInstance instance = deploymentNode.add(database, "Group 1", "Group 2"); assertEquals(2, instance.getDeploymentGroups().size()); @@ -107,7 +107,7 @@ public void test_getDeploymentGroups_WhenMultipleGroupsAreSpecified() { } @Test - public void test_addHealthCheck() { + void addHealthCheck() { ContainerInstance instance = deploymentNode.add(database); assertTrue(instance.getHealthChecks().isEmpty()); @@ -120,7 +120,7 @@ public void test_addHealthCheck() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { + void addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { ContainerInstance instance = deploymentNode.add(database); try { @@ -132,7 +132,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { + void addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { ContainerInstance instance = deploymentNode.add(database); try { @@ -144,7 +144,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { ContainerInstance instance = deploymentNode.add(database); try { @@ -156,7 +156,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { ContainerInstance instance = deploymentNode.add(database); try { @@ -168,7 +168,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { ContainerInstance instance = deploymentNode.add(database); try { @@ -180,7 +180,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero() { + void addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero() { ContainerInstance instance = deploymentNode.add(database); try { @@ -192,7 +192,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero( } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheTimeoutIsLessThanZero() { + void addHealthCheck_ThrowsAnException_WhenTheTimeoutIsLessThanZero() { ContainerInstance instance = deploymentNode.add(database); try { diff --git a/structurizr-core/src/test/java/com/structurizr/model/ContainerTests.java b/structurizr-core/src/test/java/com/structurizr/model/ContainerTests.java new file mode 100644 index 000000000..f9599c2c1 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/model/ContainerTests.java @@ -0,0 +1,120 @@ +package com.structurizr.model; + +import com.structurizr.AbstractWorkspaceTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ContainerTests extends AbstractWorkspaceTestBase { + + private SoftwareSystem softwareSystem = model.addSoftwareSystem("System", "Description"); + private Container container = softwareSystem.addContainer("Container", "Description", "Some technology"); + + @Test + void technologyProperty() { + assertEquals("Some technology", container.getTechnology()); + + container.setTechnology("Some other technology"); + assertEquals("Some other technology", container.getTechnology()); + } + + @Test + void getCanonicalName() { + assertEquals("Container://System.Container", container.getCanonicalName()); + } + + @Test + void getCanonicalName_WhenNameContainsSlashAndDotCharacters() { + container = softwareSystem.addContainer("Name1/.Name2", "Description", "Some technology"); + + assertEquals("Container://System.Name1Name2", container.getCanonicalName()); + } + + @Test + void getParent_ReturnsTheParentSoftwareSystem() { + assertEquals(softwareSystem, container.getParent()); + } + + @Test + void getSoftwareSystem_ReturnsTheParentSoftwareSystem() { + assertEquals(softwareSystem, container.getSoftwareSystem()); + } + + @Test + void removeTags_DoesNotRemoveRequiredTags() { + assertTrue(container.getTags().contains(Tags.ELEMENT)); + assertTrue(container.getTags().contains(Tags.CONTAINER)); + + container.removeTag(Tags.CONTAINER); + container.removeTag(Tags.ELEMENT); + + assertTrue(container.getTags().contains(Tags.ELEMENT)); + assertTrue(container.getTags().contains(Tags.CONTAINER)); + } + + @Test + void addComponent_ThrowsAnException_WhenANullNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + container.addComponent(null, ""); + }); + } + + @Test + void addComponent_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + container.addComponent(" ", ""); + }); + } + + @Test + void addComponent_ThrowsAnException_WhenAComponentWithTheSameNameAlreadyExists() { + container.addComponent("Component 1", ""); + try { + container.addComponent("Component 1", ""); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A component named 'Component 1' already exists for this container.", iae.getMessage()); + } + } + + @Test + void addComponent_AddsAComponentWithTheSpecifiedNameAndDescription() { + Component component = container.addComponent("Name", "Description"); + assertTrue(container.getComponents().contains(component)); + assertEquals("Name", component.getName()); + assertEquals("Description", component.getDescription()); + assertNull(component.getTechnology()); + assertSame(container, component.getParent()); + } + + @Test + void addComponent_AddsAComponentWithTheSpecifiedNameAndDescriptionAndTechnology() { + Component component = container.addComponent("Name", "Description", "Technology"); + assertTrue(container.getComponents().contains(component)); + assertEquals("Name", component.getName()); + assertEquals("Description", component.getDescription()); + assertEquals("Technology", component.getTechnology()); + assertSame(container, component.getParent()); + } + + @Test + void getComponentWithName_ThrowsAnException_WhenANullNameIsSpecified() { + try { + container.getComponentWithName(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A component name must be provided.", iae.getMessage()); + } + } + + @Test + void getComponentWithName_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + try { + container.getComponentWithName(" "); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A component name must be provided.", iae.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java b/structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java similarity index 93% rename from structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java rename to structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java index d61147232..aeb8b6839 100644 --- a/structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests.java @@ -1,16 +1,16 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Set; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategyTests extends AbstractWorkspaceTestBase { @Test - public void test_impliedRelationshipsAreCreated() { + void impliedRelationshipsAreCreated() { SoftwareSystem a = model.addSoftwareSystem("A", ""); Container aa = a.addContainer("AA", "", ""); Component aaa = aa.addComponent("AAA", "", ""); @@ -21,7 +21,7 @@ public void test_impliedRelationshipsAreCreated() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); - Relationship explicitRelationship = aaa.uses(bbb, "Uses 1", "Technology", InteractionStyle.Asynchronous, new String[] { "Tag 1", "Tag 2" }); + Relationship explicitRelationship = aaa.uses(bbb, "Uses 1", "Technology", InteractionStyle.Asynchronous, new String[]{"Tag 1", "Tag 2"}); assertEquals(9, model.getRelationships().size()); assertTrue(aaa.hasEfferentRelationshipWith(bbb, "Uses 1")); @@ -54,7 +54,7 @@ public void test_impliedRelationshipsAreCreated() { } @Test - public void test_impliedRelationshipsAreCreated_UnlessAnyRelationshipExists() { + void impliedRelationshipsAreCreated_UnlessAnyRelationshipExists() { SoftwareSystem a = model.addSoftwareSystem("A", ""); Container aa = a.addContainer("AA", "", ""); Component aaa = aa.addComponent("AAA", "", ""); diff --git a/structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java b/structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java similarity index 92% rename from structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java rename to structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java index 0fa35ee89..1b8fda1ce 100644 --- a/structurizr-core/test/unit/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests.java @@ -1,16 +1,16 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Set; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategyTests extends AbstractWorkspaceTestBase { @Test - public void test_impliedRelationships_WhenNoSummaryRelationshipsExist() { + void impliedRelationships_WhenNoSummaryRelationshipsExist() { SoftwareSystem a = model.addSoftwareSystem("A", ""); Container aa = a.addContainer("AA", "", ""); Component aaa = aa.addComponent("AAA", "", ""); @@ -21,7 +21,7 @@ public void test_impliedRelationships_WhenNoSummaryRelationshipsExist() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy()); - Relationship explicitRelationship = aaa.uses(bbb, "Uses 1", "Technology", InteractionStyle.Asynchronous, new String[] { "Tag 1", "Tag 2" }); + Relationship explicitRelationship = aaa.uses(bbb, "Uses 1", "Technology", InteractionStyle.Asynchronous, new String[]{"Tag 1", "Tag 2"}); assertEquals(9, model.getRelationships().size()); assertTrue(aaa.hasEfferentRelationshipWith(bbb, "Uses 1")); diff --git a/structurizr-core/test/unit/com/structurizr/model/CustomElementTests.java b/structurizr-core/src/test/java/com/structurizr/model/CustomElementTests.java similarity index 82% rename from structurizr-core/test/unit/com/structurizr/model/CustomElementTests.java rename to structurizr-core/src/test/java/com/structurizr/model/CustomElementTests.java index 6637c088c..f90ef36a2 100644 --- a/structurizr-core/test/unit/com/structurizr/model/CustomElementTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/CustomElementTests.java @@ -1,14 +1,14 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class CustomElementTests extends AbstractWorkspaceTestBase { @Test - public void test_basicProperties() { + void basicProperties() { CustomElement element = model.addCustomElement("Name", "Type", "Description"); assertEquals("Name", element.getName()); assertEquals("Type", element.getMetadata()); @@ -16,26 +16,26 @@ public void test_basicProperties() { } @Test - public void test_getCanonicalName() { + void getCanonicalName() { CustomElement element = model.addCustomElement("Name", "Type", "Description"); assertEquals("Custom://Name", element.getCanonicalName()); } @Test - public void test_getCanonicalName_WhenNameContainsSlashAndDotCharacters() { + void getCanonicalName_WhenNameContainsSlashAndDotCharacters() { CustomElement element = model.addCustomElement("Name", "Type", "Description"); element.setName("Name1/.Name2"); assertEquals("Custom://Name1Name2", element.getCanonicalName()); } @Test - public void test_getParent_ReturnsNull() { + void getParent_ReturnsNull() { CustomElement element = model.addCustomElement("Name", "Type", "Description"); assertNull(element.getParent()); } @Test - public void test_removeTags_DoesNotRemoveRequiredTags() { + void removeTags_DoesNotRemoveRequiredTags() { CustomElement element = model.addCustomElement("Name", "Type", "Description"); assertTrue(element.getTags().contains(Tags.ELEMENT)); @@ -45,7 +45,7 @@ public void test_removeTags_DoesNotRemoveRequiredTags() { } @Test - public void test_uses_AddsARelationshipWhenTheDescriptionIsSpecified() { + void uses_AddsARelationshipWhenTheDescriptionIsSpecified() { CustomElement element1 = model.addCustomElement("Box 1"); CustomElement element2 = model.addCustomElement("Box 2"); @@ -61,7 +61,7 @@ public void test_uses_AddsARelationshipWhenTheDescriptionIsSpecified() { } @Test - public void test_uses_AddsARelationshipWhenTheDescriptionAndTechnologyAreSpecified() { + void uses_AddsARelationshipWhenTheDescriptionAndTechnologyAreSpecified() { CustomElement element1 = model.addCustomElement("Box 1"); CustomElement element2 = model.addCustomElement("Box 2"); @@ -73,11 +73,11 @@ public void test_uses_AddsARelationshipWhenTheDescriptionAndTechnologyAreSpecifi assertSame(element2, relationship.getDestination()); assertEquals("Uses", relationship.getDescription()); assertEquals("Technology", relationship.getTechnology()); - assertEquals(null, relationship.getInteractionStyle()); + assertNull(relationship.getInteractionStyle()); } @Test - public void test_uses_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAreSpecified() { + void uses_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAreSpecified() { CustomElement element1 = model.addCustomElement("Box 1"); CustomElement element2 = model.addCustomElement("Box 2"); @@ -93,11 +93,11 @@ public void test_uses_AddsARelationshipWhenTheDescriptionAndTechnologyAndInterac } @Test - public void test_uses_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAndTagsAreSpecified() { + void uses_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAndTagsAreSpecified() { CustomElement element1 = model.addCustomElement("Box 1"); CustomElement element2 = model.addCustomElement("Box 2"); - element1.uses(element2, "Uses", "Technology", InteractionStyle.Asynchronous, new String[] { "Tag 1", "Tag 2" }); + element1.uses(element2, "Uses", "Technology", InteractionStyle.Asynchronous, new String[]{"Tag 1", "Tag 2"}); assertEquals(1, element1.getRelationships().size()); Relationship relationship = element1.getRelationships().iterator().next(); diff --git a/structurizr-core/test/unit/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java b/structurizr-core/src/test/java/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java similarity index 81% rename from structurizr-core/test/unit/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java rename to structurizr-core/src/test/java/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java index 26ca1ff02..7f0e261ae 100644 --- a/structurizr-core/test/unit/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/DefaultImpliedRelationshipsStrategyTests.java @@ -1,15 +1,15 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; public class DefaultImpliedRelationshipsStrategyTests extends AbstractWorkspaceTestBase { @Test - public void test_createImpliedRelationships_DoesNothing() { + void createImpliedRelationships_DoesNothing() { SoftwareSystem a = model.addSoftwareSystem("A", ""); Container aa = a.addContainer("AA", "", ""); Component aaa = aa.addComponent("AAA", "", ""); diff --git a/structurizr-core/test/unit/com/structurizr/model/DeploymentNodeTests.java b/structurizr-core/src/test/java/com/structurizr/model/DeploymentNodeTests.java similarity index 66% rename from structurizr-core/test/unit/com/structurizr/model/DeploymentNodeTests.java rename to structurizr-core/src/test/java/com/structurizr/model/DeploymentNodeTests.java index 694741de9..303d3ded6 100644 --- a/structurizr-core/test/unit/com/structurizr/model/DeploymentNodeTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/DeploymentNodeTests.java @@ -2,21 +2,21 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.util.MapUtils; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class DeploymentNodeTests extends AbstractWorkspaceTestBase { @Test - public void test_getCanonicalName_WhenTheDeploymentNodeHasNoParent() { + void getCanonicalName_WhenTheDeploymentNodeHasNoParent() { DeploymentNode deploymentNode = model.addDeploymentNode("Ubuntu Server", "", ""); assertEquals("DeploymentNode://Default/Ubuntu Server", deploymentNode.getCanonicalName()); } @Test - public void test_getCanonicalName_WhenTheDeploymentNodeHasAParent() { + void getCanonicalName_WhenTheDeploymentNodeHasAParent() { DeploymentNode l1 = model.addDeploymentNode("Level 1", "", ""); DeploymentNode l2 = l1.addDeploymentNode("Level 2", "", ""); DeploymentNode l3 = l2.addDeploymentNode("Level 3", "", ""); @@ -27,7 +27,7 @@ public void test_getCanonicalName_WhenTheDeploymentNodeHasAParent() { } @Test - public void test_getParent_ReturnsTheParentDeploymentNode() { + void getParent_ReturnsTheParentDeploymentNode() { DeploymentNode parent = model.addDeploymentNode("Parent", "", ""); assertNull(parent.getParent()); @@ -37,7 +37,7 @@ public void test_getParent_ReturnsTheParentDeploymentNode() { } @Test - public void test_getRequiredTags() { + void getRequiredTags() { DeploymentNode deploymentNode = new DeploymentNode(); assertEquals(2, deploymentNode.getDefaultTags().size()); assertTrue(deploymentNode.getDefaultTags().contains(Tags.ELEMENT)); @@ -45,14 +45,14 @@ public void test_getRequiredTags() { } @Test - public void test_getTags() { + void getTags() { DeploymentNode deploymentNode = new DeploymentNode(); deploymentNode.addTags("Tag 1", "Tag 2"); assertEquals("Element,Deployment Node,Tag 1,Tag 2", deploymentNode.getTags()); } @Test - public void test_add_ThrowsAnException_WhenASoftwareSystemIsNotSpecified() { + void add_ThrowsAnException_WhenASoftwareSystemIsNotSpecified() { try { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); deploymentNode.add((SoftwareSystem) null); @@ -63,10 +63,10 @@ public void test_add_ThrowsAnException_WhenASoftwareSystemIsNotSpecified() { } @Test - public void test_add_ThrowsAnException_WhenAContainerIsNotSpecified() { + void add_ThrowsAnException_WhenAContainerIsNotSpecified() { try { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); - deploymentNode.add((Container)null); + deploymentNode.add((Container) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("A container must be specified.", iae.getMessage()); @@ -74,7 +74,7 @@ public void test_add_ThrowsAnException_WhenAContainerIsNotSpecified() { } @Test - public void test_add_AddsAContainerInstance_WhenAContainerIsSpecified() { + void add_AddsAContainerInstance_WhenAContainerIsSpecified() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "", ""); DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "", ""); @@ -87,7 +87,7 @@ public void test_add_AddsAContainerInstance_WhenAContainerIsSpecified() { } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenANameIsNotSpecified() { + void addDeploymentNode_ThrowsAnException_WhenANameIsNotSpecified() { try { DeploymentNode parent = model.addDeploymentNode("Parent", "", ""); parent.addDeploymentNode(null, "", ""); @@ -98,7 +98,7 @@ public void test_addDeploymentNode_ThrowsAnException_WhenANameIsNotSpecified() { } @Test - public void test_addDeploymentNode_AddsAChildDeploymentNode_WhenANameIsSpecified() { + void addDeploymentNode_AddsAChildDeploymentNode_WhenANameIsSpecified() { DeploymentNode parent = model.addDeploymentNode("Parent", "", ""); DeploymentNode child = parent.addDeploymentNode("Child 1", "Description", "Technology"); @@ -107,7 +107,7 @@ public void test_addDeploymentNode_AddsAChildDeploymentNode_WhenANameIsSpecified assertEquals("Description", child.getDescription()); assertEquals("Technology", child.getTechnology()); assertEquals("Default", child.getEnvironment()); - assertEquals(1, child.getInstances()); + assertEquals("1", child.getInstances()); assertTrue(child.getProperties().isEmpty()); assertTrue(parent.getChildren().contains(child)); @@ -117,7 +117,7 @@ public void test_addDeploymentNode_AddsAChildDeploymentNode_WhenANameIsSpecified assertEquals("Description", child.getDescription()); assertEquals("Technology", child.getTechnology()); assertEquals("Default", child.getEnvironment()); - assertEquals(4, child.getInstances()); + assertEquals("4", child.getInstances()); assertTrue(child.getProperties().isEmpty()); assertTrue(parent.getChildren().contains(child)); @@ -127,17 +127,17 @@ public void test_addDeploymentNode_AddsAChildDeploymentNode_WhenANameIsSpecified assertEquals("Description", child.getDescription()); assertEquals("Technology", child.getTechnology()); assertEquals("Default", child.getEnvironment()); - assertEquals(4, child.getInstances()); + assertEquals("4", child.getInstances()); assertEquals(1, child.getProperties().size()); assertEquals("value", child.getProperties().get("name")); assertTrue(parent.getChildren().contains(child)); } @Test - public void test_uses_ThrowsAnException_WhenANullDestinationIsSpecified() { + void uses_ThrowsAnException_WhenANullDestinationIsSpecified() { try { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "", ""); - deploymentNode.uses((DeploymentNode)null, "", ""); + deploymentNode.uses((DeploymentNode) null, "", ""); fail(); } catch (IllegalArgumentException iae) { assertEquals("The destination must be specified.", iae.getMessage()); @@ -145,7 +145,7 @@ public void test_uses_ThrowsAnException_WhenANullDestinationIsSpecified() { } @Test - public void test_uses_AddsARelationship() { + void uses_AddsARelationship() { DeploymentNode primaryNode = model.addDeploymentNode("MySQL - Primary", "", ""); DeploymentNode secondaryNode = model.addDeploymentNode("MySQL - Secondary", "", ""); Relationship relationship = primaryNode.uses(secondaryNode, "Replicates data to", "Some technology"); @@ -158,7 +158,7 @@ public void test_uses_AddsARelationship() { } @Test - public void test_getDeploymentNodeWithName_ThrowsAnException_WhenANameIsNotSpecified() { + void getDeploymentNodeWithName_ThrowsAnException_WhenANameIsNotSpecified() { try { DeploymentNode deploymentNode = new DeploymentNode(); deploymentNode.getDeploymentNodeWithName(null); @@ -169,59 +169,112 @@ public void test_getDeploymentNodeWithName_ThrowsAnException_WhenANameIsNotSpeci } @Test - public void test_getDeploymentNodeWithName_ReturnsNull_WhenThereIsNoDeploymentNodeWithTheSpecifiedName() { + void getDeploymentNodeWithName_ReturnsNull_WhenThereIsNoDeploymentNodeWithTheSpecifiedName() { DeploymentNode deploymentNode = new DeploymentNode(); assertNull(deploymentNode.getDeploymentNodeWithName("foo")); } @Test - public void test_getDeploymentNodeWithName_ReturnsTheNamedDeploymentNode_WhenThereIsADeploymentNodeWithTheSpecifiedName() { + void getDeploymentNodeWithName_ReturnsTheNamedDeploymentNode_WhenThereIsADeploymentNodeWithTheSpecifiedName() { DeploymentNode parent = model.addDeploymentNode("parent", "", ""); DeploymentNode child = parent.addDeploymentNode("child", "", ""); assertSame(child, parent.getDeploymentNodeWithName("child")); } @Test - public void test_getInfrastructureNodeWithName_ReturnsNull_WhenThereIsNoInfrastructureNodeWithTheSpecifiedName() { + void getInfrastructureNodeWithName_ReturnsNull_WhenThereIsNoInfrastructureNodeWithTheSpecifiedName() { DeploymentNode deploymentNode = new DeploymentNode(); assertNull(deploymentNode.getInfrastructureNodeWithName("foo")); } @Test - public void test_getInfrastructureNodeWithName_ReturnsTheNamedDeploymentNode_WhenThereIsAInfrastructureNodeWithTheSpecifiedName() { + void getInfrastructureNodeWithName_ReturnsTheNamedDeploymentNode_WhenThereIsAInfrastructureNodeWithTheSpecifiedName() { DeploymentNode parent = model.addDeploymentNode("parent", "", ""); InfrastructureNode child = parent.addInfrastructureNode("child", "", ""); assertSame(child, parent.getInfrastructureNodeWithName("child")); } @Test - public void test_setInstances() { - DeploymentNode deploymentNode = new DeploymentNode(); - deploymentNode.setInstances(8); - - assertEquals(8, deploymentNode.getInstances()); + void setInstances_ThrowsAnException_WhenAZeroIsSpecified() { + try { + DeploymentNode deploymentNode = new DeploymentNode(); + deploymentNode.setInstances("0"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("Number of instances must be a positive integer or a range.", iae.getMessage()); + } } @Test - public void test_setInstances_ThrowsAnException_WhenZeroIsSpecified() { + void setInstances_ThrowsAnException_WhenANegativeNumberIsSpecified() { try { DeploymentNode deploymentNode = new DeploymentNode(); - deploymentNode.setInstances(0); + deploymentNode.setInstances("-1"); fail(); } catch (IllegalArgumentException iae) { - assertEquals("Number of instances must be a positive integer.", iae.getMessage()); + assertEquals("Number of instances must be a positive integer or a range.", iae.getMessage()); } } @Test - public void test_setInstances_ThrowsAnException_WhenANegativeNumberIsSpecified() { + void setInstancesAsPositiveInteger() { + DeploymentNode deploymentNode = new DeploymentNode(); + + deploymentNode.setInstances("8"); + assertEquals("8", deploymentNode.getInstances()); + + deploymentNode.setInstances(8); + assertEquals("8", deploymentNode.getInstances()); + } + + @Test + void setInstances_ThrowsAnException_WhenAnInvalidRangeIsSpecified() { try { DeploymentNode deploymentNode = new DeploymentNode(); - deploymentNode.setInstances(-1); + deploymentNode.setInstances("x..N"); fail(); } catch (IllegalArgumentException iae) { - assertEquals("Number of instances must be a positive integer.", iae.getMessage()); + assertEquals("Number of instances must be a positive integer or a range.", iae.getMessage()); } + + try { + DeploymentNode deploymentNode = new DeploymentNode(); + deploymentNode.setInstances("2..1"); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("Range upper bound must be greater than the lower bound.", iae.getMessage()); + } + } + + @Test + void setInstancesAsRangeWithBoundedUpperRange() { + DeploymentNode deploymentNode = new DeploymentNode(); + + deploymentNode.setInstances("0..2"); + assertEquals("0..2", deploymentNode.getInstances()); + + deploymentNode.setInstances("1..2"); + assertEquals("1..2", deploymentNode.getInstances()); + + deploymentNode.setInstances("5..10"); + assertEquals("5..10", deploymentNode.getInstances()); + } + + @Test + void setInstancesAsRangeWithUnboundedUpperRange() { + DeploymentNode deploymentNode = new DeploymentNode(); + + deploymentNode.setInstances("0..N"); + assertEquals("0..N", deploymentNode.getInstances()); + + deploymentNode.setInstances("1..N"); + assertEquals("1..N", deploymentNode.getInstances()); + + deploymentNode.setInstances("0..*"); + assertEquals("0..*", deploymentNode.getInstances()); + + deploymentNode.setInstances("1..*"); + assertEquals("1..*", deploymentNode.getInstances()); } } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/ElementTests.java b/structurizr-core/src/test/java/com/structurizr/model/ElementTests.java similarity index 71% rename from structurizr-core/test/unit/com/structurizr/model/ElementTests.java rename to structurizr-core/src/test/java/com/structurizr/model/ElementTests.java index cf2b8b212..05a151574 100644 --- a/structurizr-core/test/unit/com/structurizr/model/ElementTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/ElementTests.java @@ -1,25 +1,21 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import com.structurizr.Workspace; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ElementTests extends AbstractWorkspaceTestBase { - @Test - public void test_construction() { - Element element = model.addSoftwareSystem("Name", "Description"); - assertEquals("Name", element.getName()); - assertEquals("Description", element.getDescription()); - } + @Test + void construction() { + Element element = model.addSoftwareSystem("Name", "Description"); + assertEquals("Name", element.getName()); + assertEquals("Description", element.getDescription()); + } @Test - public void test_setName_ThrowsAnException_WhenANullValueIsSpecified() { + void setName_ThrowsAnException_WhenANullValueIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); try { element.setName(null); @@ -30,7 +26,7 @@ public void test_setName_ThrowsAnException_WhenANullValueIsSpecified() { } @Test - public void test_setName_ThrowsAnException_WhenAnEmptyValueIsSpecified() { + void setName_ThrowsAnException_WhenAnEmptyValueIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); try { element.setName(" "); @@ -41,26 +37,26 @@ public void test_setName_ThrowsAnException_WhenAnEmptyValueIsSpecified() { } @Test - public void test_hasEfferentRelationshipWith_ReturnsFalse_WhenANullElementIsSpecified() { + void hasEfferentRelationshipWith_ReturnsFalse_WhenANullElementIsSpecified() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); assertFalse(softwareSystem1.hasEfferentRelationshipWith(null)); } @Test - public void test_hasEfferentRelationshipWith_ReturnsFalse_WhenTheSameElementIsSpecifiedAndNoCyclicRelationshipExists() { + void hasEfferentRelationshipWith_ReturnsFalse_WhenTheSameElementIsSpecifiedAndNoCyclicRelationshipExists() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); assertFalse(softwareSystem1.hasEfferentRelationshipWith(softwareSystem1)); } @Test - public void test_hasEfferentRelationshipWith_ReturnsTrue_WhenTheSameElementIsSpecifiedAndACyclicRelationshipExists() { + void hasEfferentRelationshipWith_ReturnsTrue_WhenTheSameElementIsSpecifiedAndACyclicRelationshipExists() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); softwareSystem1.uses(softwareSystem1, "uses"); assertTrue(softwareSystem1.hasEfferentRelationshipWith(softwareSystem1)); } @Test - public void test_hasEfferentRelationshipWith_ReturnsTrue_WhenThereIsARelationship() { + void hasEfferentRelationshipWith_ReturnsTrue_WhenThereIsARelationship() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "uses"); @@ -68,13 +64,13 @@ public void test_hasEfferentRelationshipWith_ReturnsTrue_WhenThereIsARelationshi } @Test - public void test_hasEfferentRelationshipWithElementAndDescription_ReturnsFalse_WhenANullElementIsSpecified() { + void hasEfferentRelationshipWithElementAndDescription_ReturnsFalse_WhenANullElementIsSpecified() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); assertFalse(softwareSystem1.hasEfferentRelationshipWith(null, null)); } @Test - public void test_hasEfferentRelationshipWithElementAndDescription_ReturnsFalse_WhenThereIsNotAMatchingRelationship() { + void hasEfferentRelationshipWithElementAndDescription_ReturnsFalse_WhenThereIsNotAMatchingRelationship() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "Uses"); @@ -83,7 +79,7 @@ public void test_hasEfferentRelationshipWithElementAndDescription_ReturnsFalse_W } @Test - public void test_hasEfferentRelationshipWithElementAndDescription_ReturnsTrue_WhenThereIsAMatchingRelationship() { + void hasEfferentRelationshipWithElementAndDescription_ReturnsTrue_WhenThereIsAMatchingRelationship() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "Uses"); @@ -92,19 +88,19 @@ public void test_hasEfferentRelationshipWithElementAndDescription_ReturnsTrue_Wh } @Test - public void test_getEfferentRelationshipWith_ReturnsNull_WhenANullElementIsSpecified() { + void getEfferentRelationshipWith_ReturnsNull_WhenANullElementIsSpecified() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); assertNull(softwareSystem1.getEfferentRelationshipWith(null)); } @Test - public void test_getEfferentRelationshipWith_ReturnsNull_WhenTheSameElementIsSpecifiedAndNoCyclicRelationshipExists() { + void getEfferentRelationshipWith_ReturnsNull_WhenTheSameElementIsSpecifiedAndNoCyclicRelationshipExists() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); assertNull(softwareSystem1.getEfferentRelationshipWith(softwareSystem1)); } @Test - public void test_getEfferentRelationshipWith_ReturnsCyclicRelationship_WhenTheSameElementIsSpecifiedAndACyclicRelationshipExists() { + void getEfferentRelationshipWith_ReturnsCyclicRelationship_WhenTheSameElementIsSpecifiedAndACyclicRelationshipExists() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); softwareSystem1.uses(softwareSystem1, "uses"); @@ -115,7 +111,7 @@ public void test_getEfferentRelationshipWith_ReturnsCyclicRelationship_WhenTheSa } @Test - public void test_getEfferentRelationshipWith_ReturnsTheRelationship_WhenThereIsARelationship() { + void getEfferentRelationshipWith_ReturnsTheRelationship_WhenThereIsARelationship() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "uses"); @@ -127,7 +123,7 @@ public void test_getEfferentRelationshipWith_ReturnsTheRelationship_WhenThereIsA } @Test - public void test_hasAfferentRelationships_ReturnsFalse_WhenThereAreNoIncomingRelationships() { + void hasAfferentRelationships_ReturnsFalse_WhenThereAreNoIncomingRelationships() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "Uses"); @@ -136,7 +132,7 @@ public void test_hasAfferentRelationships_ReturnsFalse_WhenThereAreNoIncomingRel } @Test - public void test_hasAfferentRelationships_ReturnsTrue_WhenThereAreIncomingRelationships() { + void hasAfferentRelationships_ReturnsTrue_WhenThereAreIncomingRelationships() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("System 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("System 2", ""); softwareSystem1.uses(softwareSystem2, "Uses"); @@ -145,7 +141,7 @@ public void test_hasAfferentRelationships_ReturnsTrue_WhenThereAreIncomingRelati } @Test - public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithASoftwareSystemIsAddedMoreThanOnce() { + void addRelationship_DoesNothing_WhenTheSameRelationshipWithASoftwareSystemIsAddedMoreThanOnce() { SoftwareSystem a = model.addSoftwareSystem("A", ""); SoftwareSystem b = model.addSoftwareSystem("B", ""); @@ -164,7 +160,7 @@ public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithASoftwar } @Test - public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithAContainerIsAddedMoreThanOnce() { + void addRelationship_DoesNothing_WhenTheSameRelationshipWithAContainerIsAddedMoreThanOnce() { SoftwareSystem a = model.addSoftwareSystem("A", ""); SoftwareSystem b = model.addSoftwareSystem("B", ""); Container bb = b.addContainer("BB", "", ""); @@ -184,7 +180,7 @@ public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithAContain } @Test - public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithAComponentIsAddedMoreThanOnce() { + void addRelationship_DoesNothing_WhenTheSameRelationshipWithAComponentIsAddedMoreThanOnce() { SoftwareSystem a = model.addSoftwareSystem("A", ""); SoftwareSystem b = model.addSoftwareSystem("B", ""); Container bb = b.addContainer("BB", "", ""); @@ -205,7 +201,7 @@ public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithACompone } @Test - public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithAPersonIsAddedMoreThanOnce() { + void addRelationship_DoesNothing_WhenTheSameRelationshipWithAPersonIsAddedMoreThanOnce() { SoftwareSystem a = model.addSoftwareSystem("A", ""); Person b = model.addPerson("B", ""); @@ -224,52 +220,54 @@ public void test_addRelationship_DoesNothing_WhenTheSameRelationshipWithAPersonI } @Test - public void test_equals_ReturnsFalse_WhenTestedAgainstNull() { + void equals_ReturnsFalse_WhenTestedAgainstNull() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - assertFalse(softwareSystem.equals(null)); + assertNotEquals(softwareSystem, null); } @Test - public void test_equals_ReturnsFalse_WhenTheTwoObjectsAreDifferentTypes() { + void equals_ReturnsFalse_WhenTheTwoObjectsAreDifferentTypes() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - assertFalse(softwareSystem.equals("hello world")); + assertNotEquals(softwareSystem, "hello world"); } @Test - public void test_equals_ReturnsTrue_WhenTestedAgainstItself() { + void equals_ReturnsTrue_WhenTestedAgainstItself() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - assertTrue(softwareSystem.equals(softwareSystem)); + assertEquals(softwareSystem, softwareSystem); } @Test - public void test_equals_ReturnsFalse_WhenTheTwoObjectsAreDifferent() { + void equals_ReturnsFalse_WhenTheTwoObjectsAreDifferent() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("A", "Description"); SoftwareSystem softwareSystemB = model.addSoftwareSystem("B", "Description"); - assertFalse(softwareSystemA.equals(softwareSystemB)); + assertNotEquals(softwareSystemA, softwareSystemB); } @Test - public void test_equals_ReturnsFalse_WhenTheTwoElementsHaveTheSameCanonicalNameButAreDifferentTypes() { + void equals_ReturnsFalse_WhenTheTwoElementsHaveTheSameCanonicalNameButAreDifferentTypes() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); Person person = model.addPerson("Name", "Description"); - assertFalse(softwareSystem.equals(person)); + assertNotEquals(softwareSystem, person); } @Test - public void test_setUrl() { + void setUrl() { Element element = model.addSoftwareSystem("Name", "Description"); element.setUrl("https://structurizr.com"); assertEquals("https://structurizr.com", element.getUrl()); } - @Test(expected = IllegalArgumentException.class) - public void test_setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - Element element = model.addSoftwareSystem("Name", "Description"); - element.setUrl("htt://blah"); + @Test + void setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + Element element = model.addSoftwareSystem("Name", "Description"); + element.setUrl("htt://blah"); + }); } @Test - public void test_setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { + void setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); element.setUrl("https://structurizr.com"); element.setUrl(null); @@ -277,7 +275,7 @@ public void test_setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { } @Test - public void test_setUrl_ResetsTheUrl_WhenAnEmptyUrlIsSpecified() { + void setUrl_ResetsTheUrl_WhenAnEmptyUrlIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); element.setUrl("https://structurizr.com"); element.setUrl(" "); diff --git a/structurizr-core/test/unit/com/structurizr/model/GroupableElementTests.java b/structurizr-core/src/test/java/com/structurizr/model/GroupableElementTests.java similarity index 75% rename from structurizr-core/test/unit/com/structurizr/model/GroupableElementTests.java rename to structurizr-core/src/test/java/com/structurizr/model/GroupableElementTests.java index abd70a3e3..3c981f390 100644 --- a/structurizr-core/test/unit/com/structurizr/model/GroupableElementTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/GroupableElementTests.java @@ -1,35 +1,35 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; public class GroupableElementTests extends AbstractWorkspaceTestBase { @Test - public void test_getGroup_ReturnsNullByDefault() { + void getGroup_ReturnsNullByDefault() { Person element = model.addPerson("Person"); assertNull(element.getGroup()); } @Test - public void test_setGroup() { + void setGroup() { Person element = model.addPerson("Person"); element.setGroup("Group"); assertEquals("Group", element.getGroup()); } @Test - public void test_setGroup_TrimsWhiteSpace() { + void setGroup_TrimsWhiteSpace() { Person element = model.addPerson("Person"); element.setGroup(" Group "); assertEquals("Group", element.getGroup()); } @Test - public void test_setGroup_HandlesEmptyAndNullValues() { + void setGroup_HandlesEmptyAndNullValues() { Person element = model.addPerson("Person"); element.setGroup("Group"); diff --git a/structurizr-core/test/unit/com/structurizr/model/HttpHealthCheckTests.java b/structurizr-core/src/test/java/com/structurizr/model/HttpHealthCheckTests.java similarity index 76% rename from structurizr-core/test/unit/com/structurizr/model/HttpHealthCheckTests.java rename to structurizr-core/src/test/java/com/structurizr/model/HttpHealthCheckTests.java index 3610d812a..57935806b 100644 --- a/structurizr-core/test/unit/com/structurizr/model/HttpHealthCheckTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/HttpHealthCheckTests.java @@ -1,22 +1,22 @@ package com.structurizr.model; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; public class HttpHealthCheckTests { private HttpHealthCheck healthCheck; @Test - public void test_defaultConstructorExists() { + void defaultConstructorExists() { // the default constructor is used when deserializing from JSON healthCheck = new HttpHealthCheck(); } @Test - public void test_construction() { + void construction() { healthCheck = new HttpHealthCheck("Name", "http://localhost", 120, 1000); assertEquals("Name", healthCheck.getName()); assertEquals("http://localhost", healthCheck.getUrl()); @@ -25,14 +25,14 @@ public void test_construction() { } @Test - public void test_addHeader() { + void addHeader() { healthCheck = new HttpHealthCheck(); healthCheck.addHeader("Name", "Value"); assertEquals("Value", healthCheck.getHeaders().get("Name")); } @Test - public void test_addHeader_ThrowsAnException_WhenTheHeaderNameIsNull() { + void addHeader_ThrowsAnException_WhenTheHeaderNameIsNull() { healthCheck = new HttpHealthCheck(); try { healthCheck.addHeader(null, "value"); @@ -43,7 +43,7 @@ public void test_addHeader_ThrowsAnException_WhenTheHeaderNameIsNull() { } @Test - public void test_addHeader_ThrowsAnException_WhenTheHeaderNameIsEmpty() { + void addHeader_ThrowsAnException_WhenTheHeaderNameIsEmpty() { healthCheck = new HttpHealthCheck(); try { healthCheck.addHeader(" ", "value"); @@ -54,7 +54,7 @@ public void test_addHeader_ThrowsAnException_WhenTheHeaderNameIsEmpty() { } @Test - public void test_addHeader_ThrowsAnException_WhenTheHeaderValueIsNull() { + void addHeader_ThrowsAnException_WhenTheHeaderValueIsNull() { healthCheck = new HttpHealthCheck(); try { healthCheck.addHeader("Name", null); @@ -65,7 +65,7 @@ public void test_addHeader_ThrowsAnException_WhenTheHeaderValueIsNull() { } @Test - public void test_addHeader_DoesNotThrowAnException_WhenTheHeaderValueIsEmpty() { + void addHeader_DoesNotThrowAnException_WhenTheHeaderValueIsEmpty() { healthCheck = new HttpHealthCheck(); healthCheck.addHeader("Name", ""); assertEquals("", healthCheck.getHeaders().get("Name")); diff --git a/structurizr-core/test/unit/com/structurizr/model/InfrastructureNodeTests.java b/structurizr-core/src/test/java/com/structurizr/model/InfrastructureNodeTests.java similarity index 84% rename from structurizr-core/test/unit/com/structurizr/model/InfrastructureNodeTests.java rename to structurizr-core/src/test/java/com/structurizr/model/InfrastructureNodeTests.java index 11ec4c204..8ec8bb364 100644 --- a/structurizr-core/test/unit/com/structurizr/model/InfrastructureNodeTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/InfrastructureNodeTests.java @@ -1,14 +1,14 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class InfrastructureNodeTests extends AbstractWorkspaceTestBase { @Test - public void test_getCanonicalName() { + void getCanonicalName() { DeploymentNode deploymentNode = model.addDeploymentNode("Amazon Web Services", "", ""); InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Route 53", "", ""); @@ -16,7 +16,7 @@ public void test_getCanonicalName() { } @Test - public void test_getParent_ReturnsTheParentDeploymentNode() { + void getParent_ReturnsTheParentDeploymentNode() { DeploymentNode parent = model.addDeploymentNode("Parent", "", ""); InfrastructureNode child = parent.addInfrastructureNode("Child", "", ""); child.setParent(parent); @@ -24,7 +24,7 @@ public void test_getParent_ReturnsTheParentDeploymentNode() { } @Test - public void test_getRequiredTags() { + void getRequiredTags() { InfrastructureNode infrastructureNode = new InfrastructureNode(); assertEquals(2, infrastructureNode.getDefaultTags().size()); assertTrue(infrastructureNode.getDefaultTags().contains(Tags.ELEMENT)); @@ -32,7 +32,7 @@ public void test_getRequiredTags() { } @Test - public void test_getTags() { + void getTags() { InfrastructureNode infrastructureNode = new InfrastructureNode(); infrastructureNode.addTags("Tag 1", "Tag 2"); assertEquals("Element,Infrastructure Node,Tag 1,Tag 2", infrastructureNode.getTags()); diff --git a/structurizr-core/test/unit/com/structurizr/model/ModelItemTests.java b/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java similarity index 58% rename from structurizr-core/test/unit/com/structurizr/model/ModelItemTests.java rename to structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java index da1df410c..783a1c548 100644 --- a/structurizr-core/test/unit/com/structurizr/model/ModelItemTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/ModelItemTests.java @@ -1,56 +1,53 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.fail; +import static org.junit.jupiter.api.Assertions.*; public class ModelItemTests extends AbstractWorkspaceTestBase { - @Test - public void test_construction() { - Element element = model.addSoftwareSystem("Name", "Description"); - assertEquals("Name", element.getName()); - assertEquals("Description", element.getDescription()); - } + @Test + void construction() { + Element element = model.addSoftwareSystem("Name", "Description"); + assertEquals("Name", element.getName()); + assertEquals("Description", element.getDescription()); + } @Test - public void test_getTags_WhenThereAreNoTags() { + void getTags_WhenThereAreNoTags() { Element element = model.addSoftwareSystem("Name", "Description"); assertEquals("Element,Software System", element.getTags()); } @Test - public void test_hasTag_ChecksRequiredTags() { + void hasTag_ChecksRequiredTags() { SoftwareSystem system = model.addSoftwareSystem("Name", "Description"); - assertTrue("hasTag returns true for Software System", system.hasTag("Software System")); - assertTrue("hasTag returns true for Element", system.hasTag("Element")); + assertTrue(system.hasTag("Software System"), "hasTag returns true for Software System"); + assertTrue(system.hasTag("Element"), "hasTag returns true for Element"); } @Test - public void test_getTags_ReturnsTheListOfTags_WhenThereAreSomeTags() { + void getTags_ReturnsTheListOfTags_WhenThereAreSomeTags() { Element element = model.addSoftwareSystem("Name", "Description"); element.addTags("tag1", "tag2", "tag3"); assertEquals("Element,Software System,tag1,tag2,tag3", element.getTags()); } @Test - public void test_setTags_DoesNotDoAnything_WhenPassedNull() { + void setTags_DoesNotDoAnything_WhenPassedNull() { Element element = model.addSoftwareSystem("Name", "Description"); element.setTags(null); assertEquals("Element,Software System", element.getTags()); } @Test - public void test_addTags_DoesNotDoAnything_WhenPassedNull() { + void addTags_DoesNotDoAnything_WhenPassedNull() { Element element = model.addSoftwareSystem("Name", "Description"); - element.addTags((String)null); + element.addTags((String) null); assertEquals("Element,Software System", element.getTags()); element.addTags(null, null, null); @@ -58,37 +55,38 @@ public void test_addTags_DoesNotDoAnything_WhenPassedNull() { } @Test - public void test_addTags_AddsTags_WhenPassedSomeTags() { + void addTags_AddsTags_WhenPassedSomeTags() { Element element = model.addSoftwareSystem("Name", "Description"); element.addTags(null, "tag1", null, "tag2"); assertEquals("Element,Software System,tag1,tag2", element.getTags()); } @Test - public void test_addTags_AddsTags_WhenPassedSomeTagsAndThereAreDuplicateTags() { + void addTags_AddsTags_WhenPassedSomeTagsAndThereAreDuplicateTags() { Element element = model.addSoftwareSystem("Name", "Description"); element.addTags(null, "tag1", null, "tag2", "tag2"); assertEquals("Element,Software System,tag1,tag2", element.getTags()); } @Test - public void test_removeTags() { + void removeTags() { Element element = model.addSoftwareSystem("Name", "Description"); element.addTags("tag1", "tag2"); - assertTrue("Remove an existing tag returns true", element.removeTag("tag1")); - assertFalse("Tag has been removed", element.hasTag("tag1")); + assertTrue(element.removeTag("tag1"), "Remove an existing tag returns true"); + assertFalse(element.hasTag("tag1"), "Tag has been removed"); - assertFalse("Remove a non-existing tag returns false", element.removeTag("no-such-tag")); - assertFalse("Remove a required tag returns false", element.removeTag("Element")); + assertFalse(element.removeTag("no-such-tag"), "Remove a non-existing tag returns false"); + assertFalse(element.removeTag("Element"), "Remove a required tag returns false"); } + @Test - public void test_getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { + void getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { Element element = model.addSoftwareSystem("Name", "Description"); assertEquals(0, element.getProperties().size()); } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { + void addProperty_ThrowsAnException_WhenTheNameIsNull() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addProperty(null, "value"); @@ -99,7 +97,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { + void addProperty_ThrowsAnException_WhenTheNameIsEmpty() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addProperty(" ", "value"); @@ -110,7 +108,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { + void addProperty_ThrowsAnException_WhenTheValueIsNull() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addProperty("name", null); @@ -121,7 +119,7 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { + void addProperty_ThrowsAnException_WhenTheValueIsEmpty() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addProperty("name", " "); @@ -132,21 +130,21 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { } @Test - public void test_addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { + void addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); element.addProperty("AWS region", "us-east-1"); assertEquals("us-east-1", element.getProperties().get("AWS region")); } @Test - public void test_setProperties_DoesNothing_WhenNullIsSpecified() { + void setProperties_DoesNothing_WhenNullIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); element.setProperties(null); assertEquals(0, element.getProperties().size()); } @Test - public void test_setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { + void setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { Element element = model.addSoftwareSystem("Name", "Description"); Map properties = new HashMap<>(); properties.put("name", "value"); @@ -156,7 +154,7 @@ public void test_setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { } @Test - public void test_addPerspective_ThrowsAnException_WhenANameIsNotSpecified() { + void addPerspective_ThrowsAnException_WhenANameIsNotSpecified() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addPerspective(null, null); @@ -167,7 +165,7 @@ public void test_addPerspective_ThrowsAnException_WhenANameIsNotSpecified() { } @Test - public void test_addPerspective_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + void addPerspective_ThrowsAnException_WhenAnEmptyNameIsSpecified() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addPerspective(" ", null); @@ -176,8 +174,9 @@ public void test_addPerspective_ThrowsAnException_WhenAnEmptyNameIsSpecified() { assertEquals("A name must be specified.", iae.getMessage()); } } + @Test - public void test_addPerspective_ThrowsAnException_WhenADescriptionIsNotSpecified() { + void addPerspective_ThrowsAnException_WhenADescriptionIsNotSpecified() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addPerspective("Security", null); @@ -188,7 +187,7 @@ public void test_addPerspective_ThrowsAnException_WhenADescriptionIsNotSpecified } @Test - public void test_addPerspective_ThrowsAnException_WhenAnEmptyDescriptionIsSpecified() { + void addPerspective_ThrowsAnException_WhenAnEmptyDescriptionIsSpecified() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addPerspective("Security", " "); @@ -199,16 +198,24 @@ public void test_addPerspective_ThrowsAnException_WhenAnEmptyDescriptionIsSpecif } @Test - public void test_addPerspective_AddsAPerspective() { + void addPerspective_AddsAPerspective() { Element element = model.addSoftwareSystem("Name", "Description"); - Perspective perspective = element.addPerspective("Security", "Data is encrypted at rest."); - assertEquals("Security", perspective.getName()); - assertEquals("Data is encrypted at rest.", perspective.getDescription()); - assertTrue(element.getPerspectives().contains(perspective)); + + Perspective securityPerspective = element.addPerspective("Security", "Data is encrypted at rest."); + assertEquals("Security", securityPerspective.getName()); + assertEquals("Data is encrypted at rest.", securityPerspective.getDescription()); + assertEquals("", securityPerspective.getValue()); + assertTrue(element.getPerspectives().contains(securityPerspective)); + + Perspective technicalDebtPerspective = element.addPerspective("Technical Debt", "High tech debt due to feature X being delivered rapidly.", "High"); + assertEquals("Technical Debt", technicalDebtPerspective.getName()); + assertEquals("High tech debt due to feature X being delivered rapidly.", technicalDebtPerspective.getDescription()); + assertEquals("High", technicalDebtPerspective.getValue()); + assertTrue(element.getPerspectives().contains(technicalDebtPerspective)); } @Test - public void test_addPerspective_ThrowsAnException_WhenTheNamedPerspectiveAlreadyExists() { + void addPerspective_ThrowsAnException_WhenTheNamedPerspectiveAlreadyExists() { try { Element element = model.addSoftwareSystem("Name", "Description"); element.addPerspective("Security", "Data is encrypted at rest."); @@ -219,4 +226,32 @@ public void test_addPerspective_ThrowsAnException_WhenTheNamedPerspectiveAlready } } + @Test + void setUrl_AcceptsAUrl() { + Element element = model.addSoftwareSystem("Name"); + element.setUrl("https://structurizr.com"); + assertEquals("https://structurizr.com", element.getUrl()); + } + + @Test + void setUrl_AcceptsAnIntraWorkspaceUrl() { + Element element = model.addSoftwareSystem("Name"); + element.setUrl("{workspace}/diagrams#key"); + assertEquals("{workspace}/diagrams#key", element.getUrl()); + } + + @Test + void setUrl_AcceptsAnInterWorkspaceUrl() { + Element element = model.addSoftwareSystem("Name"); + + element.setUrl("{workspace:123456}"); + assertEquals("{workspace:123456}", element.getUrl()); + + element.setUrl("{workspace:123456}/diagrams#key"); + assertEquals("{workspace:123456}/diagrams#key", element.getUrl()); + + element.setUrl("{workspace:123456}/documentation"); + assertEquals("{workspace:123456}/documentation", element.getUrl()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/ModelTests.java b/structurizr-core/src/test/java/com/structurizr/model/ModelTests.java similarity index 79% rename from structurizr-core/test/unit/com/structurizr/model/ModelTests.java rename to structurizr-core/src/test/java/com/structurizr/model/ModelTests.java index 83c4b49cf..3bda19147 100644 --- a/structurizr-core/test/unit/com/structurizr/model/ModelTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/ModelTests.java @@ -1,42 +1,48 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Collections; -import java.util.Set; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ModelTests extends AbstractWorkspaceTestBase { - @Test(expected = IllegalArgumentException.class) - public void test_addSoftwareSystem_ThrowsAnException_WhenANullNameIsSpecified() { - model.addSoftwareSystem(null, ""); + @Test + void addSoftwareSystem_ThrowsAnException_WhenANullNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + model.addSoftwareSystem(null, ""); + }); } - @Test(expected = IllegalArgumentException.class) - public void test_addSoftwareSystem_ThrowsAnException_WhenAnEmptyNameIsSpecified() { - model.addSoftwareSystem(" ", ""); + @Test + void addSoftwareSystem_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + model.addSoftwareSystem(" ", ""); + }); } - @Test(expected = IllegalArgumentException.class) - public void test_addPerson_ThrowsAnException_WhenANullNameIsSpecified() { - model.addPerson(null, ""); + @Test + void addPerson_ThrowsAnException_WhenANullNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + model.addPerson(null, ""); + }); } - @Test(expected = IllegalArgumentException.class) - public void test_addPerson_ThrowsAnException_WhenAnEmptyNameIsSpecified() { - model.addPerson(" ", ""); + @Test + void addPerson_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + model.addPerson(" ", ""); + }); } @Test - public void test_addSoftwareSystem_AddsTheSoftwareSystem_WhenASoftwareSystemDoesNotExistWithTheSameName() { + void addSoftwareSystem_AddsTheSoftwareSystem_WhenASoftwareSystemDoesNotExistWithTheSameName() { assertTrue(model.getSoftwareSystems().isEmpty()); - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System A", "Some description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("System A", "Some description"); assertEquals(1, model.getSoftwareSystems().size()); - assertEquals(Location.External, softwareSystem.getLocation()); assertEquals("System A", softwareSystem.getName()); assertEquals("Some description", softwareSystem.getDescription()); assertEquals("1", softwareSystem.getId()); @@ -44,12 +50,12 @@ public void test_addSoftwareSystem_AddsTheSoftwareSystem_WhenASoftwareSystemDoes } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenASoftwareSystemExistsWithTheSameName() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System A", "Some description"); + void addSoftwareSystem_ThrowsAnException_WhenASoftwareSystemExistsWithTheSameName() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("System A", "Some description"); assertEquals(1, model.getSoftwareSystems().size()); try { - model.addSoftwareSystem(Location.External, "System A", "Description"); + model.addSoftwareSystem("System A", "Description"); fail(); } catch (Exception e) { assertEquals("A top-level element named 'System A' already exists.", e.getMessage()); @@ -57,12 +63,11 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenASoftwareSystemExistsWi } @Test - public void test_addSoftwareSystemWithoutSpecifyingLocation_AddsTheSoftwareSystem_WhenASoftwareSystemDoesNotExistWithTheSameName() { + void addSoftwareSystemWithoutSpecifyingLocation_AddsTheSoftwareSystem_WhenASoftwareSystemDoesNotExistWithTheSameName() { assertTrue(model.getSoftwareSystems().isEmpty()); SoftwareSystem softwareSystem = model.addSoftwareSystem("System A", "Some description"); assertEquals(1, model.getSoftwareSystems().size()); - assertEquals(Location.Unspecified, softwareSystem.getLocation()); assertEquals("System A", softwareSystem.getName()); assertEquals("Some description", softwareSystem.getDescription()); assertEquals("1", softwareSystem.getId()); @@ -70,12 +75,11 @@ public void test_addSoftwareSystemWithoutSpecifyingLocation_AddsTheSoftwareSyste } @Test - public void test_addPerson_AddsThePerson_WhenAPersonDoesNotExistWithTheSameName() { + void addPerson_AddsThePerson_WhenAPersonDoesNotExistWithTheSameName() { assertTrue(model.getPeople().isEmpty()); - Person person = model.addPerson(Location.Internal, "Some internal user", "Some description"); + Person person = model.addPerson("Some internal user", "Some description"); assertEquals(1, model.getPeople().size()); - assertEquals(Location.Internal, person.getLocation()); assertEquals("Some internal user", person.getName()); assertEquals("Some description", person.getDescription()); assertEquals("1", person.getId()); @@ -83,12 +87,12 @@ public void test_addPerson_AddsThePerson_WhenAPersonDoesNotExistWithTheSameName( } @Test - public void test_addPerson_ThrowsAnException_WhenAPersonExistsWithTheSameName() { - Person person = model.addPerson(Location.Internal, "Admin User", "Description"); + void addPerson_ThrowsAnException_WhenAPersonExistsWithTheSameName() { + Person person = model.addPerson("Admin User", "Description"); assertEquals(1, model.getPeople().size()); try { - model.addPerson(Location.External, "Admin User", "Description"); + model.addPerson("Admin User", "Description"); fail(); } catch (Exception e) { assertEquals("A top-level element named 'Admin User' already exists.", e.getMessage()); @@ -96,12 +100,11 @@ public void test_addPerson_ThrowsAnException_WhenAPersonExistsWithTheSameName() } @Test - public void test_addPerson_AddsThePersonWithoutSpecifyingTheLocation_WhenAPersonDoesNotExistWithTheSameName() { + void addPerson_AddsThePersonWithoutSpecifyingTheLocation_WhenAPersonDoesNotExistWithTheSameName() { assertTrue(model.getPeople().isEmpty()); Person person = model.addPerson("Some internal user", "Some description"); assertEquals(1, model.getPeople().size()); - assertEquals(Location.Unspecified, person.getLocation()); assertEquals("Some internal user", person.getName()); assertEquals("Some description", person.getDescription()); assertEquals("1", person.getId()); @@ -109,42 +112,42 @@ public void test_addPerson_AddsThePersonWithoutSpecifyingTheLocation_WhenAPerson } @Test - public void test_getElement_ReturnsNull_WhenAnElementWithTheSpecifiedIdDoesNotExist() { + void getElement_ReturnsNull_WhenAnElementWithTheSpecifiedIdDoesNotExist() { assertNull(model.getElement("100")); } @Test - public void test_getElement_ReturnsAnElement_WhenAnElementWithTheSpecifiedIdDoesExist() { - Person person = model.addPerson(Location.Internal, "Name", "Description"); + void getElement_ReturnsAnElement_WhenAnElementWithTheSpecifiedIdDoesExist() { + Person person = model.addPerson("Name", "Description"); assertSame(person, model.getElement(person.getId())); } @Test - public void test_contains_ReturnsFalse_WhenTheSpecifiedElementIsNotInTheModel() { + void contains_ReturnsFalse_WhenTheSpecifiedElementIsNotInTheModel() { Model newModel = new Model(); - SoftwareSystem softwareSystem = newModel.addSoftwareSystem(Location.Unspecified, "Name", "Description"); + SoftwareSystem softwareSystem = newModel.addSoftwareSystem("Name", "Description"); assertFalse(model.contains(softwareSystem)); } @Test - public void test_contains_ReturnsTrue_WhenTheSpecifiedElementIsInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Unspecified, "Name", "Description"); + void contains_ReturnsTrue_WhenTheSpecifiedElementIsInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); assertTrue(model.contains(softwareSystem)); } @Test - public void test_getSoftwareSystemWithName_ReturnsNull_WhenASoftwareSystemWithTheSpecifiedNameDoesNotExist() { + void getSoftwareSystemWithName_ReturnsNull_WhenASoftwareSystemWithTheSpecifiedNameDoesNotExist() { assertNull(model.getSoftwareSystemWithName("System X")); } @Test - public void test_getSoftwareSystemWithName_ReturnsASoftwareSystem_WhenASoftwareSystemWithTheSpecifiedNameExists() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System A", "Description"); + void getSoftwareSystemWithName_ReturnsASoftwareSystem_WhenASoftwareSystemWithTheSpecifiedNameExists() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("System A", "Description"); assertSame(softwareSystem, model.getSoftwareSystemWithName("System A")); } @Test - public void test_getSoftwareSystemWithId_ThrowsAnException_WhenPassedANullId() { + void getSoftwareSystemWithId_ThrowsAnException_WhenPassedANullId() { try { model.getSoftwareSystemWithId(null); fail(); @@ -154,7 +157,7 @@ public void test_getSoftwareSystemWithId_ThrowsAnException_WhenPassedANullId() { } @Test - public void test_getSoftwareSystemWithId_ThrowsAnException_WhenPassedAnEmptyId() { + void getSoftwareSystemWithId_ThrowsAnException_WhenPassedAnEmptyId() { try { model.getSoftwareSystemWithId(" "); fail(); @@ -164,29 +167,29 @@ public void test_getSoftwareSystemWithId_ThrowsAnException_WhenPassedAnEmptyId() } @Test - public void test_getSoftwareSystemWithId_ReturnsNull_WhenASoftwareSystemWithTheSpecifiedIdDoesNotExist() { + void getSoftwareSystemWithId_ReturnsNull_WhenASoftwareSystemWithTheSpecifiedIdDoesNotExist() { assertNull(model.getSoftwareSystemWithId("100")); } @Test - public void test_getSoftwareSystemWithId_ReturnsASoftwareSystem_WhenASoftwareSystemWithTheSpecifiedIdDoesExist() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System A", "Description"); + void getSoftwareSystemWithId_ReturnsASoftwareSystem_WhenASoftwareSystemWithTheSpecifiedIdDoesExist() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("System A", "Description"); assertSame(softwareSystem, model.getSoftwareSystemWithId(softwareSystem.getId())); } @Test - public void test_getPersonWithName_ReturnsNull_WhenAPersonWithTheSpecifiedNameDoesNotExist() { + void getPersonWithName_ReturnsNull_WhenAPersonWithTheSpecifiedNameDoesNotExist() { assertNull(model.getPersonWithName("Admin User")); } @Test - public void test_getPersonWithName_ReturnsAPerson_WhenAPersonWithTheSpecifiedNameExists() { - Person person = model.addPerson(Location.External, "Admin User", "Description"); + void getPersonWithName_ReturnsAPerson_WhenAPersonWithTheSpecifiedNameExists() { + Person person = model.addPerson("Admin User", "Description"); assertSame(person, model.getPersonWithName("Admin User")); } @Test - public void test_getRelationship_ThrowsAnException_WhenPassedANullId() { + void getRelationship_ThrowsAnException_WhenPassedANullId() { try { model.getRelationship(null); fail(); @@ -196,7 +199,7 @@ public void test_getRelationship_ThrowsAnException_WhenPassedANullId() { } @Test - public void test_getRelationship_ThrowsAnException_WhenPassedAnEmptyId() { + void getRelationship_ThrowsAnException_WhenPassedAnEmptyId() { try { model.getRelationship(" "); fail(); @@ -206,7 +209,7 @@ public void test_getRelationship_ThrowsAnException_WhenPassedAnEmptyId() { } @Test - public void test_addRelationship_AddsARelationshipWithTheSpecifiedDescriptionAndTechnologyAndInteractionStyle() { + void addRelationship_AddsARelationshipWithTheSpecifiedDescriptionAndTechnologyAndInteractionStyle() { SoftwareSystem a = model.addSoftwareSystem("A", ""); SoftwareSystem b = model.addSoftwareSystem("B", ""); Relationship relationship = model.addRelationship(a, b, "Uses", "HTTPS", InteractionStyle.Asynchronous); @@ -221,7 +224,7 @@ public void test_addRelationship_AddsARelationshipWithTheSpecifiedDescriptionAnd } @Test - public void test_addRelationship_DisallowsTheSameRelationshipToBeAddedMoreThanOnce() { + void addRelationship_DisallowsTheSameRelationshipToBeAddedMoreThanOnce() { SoftwareSystem element1 = model.addSoftwareSystem("Element 1", "Description"); SoftwareSystem element2 = model.addSoftwareSystem("Element 2", "Description"); Relationship relationship1 = element1.uses(element2, "Uses", ""); @@ -232,7 +235,7 @@ public void test_addRelationship_DisallowsTheSameRelationshipToBeAddedMoreThanOn } @Test - public void test_addRelationship_AllowsMultipleRelationshipsBetweenElements() { + void addRelationship_AllowsMultipleRelationshipsBetweenElements() { SoftwareSystem element1 = model.addSoftwareSystem("Element 1", "Description"); SoftwareSystem element2 = model.addSoftwareSystem("Element 2", "Description"); Relationship relationship1 = element1.uses(element2, "Uses in some way", ""); @@ -243,7 +246,7 @@ public void test_addRelationship_AllowsMultipleRelationshipsBetweenElements() { } @Test - public void test_addRelationship_ThrowsAnException_WhenTheDestinationIsAChildOfTheSource() { + void addRelationship_ThrowsAnException_WhenTheDestinationIsAChildOfTheSource() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "", ""); Component component = container.addComponent("Component", "", ""); @@ -274,7 +277,7 @@ public void test_addRelationship_ThrowsAnException_WhenTheDestinationIsAChildOfT } @Test - public void test_addRelationship_ThrowsAnException_WhenTheSourceIsAChildOfTheDestination() { + void addRelationship_ThrowsAnException_WhenTheSourceIsAChildOfTheDestination() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "", ""); Component component = container.addComponent("Component", "", ""); @@ -305,7 +308,7 @@ public void test_addRelationship_ThrowsAnException_WhenTheSourceIsAChildOfTheDes } @Test - public void test_modifyRelationship_ThrowsAnException_WhenARelationshipIsNotSpecified() { + void modifyRelationship_ThrowsAnException_WhenARelationshipIsNotSpecified() { try { model.modifyRelationship(null, "Uses", "Technology"); fail(); @@ -315,7 +318,7 @@ public void test_modifyRelationship_ThrowsAnException_WhenARelationshipIsNotSpec } @Test - public void test_modifyRelationship_ModifiesAnExistingRelationship_WhenThatRelationshipDoesNotAlreadyExist() { + void modifyRelationship_ModifiesAnExistingRelationship_WhenThatRelationshipDoesNotAlreadyExist() { SoftwareSystem element1 = model.addSoftwareSystem("Element 1", "Description"); SoftwareSystem element2 = model.addSoftwareSystem("Element 2", "Description"); Relationship relationship = element1.uses(element2, "", ""); @@ -326,7 +329,7 @@ public void test_modifyRelationship_ModifiesAnExistingRelationship_WhenThatRelat } @Test - public void test_modifyRelationship_ThrowsAnException_WhenThatRelationshipDoesAlreadyExist() { + void modifyRelationship_ThrowsAnException_WhenThatRelationshipDoesAlreadyExist() { SoftwareSystem element1 = model.addSoftwareSystem("Element 1", "Description"); SoftwareSystem element2 = model.addSoftwareSystem("Element 2", "Description"); Relationship relationship = element1.uses(element2, "Uses", "Technology"); @@ -340,7 +343,7 @@ public void test_modifyRelationship_ThrowsAnException_WhenThatRelationshipDoesAl } @Test - public void test_addSoftwareSystemInstance_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { + void addSoftwareSystemInstance_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { try { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); deploymentNode.add((SoftwareSystem) null); @@ -351,10 +354,10 @@ public void test_addSoftwareSystemInstance_ThrowsAnException_WhenANullSoftwareSy } @Test - public void test_addContainerInstance_ThrowsAnException_WhenANullContainerIsSpecified() { + void addContainerInstance_ThrowsAnException_WhenANullContainerIsSpecified() { try { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); - deploymentNode.add((Container)null); + deploymentNode.add((Container) null); fail(); } catch (Exception e) { assertEquals("A container must be specified.", e.getMessage()); @@ -362,7 +365,7 @@ public void test_addContainerInstance_ThrowsAnException_WhenANullContainerIsSpec } @Test - public void test_addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmentIsNotSpecified() { + void addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmentIsNotSpecified() { DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); assertEquals("Deployment Node", deploymentNode.getName()); @@ -372,7 +375,7 @@ public void test_addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmen } @Test - public void test_addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmentIsSpecified() { + void addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmentIsSpecified() { DeploymentNode deploymentNode = model.addDeploymentNode("Development", "Deployment Node", "Description", "Technology"); assertEquals("Deployment Node", deploymentNode.getName()); @@ -382,7 +385,7 @@ public void test_addDeploymentNode_AddsADeploymentNode_WhenADeploymentEnvironmen } @Test - public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironment() { + void addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironment() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); Container container1 = softwareSystem1.addContainer("Container 1", "Description", "Technology"); @@ -440,7 +443,7 @@ public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshi } @Test - public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndDefaultGroup() { + void addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndDefaultGroup() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System"); Container api = softwareSystem1.addContainer("API"); Container database = softwareSystem1.addContainer("Database"); @@ -474,7 +477,7 @@ public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshi } @Test - public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndSpecifiedGroup() { + void addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndSpecifiedGroup() { // in this test, container instances are added to two deployment groups: "Instance 1" and "Instance 2" // relationships are not replicated between element instances in other groups @@ -503,7 +506,7 @@ public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshi } @Test - public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndSpecifiedGroups() { + void addElementInstance_AddsElementInstancesAndReplicatesRelationshipsWithinTheDeploymentEnvironmentAndSpecifiedGroups() { // in this test: // - API container instances are added to "Instance 1", "Instance 2" and "Shared" // - database container instances are added to "Instance 1" and "Instance 2" @@ -543,7 +546,7 @@ public void test_addElementInstance_AddsElementInstancesAndReplicatesRelationshi } @Test - public void test_getElement_ThrowsAnException_WhenANullIdIsSpecified() { + void getElement_ThrowsAnException_WhenANullIdIsSpecified() { try { model.getElement(null); } catch (IllegalArgumentException iae) { @@ -552,7 +555,7 @@ public void test_getElement_ThrowsAnException_WhenANullIdIsSpecified() { } @Test - public void test_getElement_ThrowsAnException_WhenAnEmptyIdIsSpecified() { + void getElement_ThrowsAnException_WhenAnEmptyIdIsSpecified() { try { model.getElement(" "); } catch (IllegalArgumentException iae) { @@ -561,7 +564,7 @@ public void test_getElement_ThrowsAnException_WhenAnEmptyIdIsSpecified() { } @Test - public void test_getElementWithCanonicalName_ThrowsAnException_WhenANullCanonicalNameIsSpecified() { + void getElementWithCanonicalName_ThrowsAnException_WhenANullCanonicalNameIsSpecified() { try { model.getElementWithCanonicalName(null); } catch (IllegalArgumentException iae) { @@ -570,7 +573,7 @@ public void test_getElementWithCanonicalName_ThrowsAnException_WhenANullCanonica } @Test - public void test_getElementWithCanonicalName_ThrowsAnException_WhenAnEmptyCanonicalNameIsSpecified() { + void getElementWithCanonicalName_ThrowsAnException_WhenAnEmptyCanonicalNameIsSpecified() { try { model.getElementWithCanonicalName(" "); } catch (IllegalArgumentException iae) { @@ -579,21 +582,53 @@ public void test_getElementWithCanonicalName_ThrowsAnException_WhenAnEmptyCanoni } @Test - public void test_getElementWithCanonicalName_ReturnsNull_WhenAnElementWithTheSpecifiedCanonicalNameDoesNotExist() { - assertNull(model.getElementWithCanonicalName("Software System")); + void getElementWithCanonicalName_ReturnsNull_WhenAnElementWithTheSpecifiedCanonicalNameDoesNotExist() { + assertNull(model.getElementWithCanonicalName("SoftwareSystem://A")); + } + + @Test + void getElementWithCanonicalName_ReturnsTheElement_WhenAnElementWithTheSpecifiedCanonicalNameExists() { + SoftwareSystem a = model.addSoftwareSystem("A"); + Container b = a.addContainer("B"); + + assertSame(a, model.getElementWithCanonicalName("SoftwareSystem://A")); + assertSame(b, model.getElementWithCanonicalName("Container://A.B")); + } + + @Test + void getRelationshipWithCanonicalName_ThrowsAnException_WhenANullCanonicalNameIsSpecified() { + try { + model.getRelationshipWithCanonicalName(null); + } catch (IllegalArgumentException iae) { + assertEquals("A canonical name must be specified.", iae.getMessage()); + } + } + + @Test + void getRelationshipWithCanonicalName_ThrowsAnException_WhenAnEmptyCanonicalNameIsSpecified() { + try { + model.getRelationshipWithCanonicalName(" "); + } catch (IllegalArgumentException iae) { + assertEquals("A canonical name must be specified.", iae.getMessage()); + } + } + + @Test + void getRelationshipWithCanonicalName_ReturnsNull_WhenARelationshipWithTheSpecifiedCanonicalNameDoesNotExist() { + assertNull(model.getRelationshipWithCanonicalName("Relationship://SoftwareSystem://A -> SoftwareSystem://B (Uses)")); } @Test - public void test_getElementWithCanonicalName_ReturnsTheElement_WhenAnElementWithTheSpecifiedCanonicalNameExists() { - SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); - Container container = softwareSystem.addContainer("Web Application", "Description", "Technology"); + void getRelationshipWithCanonicalName_ReturnsTheRelationship_WhenARelationshipWithTheSpecifiedCanonicalNameExists() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship r = a.uses(b, "Uses"); - assertSame(softwareSystem, model.getElementWithCanonicalName("SoftwareSystem://Software System")); - assertSame(container, model.getElementWithCanonicalName("Container://Software System.Web Application")); + assertSame(r, model.getRelationshipWithCanonicalName("Relationship://SoftwareSystem://A -> SoftwareSystem://B (Uses)")); } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenADeploymentNodeWithTheSameNameAlreadyExists() { + void addDeploymentNode_ThrowsAnException_WhenADeploymentNodeWithTheSameNameAlreadyExists() { model.addDeploymentNode("Amazon AWS", "Description", "Technology"); try { model.addDeploymentNode("Amazon AWS", "Description", "Technology"); @@ -604,7 +639,7 @@ public void test_addDeploymentNode_ThrowsAnException_WhenADeploymentNodeWithTheS } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenAChildDeploymentNodeWithTheSameNameAlreadyExists() { + void addDeploymentNode_ThrowsAnException_WhenAChildDeploymentNodeWithTheSameNameAlreadyExists() { DeploymentNode deploymentNode = model.addDeploymentNode("Amazon Web Services"); deploymentNode.addDeploymentNode("AWS Region"); try { @@ -616,7 +651,7 @@ public void test_addDeploymentNode_ThrowsAnException_WhenAChildDeploymentNodeWit } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenAChildInfrastructureNodeWithTheSameNameAlreadyExists() { + void addDeploymentNode_ThrowsAnException_WhenAChildInfrastructureNodeWithTheSameNameAlreadyExists() { DeploymentNode deploymentNode = model.addDeploymentNode("Amazon Web Services"); deploymentNode.addInfrastructureNode("Node"); try { @@ -628,7 +663,7 @@ public void test_addDeploymentNode_ThrowsAnException_WhenAChildInfrastructureNod } @Test - public void test_addInfrastructureNode_ThrowsAnException_WhenAChildDeploymentNodeWithTheSameNameAlreadyExists() { + void addInfrastructureNode_ThrowsAnException_WhenAChildDeploymentNodeWithTheSameNameAlreadyExists() { DeploymentNode deploymentNode = model.addDeploymentNode("Amazon Web Services"); deploymentNode.addDeploymentNode("Node"); try { @@ -640,7 +675,7 @@ public void test_addInfrastructureNode_ThrowsAnException_WhenAChildDeploymentNod } @Test - public void test_addInfrastructureNode_ThrowsAnException_WhenAChildInfrastructureNodeWithTheSameNameAlreadyExists() { + void addInfrastructureNode_ThrowsAnException_WhenAChildInfrastructureNodeWithTheSameNameAlreadyExists() { DeploymentNode deploymentNode = model.addDeploymentNode("Amazon Web Services"); deploymentNode.addInfrastructureNode("Node"); try { @@ -652,7 +687,7 @@ public void test_addInfrastructureNode_ThrowsAnException_WhenAChildInfrastructur } @Test - public void test_setIdGenerator_ThrowsAnException_WhenANullIdGeneratorIsSpecified() { + void setIdGenerator_ThrowsAnException_WhenANullIdGeneratorIsSpecified() { try { model.setIdGenerator(null); fail(); @@ -662,7 +697,7 @@ public void test_setIdGenerator_ThrowsAnException_WhenANullIdGeneratorIsSpecifie } @Test - public void test_hydrate() { + void hydrate() { Person person = new Person(); person.setId("1"); person.setName("Person"); @@ -742,7 +777,7 @@ public void test_hydrate() { } @Test - public void test_impliedRelationshipStrategy() { + void impliedRelationshipStrategy() { // default strategy initially assertTrue(model.getImpliedRelationshipsStrategy() instanceof DefaultImpliedRelationshipsStrategy); @@ -751,7 +786,7 @@ public void test_impliedRelationshipStrategy() { } @Test - public void test_setImpliedRelationshipStrategy_ResetsToTheDefaultStrategy_WhenPassedNull() { + void setImpliedRelationshipStrategy_ResetsToTheDefaultStrategy_WhenPassedNull() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); model.setImpliedRelationshipsStrategy(null); @@ -759,7 +794,7 @@ public void test_setImpliedRelationshipStrategy_ResetsToTheDefaultStrategy_WhenP } @Test - public void test_addSoftwareSystemInstance_AllocatesInstanceIdsPerDeploymentNode() { + void addSoftwareSystemInstance_AllocatesInstanceIdsPerDeploymentNode() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); DeploymentNode deploymentNodeA = model.addDeploymentNode("Deployment Node A", "", ""); DeploymentNode deploymentNodeB = model.addDeploymentNode("Deployment Node B", "", ""); @@ -775,7 +810,7 @@ public void test_addSoftwareSystemInstance_AllocatesInstanceIdsPerDeploymentNode } @Test - public void test_addContainerInstance_AllocatesInstanceIdsPerDeploymentNode() { + void addContainerInstance_AllocatesInstanceIdsPerDeploymentNode() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "", ""); DeploymentNode deploymentNodeA = model.addDeploymentNode("Deployment Node A", "", ""); diff --git a/structurizr-core/test/unit/com/structurizr/model/PersonTests.java b/structurizr-core/src/test/java/com/structurizr/model/PersonTests.java similarity index 78% rename from structurizr-core/test/unit/com/structurizr/model/PersonTests.java rename to structurizr-core/src/test/java/com/structurizr/model/PersonTests.java index f51836712..63fd8a6ee 100644 --- a/structurizr-core/test/unit/com/structurizr/model/PersonTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/PersonTests.java @@ -1,33 +1,33 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class PersonTests extends AbstractWorkspaceTestBase { @Test - public void test_getCanonicalName() { + void getCanonicalName() { Person person = model.addPerson("Person", "Description"); assertEquals("Person://Person", person.getCanonicalName()); } @Test - public void test_getCanonicalName_WhenNameContainsSlashAndDotCharacters() { + void getCanonicalName_WhenNameContainsSlashAndDotCharacters() { Person person = model.addPerson("Person", "Description"); person.setName("Name1/.Name2"); assertEquals("Person://Name1Name2", person.getCanonicalName()); } @Test - public void test_getParent_ReturnsNull() { + void getParent_ReturnsNull() { Person person = model.addPerson("Person", "Description"); assertNull(person.getParent()); } @Test - public void test_removeTags_DoesNotRemoveRequiredTags() { + void removeTags_DoesNotRemoveRequiredTags() { Person person = model.addPerson("Person", "Description"); assertTrue(person.getTags().contains(Tags.ELEMENT)); assertTrue(person.getTags().contains(Tags.PERSON)); @@ -40,7 +40,7 @@ public void test_removeTags_DoesNotRemoveRequiredTags() { } @Test - public void test_interactsWith_AddsARelationshipWhenTheDescriptionIsSpecified() { + void interactsWith_AddsARelationshipWhenTheDescriptionIsSpecified() { Person person1 = model.addPerson("Person 1", "Description"); Person person2 = model.addPerson("Person 2", "Description"); @@ -56,7 +56,7 @@ public void test_interactsWith_AddsARelationshipWhenTheDescriptionIsSpecified() } @Test - public void test_interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyAreSpecified() { + void interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyAreSpecified() { Person person1 = model.addPerson("Person 1", "Description"); Person person2 = model.addPerson("Person 2", "Description"); @@ -72,7 +72,7 @@ public void test_interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyA } @Test - public void test_interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAreSpecified() { + void interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyAndInteractionStyleAreSpecified() { Person person1 = model.addPerson("Person 1", "Description"); Person person2 = model.addPerson("Person 2", "Description"); @@ -87,11 +87,4 @@ public void test_interactsWith_AddsARelationshipWhenTheDescriptionAndTechnologyA assertEquals(InteractionStyle.Asynchronous, relationship.getInteractionStyle()); } - @Test - public void test_setLocation_SetsTheLocationToUnspecified_WhenNullIsPassed() { - Person person = model.addPerson("Person", "Description"); - person.setLocation(null); - assertEquals(Location.Unspecified, person.getLocation()); - } - } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/RelationshipTests.java b/structurizr-core/src/test/java/com/structurizr/model/RelationshipTests.java similarity index 68% rename from structurizr-core/test/unit/com/structurizr/model/RelationshipTests.java rename to structurizr-core/src/test/java/com/structurizr/model/RelationshipTests.java index da5fba715..be7c631dd 100644 --- a/structurizr-core/test/unit/com/structurizr/model/RelationshipTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/RelationshipTests.java @@ -1,42 +1,42 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class RelationshipTests extends AbstractWorkspaceTestBase { private SoftwareSystem softwareSystem1, softwareSystem2; - @Before + @BeforeEach public void setUp() { - softwareSystem1 = model.addSoftwareSystem(Location.Internal, "Name1", "Description"); - softwareSystem2 = model.addSoftwareSystem(Location.Internal, "Name2", "Description"); + softwareSystem1 = model.addSoftwareSystem("Name1", "Description"); + softwareSystem2 = model.addSoftwareSystem("Name2", "Description"); } @Test - public void test_getDescription_NeverReturnsNull() { + void getDescription_NeverReturnsNull() { Relationship relationship = softwareSystem1.uses(softwareSystem2, null); assertEquals("", relationship.getDescription()); } @Test - public void test_getTags_WhenThereAreNoTags() { + void getTags_WhenThereAreNoTags() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); assertEquals("Relationship", relationship.getTags()); } @Test - public void test_getTags_ReturnsTheListOfTags_WhenThereAreSomeTags() { + void getTags_ReturnsTheListOfTags_WhenThereAreSomeTags() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); relationship.addTags("tag1", "tag2", "tag3"); assertEquals("Relationship,tag1,tag2,tag3", relationship.getTags()); } @Test - public void test_setTags_ClearsTheTags_WhenPassedNull() { + void setTags_ClearsTheTags_WhenPassedNull() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); relationship.addTags("Tag 1", "Tag 2"); assertEquals("Relationship,Tag 1,Tag 2", relationship.getTags()); @@ -45,9 +45,9 @@ public void test_setTags_ClearsTheTags_WhenPassedNull() { } @Test - public void test_addTags_DoesNotDoAnything_WhenPassedNull() { + void addTags_DoesNotDoAnything_WhenPassedNull() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); - relationship.addTags((String)null); + relationship.addTags((String) null); assertEquals("Relationship", relationship.getTags()); relationship.addTags(null, null, null); @@ -55,20 +55,20 @@ public void test_addTags_DoesNotDoAnything_WhenPassedNull() { } @Test - public void test_addTags_AddsTags_WhenPassedSomeTags() { + void addTags_AddsTags_WhenPassedSomeTags() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); relationship.addTags(null, "tag1", null, "tag2"); assertEquals("Relationship,tag1,tag2", relationship.getTags()); } @Test - public void test_getInteractionStyle_ReturnsNull_WhenNotExplicitlySet() { + void getInteractionStyle_ReturnsNull_WhenNotExplicitlySet() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "uses"); assertNull(relationship.getInteractionStyle()); } @Test - public void test_getTags_IncludesTheInteractionStyleWhenSpecified() { + void getTags_IncludesTheInteractionStyleWhenSpecified() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); assertFalse(relationship.getTags().contains(Tags.SYNCHRONOUS)); assertFalse(relationship.getTags().contains(Tags.ASYNCHRONOUS)); @@ -83,20 +83,22 @@ public void test_getTags_IncludesTheInteractionStyleWhenSpecified() { } @Test - public void test_setUrl() { + void setUrl() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); relationship.setUrl("https://structurizr.com"); assertEquals("https://structurizr.com", relationship.getUrl()); } - @Test(expected = IllegalArgumentException.class) - public void test_setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); - relationship.setUrl("htt://blah"); + @Test + void setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); + relationship.setUrl("htt://blah"); + }); } @Test - public void test_setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { + void setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); relationship.setUrl("https://structurizr.com"); relationship.setUrl(null); @@ -104,7 +106,7 @@ public void test_setUrl_ResetsTheUrl_WhenANullUrlIsSpecified() { } @Test - public void test_setUrl_ResetsTheUrl_WhenAnEmptyUrlIsSpecified() { + void setUrl_ResetsTheUrl_WhenAnEmptyUrlIsSpecified() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology"); relationship.setUrl("https://structurizr.com"); relationship.setUrl(" "); @@ -112,7 +114,7 @@ public void test_setUrl_ResetsTheUrl_WhenAnEmptyUrlIsSpecified() { } @Test - public void test_interactionStyle_CanBeSetToNull() { + void interactionStyle_CanBeSetToNull() { Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses 1", "Technology", null); assertNull(relationship.getInteractionStyle()); @@ -120,4 +122,14 @@ public void test_interactionStyle_CanBeSetToNull() { assertFalse(relationship.getTagsAsSet().contains(Tags.SYNCHRONOUS)); } + @Test + void relationshipDescriptionsAreCaseSensitive() { + softwareSystem1.uses(softwareSystem2, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + softwareSystem1.uses(softwareSystem2, "USES"); + softwareSystem1.uses(softwareSystem2, "uses"); + + assertEquals(3, softwareSystem1.getRelationships().size()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/SoftwareSystemInstanceTests.java b/structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemInstanceTests.java similarity index 82% rename from structurizr-core/test/unit/com/structurizr/model/SoftwareSystemInstanceTests.java rename to structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemInstanceTests.java index 8930aac5e..6795bee01 100644 --- a/structurizr-core/test/unit/com/structurizr/model/SoftwareSystemInstanceTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemInstanceTests.java @@ -1,17 +1,17 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class SoftwareSystemInstanceTests extends AbstractWorkspaceTestBase { - private SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System", "Description"); + private SoftwareSystem softwareSystem = model.addSoftwareSystem("System", "Description"); private DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @Test - public void test_construction() { + void construction() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertSame(softwareSystem, instance.getSoftwareSystem()); @@ -20,7 +20,7 @@ public void test_construction() { } @Test - public void test_getSoftwareSystemId() { + void getSoftwareSystemId() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertEquals(softwareSystem.getId(), instance.getSoftwareSystemId()); @@ -30,7 +30,7 @@ public void test_getSoftwareSystemId() { } @Test - public void test_getName_CannotBeChanged() { + void getName_CannotBeChanged() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertEquals("System", instance.getName()); @@ -40,28 +40,28 @@ public void test_getName_CannotBeChanged() { } @Test - public void test_getCanonicalName() { + void getCanonicalName() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertEquals("SoftwareSystemInstance://Default/Deployment Node/System[1]", instance.getCanonicalName()); } @Test - public void test_getParent_ReturnsTheParentDeploymentNode() { + void getParent_ReturnsTheParentDeploymentNode() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertEquals(deploymentNode, instance.getParent()); } @Test - public void test_getRequiredTags() { + void getRequiredTags() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertTrue(instance.getDefaultTags().isEmpty()); } @Test - public void test_getTags() { + void getTags() { softwareSystem.addTags("Tag 1"); SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); instance.addTags("Primary Instance"); @@ -70,7 +70,7 @@ public void test_getTags() { } @Test - public void test_removeTags_DoesNotRemoveAnyTags() { + void removeTags_DoesNotRemoveAnyTags() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertTrue(instance.getTags().contains(Tags.SOFTWARE_SYSTEM_INSTANCE)); @@ -81,7 +81,7 @@ public void test_removeTags_DoesNotRemoveAnyTags() { } @Test - public void test_addHealthCheck() { + void addHealthCheck() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertTrue(instance.getHealthChecks().isEmpty()); @@ -94,7 +94,7 @@ public void test_addHealthCheck() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { + void addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -106,7 +106,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsNull() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { + void addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -118,7 +118,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheNameIsEmpty() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -130,7 +130,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsNull() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -142,7 +142,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsEmpty() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { + void addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -154,7 +154,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheUrlIsInvalid() { } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero() { + void addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -166,7 +166,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheIntervalIsLessThanZero( } @Test - public void test_addHealthCheck_ThrowsAnException_WhenTheTimeoutIsLessThanZero() { + void addHealthCheck_ThrowsAnException_WhenTheTimeoutIsLessThanZero() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); try { @@ -178,7 +178,7 @@ public void test_addHealthCheck_ThrowsAnException_WhenTheTimeoutIsLessThanZero() } @Test - public void test_getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { + void getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem); assertEquals(1, instance.getDeploymentGroups().size()); @@ -186,7 +186,7 @@ public void test_getDeploymentGroups_WhenNoGroupsHaveBeenSpecified() { } @Test - public void test_getDeploymentGroups_WhenOneGroupHasBeenSpecified() { + void getDeploymentGroups_WhenOneGroupHasBeenSpecified() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem, "Group 1"); assertEquals(1, instance.getDeploymentGroups().size()); @@ -194,7 +194,7 @@ public void test_getDeploymentGroups_WhenOneGroupHasBeenSpecified() { } @Test - public void test_getDeploymentGroups_WhenMultipleGroupsAreSpecified() { + void getDeploymentGroups_WhenMultipleGroupsAreSpecified() { SoftwareSystemInstance instance = deploymentNode.add(softwareSystem, "Group 1", "Group 2"); assertEquals(2, instance.getDeploymentGroups().size()); diff --git a/structurizr-core/test/unit/com/structurizr/model/SoftwareSystemTests.java b/structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemTests.java similarity index 63% rename from structurizr-core/test/unit/com/structurizr/model/SoftwareSystemTests.java rename to structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemTests.java index 9d7e020bb..51b97f3e7 100644 --- a/structurizr-core/test/unit/com/structurizr/model/SoftwareSystemTests.java +++ b/structurizr-core/src/test/java/com/structurizr/model/SoftwareSystemTests.java @@ -1,28 +1,32 @@ package com.structurizr.model; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Iterator; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class SoftwareSystemTests extends AbstractWorkspaceTestBase { - private SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "Name", "Description"); + private SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - @Test(expected = IllegalArgumentException.class) - public void test_addContainer_ThrowsAnException_WhenANullNameIsSpecified() { - softwareSystem.addContainer(null, "", ""); + @Test + void addContainer_ThrowsAnException_WhenANullNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + softwareSystem.addContainer(null, "", ""); + }); } - @Test(expected = IllegalArgumentException.class) - public void test_addContainer_ThrowsAnException_WhenAnEmptyNameIsSpecified() { - softwareSystem.addContainer(" ", "", ""); + @Test + void addContainer_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + softwareSystem.addContainer(" ", "", ""); + }); } @Test - public void test_addContainer_AddsAContainer_WhenAContainerWithTheSameNameDoesNotExist() { + void addContainer_AddsAContainer_WhenAContainerWithTheSameNameDoesNotExist() { Container container = softwareSystem.addContainer("Web Application", "Description", "Spring MVC"); assertEquals("Web Application", container.getName()); assertEquals("Description", container.getDescription()); @@ -33,7 +37,7 @@ public void test_addContainer_AddsAContainer_WhenAContainerWithTheSameNameDoesNo } @Test - public void test_addContainer_ThrowsAnException_WhenAContainerWithTheSameNameAlreadyExists() { + void addContainer_ThrowsAnException_WhenAContainerWithTheSameNameAlreadyExists() { Container container = softwareSystem.addContainer("Web Application", "Description", "Spring MVC"); assertEquals(1, softwareSystem.getContainers().size()); @@ -46,31 +50,31 @@ public void test_addContainer_ThrowsAnException_WhenAContainerWithTheSameNameAlr } @Test - public void test_getContainerWithName_ReturnsNull_WhenAContainerWithTheSpecifiedNameDoesNotExist() { + void getContainerWithName_ReturnsNull_WhenAContainerWithTheSpecifiedNameDoesNotExist() { assertNull(softwareSystem.getContainerWithName("Web Application")); } @Test - public void test_GetContainerWithName_ReturnsAContainer_WhenAContainerWithTheSpecifiedNameDoesExist() { + void GetContainerWithName_ReturnsAContainer_WhenAContainerWithTheSpecifiedNameDoesExist() { Container container = softwareSystem.addContainer("Web Application", "Description", "Spring MVC"); assertSame(container, softwareSystem.getContainerWithName("Web Application")); } @Test - public void test_getContainerWithId_ReturnsNull_WhenAContainerWithTheSpecifiedIdDoesNotExist() { + void getContainerWithId_ReturnsNull_WhenAContainerWithTheSpecifiedIdDoesNotExist() { assertNull(softwareSystem.getContainerWithId("100")); } @Test - public void test_GetContainerWithId_ReturnsAContainer_WhenAContainerWithTheSpecifiedIdDoesExist() { + void GetContainerWithId_ReturnsAContainer_WhenAContainerWithTheSpecifiedIdDoesExist() { Container container = softwareSystem.addContainer("Web Application", "Description", "Spring MVC"); assertSame(container, softwareSystem.getContainerWithId(container.getId())); } @Test - public void test_uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems() { - SoftwareSystem systemA = model.addSoftwareSystem(Location.Internal, "System A", "Description"); - SoftwareSystem systemB = model.addSoftwareSystem(Location.Internal, "System B", "Description"); + void uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems() { + SoftwareSystem systemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem systemB = model.addSoftwareSystem("System B", "Description"); systemA.uses(systemB, "Gets some data from"); assertEquals(1, systemA.getRelationships().size()); @@ -82,9 +86,9 @@ public void test_uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems() } @Test - public void test_uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems_WhenADifferentRelationshipAlreadyExists() { - SoftwareSystem systemA = model.addSoftwareSystem(Location.Internal, "System A", "Description"); - SoftwareSystem systemB = model.addSoftwareSystem(Location.Internal, "System B", "Description"); + void uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems_WhenADifferentRelationshipAlreadyExists() { + SoftwareSystem systemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem systemB = model.addSoftwareSystem("System B", "Description"); systemA.uses(systemB, "Gets data using the REST API"); systemA.uses(systemB, "Subscribes to updates using the Streaming API"); @@ -102,9 +106,9 @@ public void test_uses_AddsAUnidirectionalRelationshipBetweenTwoSoftwareSystems_W } @Test - public void test_uses_DoesNotAddAUnidirectionalRelationshipBetweenTwoSoftwareSystems_WhenTheSameRelationshipAlreadyExists() { - SoftwareSystem systemA = model.addSoftwareSystem(Location.Internal, "System A", "Description"); - SoftwareSystem systemB = model.addSoftwareSystem(Location.Internal, "System B", "Description"); + void uses_DoesNotAddAUnidirectionalRelationshipBetweenTwoSoftwareSystems_WhenTheSameRelationshipAlreadyExists() { + SoftwareSystem systemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem systemB = model.addSoftwareSystem("System B", "Description"); systemA.uses(systemB, "Gets data using the REST API"); systemA.uses(systemB, "Gets data using the REST API"); @@ -112,9 +116,9 @@ public void test_uses_DoesNotAddAUnidirectionalRelationshipBetweenTwoSoftwareSys } @Test - public void test_delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson() { - SoftwareSystem system = model.addSoftwareSystem(Location.Internal, "System", "Description"); - Person person = model.addPerson(Location.Internal, "User", "Description"); + void delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson() { + SoftwareSystem system = model.addSoftwareSystem("System", "Description"); + Person person = model.addPerson("User", "Description"); system.delivers(person, "E-mails results to"); assertEquals(1, system.getRelationships().size()); @@ -126,9 +130,9 @@ public void test_delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemA } @Test - public void test_delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson_WhenADifferentRelationshipAlreadyExists() { - SoftwareSystem system = model.addSoftwareSystem(Location.Internal, "System", "Description"); - Person person = model.addPerson(Location.Internal, "User", "Description"); + void delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson_WhenADifferentRelationshipAlreadyExists() { + SoftwareSystem system = model.addSoftwareSystem("System", "Description"); + Person person = model.addPerson("User", "Description"); system.delivers(person, "E-mails results to"); system.delivers(person, "Text messages results to"); @@ -147,9 +151,9 @@ public void test_delivers_AddsAUnidirectionalRelationshipBetweenASoftwareSystemA } @Test - public void test_delivers_DoesNotAddAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson_WhenTheSameRelationshipAlreadyExists() { - SoftwareSystem system = model.addSoftwareSystem(Location.Internal, "System", "Description"); - Person person = model.addPerson(Location.Internal, "User", "Description"); + void delivers_DoesNotAddAUnidirectionalRelationshipBetweenASoftwareSystemAndAPerson_WhenTheSameRelationshipAlreadyExists() { + SoftwareSystem system = model.addSoftwareSystem("System", "Description"); + Person person = model.addPerson("User", "Description"); system.delivers(person, "E-mails results to"); system.delivers(person, "E-mails results to"); @@ -157,30 +161,30 @@ public void test_delivers_DoesNotAddAUnidirectionalRelationshipBetweenASoftwareS } @Test - public void test_getTags_IncludesSoftwareSystemByDefault() { - SoftwareSystem system = model.addSoftwareSystem(Location.Internal, "System", "Description"); + void getTags_IncludesSoftwareSystemByDefault() { + SoftwareSystem system = model.addSoftwareSystem("System", "Description"); assertEquals("Element,Software System", system.getTags()); } @Test - public void test_getCanonicalName() { - SoftwareSystem system = model.addSoftwareSystem(Location.Internal, "System", "Description"); + void getCanonicalName() { + SoftwareSystem system = model.addSoftwareSystem("System", "Description"); assertEquals("SoftwareSystem://System", system.getCanonicalName()); } @Test - public void test_getCanonicalName_WhenNameContainsSlashAndDotCharacters() { + void getCanonicalName_WhenNameContainsSlashAndDotCharacters() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name1/.Name2", "Description"); assertEquals("SoftwareSystem://Name1Name2", softwareSystem.getCanonicalName()); } @Test - public void test_getParent_ReturnsNull() { + void getParent_ReturnsNull() { assertNull(softwareSystem.getParent()); } @Test - public void test_removeTags_DoesNotRemoveRequiredTags() { + void removeTags_DoesNotRemoveRequiredTags() { assertTrue(softwareSystem.getTags().contains(Tags.ELEMENT)); assertTrue(softwareSystem.getTags().contains(Tags.SOFTWARE_SYSTEM)); @@ -192,7 +196,7 @@ public void test_removeTags_DoesNotRemoveRequiredTags() { } @Test - public void test_getContainerWithName_ThrowsAnException_WhenANullNameIsSpecified() { + void getContainerWithName_ThrowsAnException_WhenANullNameIsSpecified() { try { softwareSystem.getContainerWithName(null); fail(); @@ -202,7 +206,7 @@ public void test_getContainerWithName_ThrowsAnException_WhenANullNameIsSpecified } @Test - public void test_getContainerWithName_ThrowsAnException_WhenAnEmptyNameIsSpecified() { + void getContainerWithName_ThrowsAnException_WhenAnEmptyNameIsSpecified() { try { softwareSystem.getContainerWithName(" "); fail(); @@ -212,7 +216,7 @@ public void test_getContainerWithName_ThrowsAnException_WhenAnEmptyNameIsSpecifi } @Test - public void test_getContainerWithId_ThrowsAnException_WhenANullIdIsSpecified() { + void getContainerWithId_ThrowsAnException_WhenANullIdIsSpecified() { try { softwareSystem.getContainerWithId(null); fail(); @@ -222,7 +226,7 @@ public void test_getContainerWithId_ThrowsAnException_WhenANullIdIsSpecified() { } @Test - public void test_getContainerWithId_ThrowsAnException_WhenAnEmptyIdIsSpecified() { + void getContainerWithId_ThrowsAnException_WhenAnEmptyIdIsSpecified() { try { softwareSystem.getContainerWithId(" "); fail(); diff --git a/structurizr-core/src/test/java/com/structurizr/util/ImageUtilsTests.java b/structurizr-core/src/test/java/com/structurizr/util/ImageUtilsTests.java new file mode 100644 index 000000000..5f2a700bc --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/util/ImageUtilsTests.java @@ -0,0 +1,215 @@ +package com.structurizr.util; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImageUtilsTests { + + @Test + void getContentType_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { + try { + ImageUtils.getContentType((File)null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A file must be specified.", iae.getMessage()); + } + } + + @Test + void getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { + try { + ImageUtils.getContentType(new File("../structurizr-core")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); + } + } + + @Test + void getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { + try { + ImageUtils.getContentType(new File("../build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); + } + } + + @Test + void getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { + try { + ImageUtils.getContentType(new File("./foo.xml")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); + } + } + + @Test + void getContentType_ReturnsTheContentType_WhenAPNGFileIsSpecified() throws Exception { + String contentType = ImageUtils.getContentType(new File("./src/test/resources/image.png")); + assertEquals("image/png", contentType); + } + + @Test + void getContentType_ReturnsTheContentType_WhenASVGFileIsSpecified() throws Exception { + String contentType = ImageUtils.getContentType(new File("./src/test/resources/image.svg")); + assertEquals("image/svg+xml", contentType); + } + + @Test + void getContentTypeFromDataUri_ThrowsAnException_WhenANullDataUriIsSpecified() throws Exception { + try { + ImageUtils.getContentTypeFromDataUri(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A data URI must be specified.", iae.getMessage()); + } + } + + @Test + void getContentTypeFromDataUri_ReturnsTheContentType_WhenAUrlIsSpecified() throws Exception { + assertEquals("image/png", ImageUtils.getContentTypeFromDataUri("data:image/png;base64,...")); + assertEquals("image/jpeg", ImageUtils.getContentTypeFromDataUri("data:image/jpeg;base64,...")); + assertEquals("image/svg+xml", ImageUtils.getContentTypeFromDataUri("data:image/svg+xml;utf8,...")); + assertNull(ImageUtils.getContentTypeFromDataUri("data:...")); + } + + @Test + void getImageAsBase64_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { + try { + ImageUtils.getImageAsBase64(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A file must be specified.", iae.getMessage()); + } + } + + @Test + void getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { + try { + ImageUtils.getImageAsBase64(new File("../structurizr-core")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); + } + } + + @Test + void getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { + try { + ImageUtils.getImageAsBase64(new File("../build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); + } + } + + @Test + void getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { + try { + ImageUtils.getImageAsBase64(new File("./foo.xml")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); + } + } + + @Test + void getImageAsBase64_ReturnsTheImageAsABase64EncodedString_WhenAPNGFileIsSpecified() throws Exception { + String imageAsBase64 = ImageUtils.getImageAsBase64(new File("./src/test/resources/image.png")); + assertEquals("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", imageAsBase64); + } + + @Test + void getImageAsBase64_ReturnsTheImageAsABase64EncodedString_WhenASVGFileIsSpecified() throws Exception { + String imageAsBase64 = ImageUtils.getImageAsBase64(new File("./src/test/resources/image.svg")); + assertEquals("PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXMtYXNjaWkiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBjb250ZW50U3R5bGVUeXBlPSJ0ZXh0L2NzcyIgaGVpZ2h0PSIxMjBweCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIgc3R5bGU9IndpZHRoOjExM3B4O2hlaWdodDoxMjBweDtiYWNrZ3JvdW5kOiNGRkZGRkY7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxMTMgMTIwIiB3aWR0aD0iMTEzcHgiIHpvb21BbmRQYW49Im1hZ25pZnkiPjxkZWZzLz48Zz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjAuNTtzdHJva2UtZGFzaGFycmF5OjUuMCw1LjA7IiB4MT0iMjYiIHgyPSIyNiIgeTE9IjM2LjI5NjkiIHkyPSI4NS40Mjk3Ii8+PGxpbmUgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7c3Ryb2tlLWRhc2hhcnJheTo1LjAsNS4wOyIgeDE9IjgyIiB4Mj0iODIiIHkxPSIzNi4yOTY5IiB5Mj0iODUuNDI5NyIvPjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDMiIHg9IjUiIHk9IjUiLz48dGV4dCBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgbGVuZ3RoQWRqdXN0PSJzcGFjaW5nIiB0ZXh0TGVuZ3RoPSIyOSIgeD0iMTIiIHk9IjI0Ljk5NTEiPkJvYjwvdGV4dD48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQzIiB4PSI1IiB5PSI4NC40Mjk3Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMjkiIHg9IjEyIiB5PSIxMDQuNDI0OCI+Qm9iPC90ZXh0PjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDkiIHg9IjU4IiB5PSI1Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMzUiIHg9IjY1IiB5PSIyNC45OTUxIj5BbGljZTwvdGV4dD48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQ5IiB4PSI1OCIgeT0iODQuNDI5NyIvPjx0ZXh0IGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0IiBsZW5ndGhBZGp1c3Q9InNwYWNpbmciIHRleHRMZW5ndGg9IjM1IiB4PSI2NSIgeT0iMTA0LjQyNDgiPkFsaWNlPC90ZXh0Pjxwb2x5Z29uIGZpbGw9IiMxODE4MTgiIHBvaW50cz0iNzAuNSw2My40Mjk3LDgwLjUsNjcuNDI5Nyw3MC41LDcxLjQyOTcsNzQuNSw2Ny40Mjk3IiBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjEuMDsiLz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjEuMDsiIHgxPSIyNi41IiB4Mj0iNzYuNSIgeTE9IjY3LjQyOTciIHkyPSI2Ny40Mjk3Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTMiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMzAiIHg9IjMzLjUiIHk9IjYyLjM2MzgiPmhlbGxvPC90ZXh0PjwhLS1TUkM9W1N5ZkZLajJyS3QzQ29LbkVMUjFJbzRaRG9TYTcwMDAwXS0tPjwvZz48L3N2Zz4=", imageAsBase64); + } + + @Test + void getImageAsDataUri_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { + try { + ImageUtils.getImageAsDataUri(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A file must be specified.", iae.getMessage()); + } + } + + @Test + void getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { + try { + ImageUtils.getImageAsDataUri(new File("../structurizr-core")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); + } + } + + @Test + void getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { + try { + ImageUtils.getImageAsDataUri(new File("../build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + iae.printStackTrace(); + assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); + } + } + + @Test + void getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { + try { + ImageUtils.getImageAsDataUri(new File("./foo.xml")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); + } + } + + @Test + void getImageAsDataUri_ReturnsTheImageAsADataUri_WhenAFileIsSpecified() throws Exception { + String imageAsDataUri = ImageUtils.getImageAsDataUri(new File("./src/test/resources/image.png")); + assertTrue(imageAsDataUri.startsWith("data:image/png;base64,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")); + } + + @Test + void validateImage() { + // allowed + ImageUtils.validateImage("https://structurizr.com/image.png"); + ImageUtils.validateImage("data:image/png;base64,iVBORw0KGg"); + ImageUtils.validateImage("data:image/jpeg;base64,iVBORw0KGg"); + ImageUtils.validateImage("image.png"); + ImageUtils.validateImage("image.jpg"); + ImageUtils.validateImage("image.jpeg"); + ImageUtils.validateImage("image.gif"); + ImageUtils.validateImage("image.svg"); + ImageUtils.validateImage("data:image/svg+xml;utf8,iVBORw0KGg"); + + //disallowed + try { + ImageUtils.validateImage("data:image/other"); + fail(); + } catch (Exception e) { + assertEquals("Only PNG, JPG, and SVG data URIs are supported: data:image/other", e.getMessage()); + } + } + + @Test + void isSupportedDataUri() { + assertTrue(ImageUtils.isSupportedDataUri("data:image/png;base64,iVBORw0KGg")); + assertTrue(ImageUtils.isSupportedDataUri("data:image/jpeg;base64,iVBORw0KGg")); + assertTrue(ImageUtils.isSupportedDataUri("data:image/svg+xml;utf8, { + branding.setLogo("htt://blah"); + }); } @Test - public void test_setLogo_DoesNothing_WhenANullUrlIsSpecified() { + void setLogo_DoesNothing_WhenANullUrlIsSpecified() { branding.setLogo(null); assertNull(branding.getLogo()); } @Test - public void test_setLogo_DoesNothing_WhenAnEmptyUrlIsSpecified() { + void setLogo_DoesNothing_WhenAnEmptyUrlIsSpecified() { branding.setLogo(" "); assertNull(branding.getLogo()); } diff --git a/structurizr-core/src/test/java/com/structurizr/view/ColorTests.java b/structurizr-core/src/test/java/com/structurizr/view/ColorTests.java new file mode 100644 index 000000000..660cfd40e --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/view/ColorTests.java @@ -0,0 +1,48 @@ +package com.structurizr.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ColorTests { + + @Test + void isHexColorCode_ReturnsFalse_WhenPassedNull() { + assertFalse(Color.isHexColorCode(null)); + } + + @Test + void isHexColorCode_ReturnsFalse_WhenPassedAnEmptyString() { + assertFalse(Color.isHexColorCode("")); + } + + @Test + void isHexColorCode_ReturnsFalse_WhenPassedAnInvalidString() { + assertFalse(Color.isHexColorCode("ffffff")); + assertFalse(Color.isHexColorCode("#fffff")); + assertFalse(Color.isHexColorCode("#gggggg")); + } + + @Test + void isHexColorCode_ReturnsTrue_WhenPassedAnValidString() { + assertTrue(Color.isHexColorCode("#abcdef")); + assertTrue(Color.isHexColorCode("#ABCDEF")); + assertTrue(Color.isHexColorCode("#123456")); + } + + @Test + void fromColorNameToHexColorCode_ReturnsNull_WhenPassedAnInvalidName() { + assertNull(Color.fromColorNameToHexColorCode("hello world")); + } + + @Test + void fromColorNameToHexColorCode_ReturnsAHexColorCode_WhenPassedAValidName() { + assertEquals("#FFFF00", Color.fromColorNameToHexColorCode("yellow")); + } + + @Test + void fromColorNameToHexColorCode_ReturnsAHexColorCode_WhenPassedAValidNameInMixedCase() { + assertEquals("#FFFF00", Color.fromColorNameToHexColorCode("Yellow")); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/ComponentViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/ComponentViewTests.java similarity index 78% rename from structurizr-core/test/unit/com/structurizr/view/ComponentViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ComponentViewTests.java index 0527ad3e4..a1408b08b 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ComponentViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ComponentViewTests.java @@ -2,13 +2,13 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import java.util.Set; import java.util.stream.Collectors; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ComponentViewTests extends AbstractWorkspaceTestBase { @@ -16,16 +16,16 @@ public class ComponentViewTests extends AbstractWorkspaceTestBase { private Container webApplication; private ComponentView view; - @Before + @BeforeEach public void setUp() { - softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + softwareSystem = model.addSoftwareSystem("The System", "Description"); webApplication = softwareSystem.addContainer("Web Application", "Does something", "Apache Tomcat"); view = new ComponentView(webApplication, "Key", "Some description"); } @Test - public void test_construction() { - assertEquals("The System - Web Application - Components", view.getName()); + void construction() { + assertEquals("Component View: The System - Web Application", view.getName()); assertEquals("Some description", view.getDescription()); assertEquals(0, view.getElements().size()); assertSame(softwareSystem, view.getSoftwareSystem()); @@ -35,16 +35,16 @@ public void test_construction() { } @Test - public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { + void addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { assertEquals(0, view.getElements().size()); view.addAllSoftwareSystems(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); + void addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); view.addAllSoftwareSystems(); @@ -54,16 +54,16 @@ public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSo } @Test - public void test_addAllPeople_DoesNothing_WhenThereAreNoPeople() { + void addAllPeople_DoesNothing_WhenThereAreNoPeople() { assertEquals(0, view.getElements().size()); view.addAllPeople(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { + Person userA = model.addPerson("User A", "Description"); + Person userB = model.addPerson("User B", "Description"); view.addAllPeople(); @@ -73,18 +73,18 @@ public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { } @Test - public void test_addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { + void addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { assertEquals(0, view.getElements().size()); view.addAllElements(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainersAndComponents_WhenThereAreSomeSoftwareSystemsAndPeopleAndContainersAndComponentsInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainersAndComponents_WhenThereAreSomeSoftwareSystemsAndPeopleAndContainersAndComponentsInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); + Person userA = model.addPerson("User A", "Description"); + Person userB = model.addPerson("User B", "Description"); Container database = softwareSystem.addContainer("Database", "Does something", "MySQL"); Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); Component componentB = webApplication.addComponent("Component B", "Does something", "Java"); @@ -102,14 +102,14 @@ public void test_addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainersAndC } @Test - public void test_addAllContainers_DoesNothing_WhenThereAreNoContainers() { + void addAllContainers_DoesNothing_WhenThereAreNoContainers() { assertEquals(0, view.getElements().size()); view.addAllContainers(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() { + void addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() { Container database = softwareSystem.addContainer("Database", "Stores something", "MySQL"); Container fileSystem = softwareSystem.addContainer("File System", "Stores something else", ""); @@ -121,14 +121,14 @@ public void test_addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() } @Test - public void test_addAllComponents_DoesNothing_WhenThereAreNoComponents() { + void addAllComponents_DoesNothing_WhenThereAreNoComponents() { assertEquals(0, view.getElements().size()); view.addAllComponents(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllComponents_AddsAllComponents_WhenThereAreSomeComponents() { + void addAllComponents_AddsAllComponents_WhenThereAreSomeComponents() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); Component componentB = webApplication.addComponent("Component B", "Does something", "Java"); @@ -140,7 +140,7 @@ public void test_addAllComponents_AddsAllComponents_WhenThereAreSomeComponents() } @Test - public void test_add_ThrowsAnException_WhenANullContainerIsSpecified() { + void add_ThrowsAnException_WhenANullContainerIsSpecified() { assertEquals(0, view.getElements().size()); try { @@ -152,7 +152,7 @@ public void test_add_ThrowsAnException_WhenANullContainerIsSpecified() { } @Test - public void test_add_AddsTheContainer_WhenTheContainerIsNoInTheViewAlready() { + void add_AddsTheContainer_WhenTheContainerIsNoInTheViewAlready() { Container database = softwareSystem.addContainer("Database", "Stores something", "MySQL"); assertEquals(0, view.getElements().size()); @@ -162,7 +162,7 @@ public void test_add_AddsTheContainer_WhenTheContainerIsNoInTheViewAlready() { } @Test - public void test_add_DoesNothing_WhenTheSpecifiedContainerIsAlreadyInTheView() { + void add_DoesNothing_WhenTheSpecifiedContainerIsAlreadyInTheView() { Container database = softwareSystem.addContainer("Database", "Stores something", "MySQL"); view.add(database); assertEquals(1, view.getElements().size()); @@ -173,9 +173,9 @@ public void test_add_DoesNothing_WhenTheSpecifiedContainerIsAlreadyInTheView() { } @Test - public void test_remove_ThrowsAndException_WhenANullContainerIsPassed() { + void remove_ThrowsAndException_WhenANullContainerIsPassed() { try { - view.remove((Container)null); + view.remove((Container) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -183,7 +183,7 @@ public void test_remove_ThrowsAndException_WhenANullContainerIsPassed() { } @Test - public void test_remove_RemovesTheContainer_WhenTheContainerIsInTheView() { + void remove_RemovesTheContainer_WhenTheContainerIsInTheView() { Container database = softwareSystem.addContainer("Database", "Stores something", "MySQL"); view.add(database); assertEquals(1, view.getElements().size()); @@ -194,7 +194,7 @@ public void test_remove_RemovesTheContainer_WhenTheContainerIsInTheView() { } @Test - public void test_remove_DoesNothing_WhenTheContainerIsNotInTheView() { + void remove_DoesNothing_WhenTheContainerIsNotInTheView() { Container database = softwareSystem.addContainer("Database", "Stores something", "MySQL"); Container fileSystem = softwareSystem.addContainer("File System", "Stores something else", ""); @@ -208,14 +208,14 @@ public void test_remove_DoesNothing_WhenTheContainerIsNotInTheView() { } @Test - public void test_add_DoesNothing_WhenANullComponentIsSpecified() { + void add_DoesNothing_WhenANullComponentIsSpecified() { assertEquals(0, view.getElements().size()); view.add((Component) null); assertEquals(0, view.getElements().size()); } @Test - public void test_add_AddsTheComponent_WhenTheComponentIsNotInTheViewAlready() { + void add_AddsTheComponent_WhenTheComponentIsNotInTheViewAlready() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); assertEquals(0, view.getElements().size()); @@ -225,7 +225,7 @@ public void test_add_AddsTheComponent_WhenTheComponentIsNotInTheViewAlready() { } @Test - public void test_add_DoesNothing_WhenTheSpecifiedComponentIsAlreadyInTheView() { + void add_DoesNothing_WhenTheSpecifiedComponentIsAlreadyInTheView() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); view.add(componentA); assertEquals(1, view.getElements().size()); @@ -236,7 +236,7 @@ public void test_add_DoesNothing_WhenTheSpecifiedComponentIsAlreadyInTheView() { } @Test - public void test_add_ThrowsAnException_WhenTheSpecifiedComponentIsInADifferentContainer() { + void add_ThrowsAnException_WhenTheSpecifiedComponentIsInADifferentContainer() { try { SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); @@ -253,7 +253,7 @@ public void test_add_ThrowsAnException_WhenTheSpecifiedComponentIsInADifferentCo } @Test - public void test_add_ThrowsAnException_WhenTheContainerOfTheViewIsAdded() { + void add_ThrowsAnException_WhenTheContainerOfTheViewIsAdded() { try { view.add(webApplication); fail(); @@ -263,20 +263,20 @@ public void test_add_ThrowsAnException_WhenTheContainerOfTheViewIsAdded() { } @Test - public void test_add_DoesNothing_WhenTheContainerOfTheViewIsAddedViaDependency() { - final SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "Some other system", "external system that uses our web application"); + void add_DoesNothing_WhenTheContainerOfTheViewIsAddedViaDependency() { + final SoftwareSystem softwareSystem = model.addSoftwareSystem("Some other system", "external system that uses our web application"); final Relationship relationshipFromExternalSystem = softwareSystem.uses(webApplication, ""); - assertEquals("the container itself is not added to the view", 0, view.getElements().stream().map(e -> e.getElement()).filter(e -> e.equals(webApplication)).count()); + assertEquals(0, view.getElements().stream().map(e -> e.getElement()).filter(e -> e.equals(webApplication)).count(), "the container itself is not added to the view"); view.add(relationshipFromExternalSystem); - assertEquals("the container itself is not added to the view", 0, view.getElements().stream().map(e -> e.getElement()).filter(e -> e.equals(webApplication)).count()); + assertEquals(0, view.getElements().stream().map(e -> e.getElement()).filter(e -> e.equals(webApplication)).count(), "the container itself is not added to the view"); } @Test - public void test_remove_DoesNothing_WhenANullComponentIsPassed() { + void remove_DoesNothing_WhenANullComponentIsPassed() { try { - view.remove((Component)null); + view.remove((Component) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -284,7 +284,7 @@ public void test_remove_DoesNothing_WhenANullComponentIsPassed() { } @Test - public void test_remove_RemovesTheComponent_WhenTheComponentIsInTheView() { + void remove_RemovesTheComponent_WhenTheComponentIsInTheView() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); view.add(componentA); assertEquals(1, view.getElements().size()); @@ -295,7 +295,7 @@ public void test_remove_RemovesTheComponent_WhenTheComponentIsInTheView() { } @Test - public void test_remove_RemovesTheComponentAndRelationships_WhenTheComponentIsInTheViewAndHasArelationshipToAnotherElement() { + void remove_RemovesTheComponentAndRelationships_WhenTheComponentIsInTheViewAndHasArelationshipToAnotherElement() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); Component componentB = webApplication.addComponent("Component B", "Does something", "Java"); componentA.uses(componentB, "uses"); @@ -311,7 +311,7 @@ public void test_remove_RemovesTheComponentAndRelationships_WhenTheComponentIsIn } @Test - public void test_remove_DoesNothing_WhenTheComponentIsNotInTheView() { + void remove_DoesNothing_WhenTheComponentIsNotInTheView() { Component componentA = webApplication.addComponent("Component A", "Does something", "Java"); Component componentB = webApplication.addComponent("Component B", "Does something", "Java"); @@ -325,14 +325,14 @@ public void test_remove_DoesNothing_WhenTheComponentIsNotInTheView() { } @Test - public void test_addNearestNeightbours_DoesNothing_WhenANullElementIsSpecified() { + void addNearestNeightbours_DoesNothing_WhenANullElementIsSpecified() { view.addNearestNeighbours(null); assertEquals(0, view.getElements().size()); } @Test - public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { + void addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { Component component = webApplication.addComponent("Component", "", ""); view.add(component); assertEquals(1, view.getElements().size()); @@ -342,7 +342,7 @@ public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { } @Test - public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { + void addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); Person userA = model.addPerson("User A", "Description"); @@ -400,7 +400,7 @@ public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNear } @Test - public void test_addExternalDependencies_AddsOrphanedElements_WhenThereAreNoDirectRelationshipsWithAComponent() { + void addExternalDependencies_AddsOrphanedElements_WhenThereAreNoDirectRelationshipsWithAComponent() { SoftwareSystem source = model.addSoftwareSystem("Source", ""); SoftwareSystem destination = model.addSoftwareSystem("Destination", ""); @@ -424,7 +424,7 @@ public void test_addExternalDependencies_AddsOrphanedElements_WhenThereAreNoDire } @Test - public void test_addExternalDependencies_AddsTheContainer_WhenAComponentHasARelationshipToAContainerInTheSameSoftwareSystem() { + void addExternalDependencies_AddsTheContainer_WhenAComponentHasARelationshipToAContainerInTheSameSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -441,7 +441,7 @@ public void test_addExternalDependencies_AddsTheContainer_WhenAComponentHasARela } @Test - public void test_addExternalDependencies_AddsTheContainer_WhenAComponentHasARelationshipFromAContainerInTheSameSoftwareSystem() { + void addExternalDependencies_AddsTheContainer_WhenAComponentHasARelationshipFromAContainerInTheSameSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -458,7 +458,7 @@ public void test_addExternalDependencies_AddsTheContainer_WhenAComponentHasARela } @Test - public void test_addExternalDependencies_AddsTheParentContainer_WhenAComponentHasARelationshipToAComponentInADifferentContainerInTheSameSoftwareSystem() { + void addExternalDependencies_AddsTheParentContainer_WhenAComponentHasARelationshipToAComponentInADifferentContainerInTheSameSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -476,7 +476,7 @@ public void test_addExternalDependencies_AddsTheParentContainer_WhenAComponentHa } @Test - public void test_addExternalDependencies_AddsTheParentContainer_WhenAComponentHasARelationshipFromAComponentInADifferentContainerInTheSameSoftwareSystem() { + void addExternalDependencies_AddsTheParentContainer_WhenAComponentHasARelationshipFromAComponentInADifferentContainerInTheSameSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -494,7 +494,7 @@ public void test_addExternalDependencies_AddsTheParentContainer_WhenAComponentHa } @Test - public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipToAContainerInAnotherSoftwareSystem() { + void addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipToAContainerInAnotherSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -512,7 +512,7 @@ public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenACompon } @Test - public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipFromAContainerInAnotherSoftwareSystem() { + void addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipFromAContainerInAnotherSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -530,7 +530,7 @@ public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenACompon } @Test - public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipToAComponentInAnotherSoftwareSystem() { + void addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipToAComponentInAnotherSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -549,7 +549,7 @@ public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenACompon } @Test - public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipFromAComponentInAnotherSoftwareSystem() { + void addExternalDependencies_AddsTheParentSoftwareSystem_WhenAComponentHasARelationshipFromAComponentInAnotherSoftwareSystem() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A", ""); Container containerA = softwareSystemA.addContainer("Container A", "", ""); Component componentA = containerA.addComponent("Component A", "", ""); @@ -568,7 +568,7 @@ public void test_addExternalDependencies_AddsTheParentSoftwareSystem_WhenACompon } @Test - public void test_addDefaultElements() { + void addDefaultElements() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); CustomElement element = model.addCustomElement("Custom"); @@ -615,7 +615,71 @@ public void test_addDefaultElements() { } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheScopeOfTheView() { + void addDefaultElements_WhenGreedyIsTrue() { + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + SoftwareSystem a = model.addSoftwareSystem("A"); + Container a1 = a.addContainer("A1", "", ""); + Component aa1 = a1.addComponent("AA1", "", ""); + Component aa2 = a1.addComponent("AA2", "", ""); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + aa1.uses(aa2, "Uses"); + aa1.uses(b, "Uses"); + aa2.uses(c, "Uses"); + b.uses(c, "Uses"); + + view = new ComponentView(a1, "components", "Description"); + view.addDefaultElements(true); + + assertEquals(4, view.getElements().size()); + assertTrue(view.getElements().contains(new ElementView(aa1))); + assertTrue(view.getElements().contains(new ElementView(aa2))); + assertTrue(view.getElements().contains(new ElementView(b))); + assertTrue(view.getElements().contains(new ElementView(c))); + + assertEquals(4, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(aa1.getEfferentRelationshipWith(aa2))); + assertNotNull(view.getRelationshipView(aa1.getEfferentRelationshipWith(b))); + assertNotNull(view.getRelationshipView(aa2.getEfferentRelationshipWith(c))); + assertNotNull(view.getRelationshipView(b.getEfferentRelationshipWith(c))); + } + + @Test + void addDefaultElements_WhenGreedyIsFalse() { + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + SoftwareSystem a = model.addSoftwareSystem("A"); + Container a1 = a.addContainer("A1", "", ""); + Component aa1 = a1.addComponent("AA1", "", ""); + Component aa2 = a1.addComponent("AA2", "", ""); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + aa1.uses(aa2, "Uses"); + aa1.uses(b, "Uses"); + aa2.uses(c, "Uses"); + b.uses(c, "Uses"); + + view = new ComponentView(a1, "components", "Description"); + view.addDefaultElements(false); + + assertEquals(4, view.getElements().size()); + assertTrue(view.getElements().contains(new ElementView(aa1))); + assertTrue(view.getElements().contains(new ElementView(aa2))); + assertTrue(view.getElements().contains(new ElementView(b))); + assertTrue(view.getElements().contains(new ElementView(c))); + + assertEquals(3, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(aa1.getEfferentRelationshipWith(aa2))); + assertNotNull(view.getRelationshipView(aa1.getEfferentRelationshipWith(b))); + assertNotNull(view.getRelationshipView(aa2.getEfferentRelationshipWith(c))); + assertNull(view.getRelationshipView(b.getEfferentRelationshipWith(c))); + } + + @Test + void addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheScopeOfTheView() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); @@ -629,7 +693,7 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheS } @Test - public void test_addContainer_ThrowsAnException_WhenTheContainerIsTheScopeOfTheView() { + void addContainer_ThrowsAnException_WhenTheContainerIsTheScopeOfTheView() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); @@ -643,7 +707,7 @@ public void test_addContainer_ThrowsAnException_WhenTheContainerIsTheScopeOfTheV } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlreadyAdded() { + void addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); @@ -665,7 +729,7 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlread } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenAChildComponentIsAlreadyAdded() { + void addSoftwareSystem_ThrowsAnException_WhenAChildComponentIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); @@ -687,7 +751,7 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenAChildComponentIsAlread } @Test - public void test_addContainer_ThrowsAnException_WhenAChildComponentIsAlreadyAdded() { + void addContainer_ThrowsAnException_WhenAChildComponentIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); @@ -709,7 +773,7 @@ public void test_addContainer_ThrowsAnException_WhenAChildComponentIsAlreadyAdde } @Test - public void test_addContainer_ThrowsAnException_WhenTheParentIsAlreadyAdded() { + void addContainer_ThrowsAnException_WhenTheParentIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); @@ -731,7 +795,7 @@ public void test_addContainer_ThrowsAnException_WhenTheParentIsAlreadyAdded() { } @Test - public void test_addComponent_ThrowsAnException_WhenTheParentIsAlreadyAdded() { + void addComponent_ThrowsAnException_WhenTheParentIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); diff --git a/structurizr-core/test/unit/com/structurizr/view/ConfigurationTests.java b/structurizr-core/src/test/java/com/structurizr/view/ConfigurationTests.java similarity index 61% rename from structurizr-core/test/unit/com/structurizr/view/ConfigurationTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ConfigurationTests.java index c7e0ac2aa..5a15e24dc 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ConfigurationTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ConfigurationTests.java @@ -1,22 +1,21 @@ package com.structurizr.view; import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.*; public class ConfigurationTests extends AbstractWorkspaceTestBase { @Test - public void test_defaultView_DoesNothing_WhenPassedNull() { + void defaultView_DoesNothing_WhenPassedNull() { Configuration configuration = new Configuration(); - configuration.setDefaultView((View)null); + configuration.setDefaultView((View) null); assertNull(configuration.getDefaultView()); } @Test - public void test_defaultView() { + void defaultView() { SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); Configuration configuration = new Configuration(); configuration.setDefaultView(view); @@ -24,7 +23,7 @@ public void test_defaultView() { } @Test - public void test_copyConfigurationFrom() { + void copyConfigurationFrom() { Configuration source = new Configuration(); source.setLastSavedView("someKey"); @@ -34,37 +33,39 @@ public void test_copyConfigurationFrom() { } @Test - public void test_setTheme_WithAUrl() { + void setTheme_WithAUrl() { Configuration configuration = new Configuration(); configuration.setTheme("https://example.com/theme.json"); - assertEquals("https://example.com/theme.json", configuration.getTheme()); + assertEquals("https://example.com/theme.json", configuration.getThemes()[0]); } @Test - public void test_setTheme_WithAUrlThatHasATrailingSpace() { + void setTheme_WithAUrlThatHasATrailingSpace() { Configuration configuration = new Configuration(); configuration.setTheme("https://example.com/theme.json "); - assertEquals("https://example.com/theme.json", configuration.getTheme()); + assertEquals("https://example.com/theme.json", configuration.getThemes()[0]); } - @Test(expected = IllegalArgumentException.class) - public void test_setTheme_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - Configuration configuration = new Configuration(); - configuration.setTheme("htt://blah"); + @Test + void setTheme_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + Configuration configuration = new Configuration(); + configuration.setTheme("htt://blah"); + }); } @Test - public void test_setTheme_DoesNothing_WhenANullUrlIsSpecified() { + void setTheme_DoesNothing_WhenANullUrlIsSpecified() { Configuration configuration = new Configuration(); configuration.setTheme(null); - assertNull(configuration.getTheme()); + assertEquals(0, configuration.getThemes().length); } @Test - public void test_setTheme_DoesNothing_WhenAnEmptyUrlIsSpecified() { + void setTheme_DoesNothing_WhenAnEmptyUrlIsSpecified() { Configuration configuration = new Configuration(); configuration.setTheme(" "); - assertNull(configuration.getTheme()); + assertEquals(0, configuration.getThemes().length); } } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/ContainerViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/ContainerViewTests.java similarity index 73% rename from structurizr-core/test/unit/com/structurizr/view/ContainerViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ContainerViewTests.java index 8f41c6886..7d877de13 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ContainerViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ContainerViewTests.java @@ -1,30 +1,26 @@ package com.structurizr.view; import com.structurizr.AbstractWorkspaceTestBase; -import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ContainerViewTests extends AbstractWorkspaceTestBase { private SoftwareSystem softwareSystem; private ContainerView view; - @Before + @BeforeEach public void setUp() { - softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + softwareSystem = model.addSoftwareSystem("The System", "Description"); view = new ContainerView(softwareSystem, "containers", "Description"); } @Test - public void test_construction() { - assertEquals("The System - Containers", view.getName()); + void construction() { + assertEquals("Container View: The System", view.getName()); assertEquals("Description", view.getDescription()); assertEquals(0, view.getElements().size()); assertSame(softwareSystem, view.getSoftwareSystem()); @@ -33,16 +29,16 @@ public void test_construction() { } @Test - public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { + void addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { assertEquals(0, view.getElements().size()); view.addAllSoftwareSystems(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); + void addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); view.addAllSoftwareSystems(); @@ -52,16 +48,16 @@ public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSo } @Test - public void test_addAllPeople_DoesNothing_WhenThereAreNoPeople() { + void addAllPeople_DoesNothing_WhenThereAreNoPeople() { assertEquals(0, view.getElements().size()); view.addAllPeople(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { + Person userA = model.addPerson("User A", "Description"); + Person userB = model.addPerson("User B", "Description"); view.addAllPeople(); @@ -71,18 +67,18 @@ public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { } @Test - public void test_addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { + void addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { assertEquals(0, view.getElements().size()); view.addAllElements(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainers_WhenThereAreSomeSoftwareSystemsAndPeopleAndContainersInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainers_WhenThereAreSomeSoftwareSystemsAndPeopleAndContainersInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); + Person userA = model.addPerson("User A", "Description"); + Person userB = model.addPerson("User B", "Description"); Container webApplication = softwareSystem.addContainer("Web Application", "Does something", "Apache Tomcat"); Container database = softwareSystem.addContainer("Database", "Does something", "MySQL"); @@ -98,14 +94,14 @@ public void test_addAllElements_AddsAllSoftwareSystemsAndPeopleAndContainers_Whe } @Test - public void test_addAllContainers_DoesNothing_WhenThereAreNoContainers() { + void addAllContainers_DoesNothing_WhenThereAreNoContainers() { assertEquals(0, view.getElements().size()); view.addAllContainers(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() { + void addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() { Container webApplication = softwareSystem.addContainer("Web Application", "Does something", "Apache Tomcat"); Container database = softwareSystem.addContainer("Database", "Does something", "MySQL"); @@ -117,21 +113,21 @@ public void test_addAllContainers_AddsAllContainers_WhenThereAreSomeContainers() } @Test - public void test_addNearestNeightbours_DoesNothing_WhenANullElementIsSpecified() { + void addNearestNeightbours_DoesNothing_WhenANullElementIsSpecified() { view.addNearestNeighbours(null); assertEquals(0, view.getElements().size()); } @Test - public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { + void addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { view.addNearestNeighbours(softwareSystem); assertEquals(0, view.getElements().size()); } @Test - public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { + void addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); Person userA = model.addPerson("User A", "Description"); @@ -190,7 +186,7 @@ public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNear } @Test - public void test_remove_RemovesContainer() { + void remove_RemovesContainer() { Container webApplication = softwareSystem.addContainer("Web Application", "", ""); Container database = softwareSystem.addContainer("Database", "", ""); @@ -203,7 +199,7 @@ public void test_remove_RemovesContainer() { } @Test - public void test_remove_ElementsWithTag() { + void remove_ElementsWithTag() { final String TAG = "myTag"; Container webApplication = softwareSystem.addContainer("Web Application", "", ""); Container database = softwareSystem.addContainer("Database", "", ""); @@ -218,7 +214,7 @@ public void test_remove_ElementsWithTag() { } @Test - public void test_remove_RelationshipWithTag() { + void remove_RelationshipWithTag() { final String TAG = "myTag"; Container webApplication = softwareSystem.addContainer("Web Application", "", ""); Container database = softwareSystem.addContainer("Database", "", ""); @@ -234,13 +230,13 @@ public void test_remove_RelationshipWithTag() { } @Test - public void test_addDependentSoftwareSystem() { + void addDependentSoftwareSystem() { assertEquals(0, view.getElements().size()); assertEquals(0, view.getRelationships().size()); view.addDependentSoftwareSystems(); - SoftwareSystem softwareSystem2 = model.addSoftwareSystem(Location.External, "SoftwareSystem 2", ""); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("SoftwareSystem 2", ""); view.addDependentSoftwareSystems(); assertEquals(0, view.getElements().size()); @@ -253,10 +249,10 @@ public void test_addDependentSoftwareSystem() { } @Test - public void test_addDependentSoftwareSystem2() { + void addDependentSoftwareSystem2() { Container container1a = softwareSystem.addContainer("Container 1A", "", ""); - SoftwareSystem softwareSystem2 = model.addSoftwareSystem(Location.External, "SoftwareSystem 2", ""); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("SoftwareSystem 2", ""); Container container2a = softwareSystem2.addContainer("Container 2-A", "", ""); model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); @@ -270,7 +266,7 @@ public void test_addDependentSoftwareSystem2() { } @Test - public void test_addDefaultElements() { + void addDefaultElements() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); CustomElement element = model.addCustomElement("Custom"); @@ -311,7 +307,69 @@ public void test_addDefaultElements() { } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheScopeOfTheView() { + void addDefaultElements_WhenGreedyIsTrue() { + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + SoftwareSystem a = model.addSoftwareSystem("A"); + Container a1 = a.addContainer("A1"); + Container a2 = a.addContainer("A2"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + a1.uses(a2, "Uses"); + a1.uses(b, "Uses"); + a2.uses(c, "Uses"); + b.uses(c, "Uses"); + + view = new ContainerView(a, "containers", "Description"); + view.addDefaultElements(true); + + assertEquals(4, view.getElements().size()); + assertTrue(view.getElements().contains(new ElementView(a1))); + assertTrue(view.getElements().contains(new ElementView(a2))); + assertTrue(view.getElements().contains(new ElementView(b))); + assertTrue(view.getElements().contains(new ElementView(c))); + + assertEquals(4, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(a1.getEfferentRelationshipWith(a2))); + assertNotNull(view.getRelationshipView(a1.getEfferentRelationshipWith(b))); + assertNotNull(view.getRelationshipView(a2.getEfferentRelationshipWith(c))); + assertNotNull(view.getRelationshipView(b.getEfferentRelationshipWith(c))); + } + + @Test + void addDefaultElements_WhenGreedyIsFalse() { + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + SoftwareSystem a = model.addSoftwareSystem("A"); + Container a1 = a.addContainer("A1"); + Container a2 = a.addContainer("A2"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + a1.uses(a2, "Uses"); + a1.uses(b, "Uses"); + a2.uses(c, "Uses"); + b.uses(c, "Uses"); + + view = new ContainerView(a, "containers", "Description"); + view.addDefaultElements(false); + + assertEquals(4, view.getElements().size()); + assertTrue(view.getElements().contains(new ElementView(a1))); + assertTrue(view.getElements().contains(new ElementView(a2))); + assertTrue(view.getElements().contains(new ElementView(b))); + assertTrue(view.getElements().contains(new ElementView(c))); + + assertEquals(3, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(a1.getEfferentRelationshipWith(a2))); + assertNotNull(view.getRelationshipView(a1.getEfferentRelationshipWith(b))); + assertNotNull(view.getRelationshipView(a2.getEfferentRelationshipWith(c))); + assertNull(view.getRelationshipView(b.getEfferentRelationshipWith(c))); + } + + @Test + void addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheScopeOfTheView() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); view = new ContainerView(softwareSystem, "containers", "Description"); @@ -324,7 +382,7 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenTheSoftwareSystemIsTheS } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlreadyAdded() { + void addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); @@ -345,7 +403,7 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenAChildContainerIsAlread } @Test - public void test_addContainer_ThrowsAnException_WhenTheParentIsAlreadyAdded() { + void addContainer_ThrowsAnException_WhenTheParentIsAlreadyAdded() { try { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); Container container1 = softwareSystem1.addContainer("Container 1"); diff --git a/structurizr-core/test/unit/com/structurizr/view/DefaultLayoutMergeStrategyTests.java b/structurizr-core/src/test/java/com/structurizr/view/DefaultLayoutMergeStrategyTests.java similarity index 56% rename from structurizr-core/test/unit/com/structurizr/view/DefaultLayoutMergeStrategyTests.java rename to structurizr-core/src/test/java/com/structurizr/view/DefaultLayoutMergeStrategyTests.java index e09e4462d..fdb582f80 100644 --- a/structurizr-core/test/unit/com/structurizr/view/DefaultLayoutMergeStrategyTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/DefaultLayoutMergeStrategyTests.java @@ -2,15 +2,18 @@ import com.structurizr.Workspace; import com.structurizr.model.Container; +import com.structurizr.model.Relationship; import com.structurizr.model.SoftwareSystem; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; public class DefaultLayoutMergeStrategyTests { @Test - public void test_copyLayoutInformation_WhenCanonicalNamesHaveNotChanged() { + void copyLayoutInformation_WhenCanonicalNamesHaveNotChanged() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container", "", ""); @@ -33,7 +36,7 @@ public void test_copyLayoutInformation_WhenCanonicalNamesHaveNotChanged() { } @Test - public void test_copyLayoutInformation_WhenAParentElementNameHasChanged() { + void copyLayoutInformation_WhenAParentElementNameHasChanged() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container", "", ""); @@ -56,7 +59,7 @@ public void test_copyLayoutInformation_WhenAParentElementNameHasChanged() { } @Test - public void test_copyLayoutInformation_WhenAnElementNameHasChangedButTheDescriptionHasNotChanged() { + void copyLayoutInformation_WhenAnElementNameHasChangedButTheDescriptionHasNotChanged() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container", "Container description", ""); @@ -79,7 +82,7 @@ public void test_copyLayoutInformation_WhenAnElementNameHasChangedButTheDescript } @Test - public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionHaveChangedButTheIdHasNotChanged() { + void copyLayoutInformation_WhenAnElementNameAndDescriptionHaveChangedButTheIdHasNotChanged() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container", "Container description", ""); @@ -102,7 +105,7 @@ public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionHaveChange } @Test - public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveChanged() { + void copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveChanged() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container", "Container description", ""); @@ -126,7 +129,7 @@ public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveC } @Test - public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveChangedAndDescriptionWasNull() { + void copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveChangedAndDescriptionWasNull() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1 = workspace1.getModel().addSoftwareSystem("Software System"); Container container1 = softwareSystem1.addContainer("Container"); @@ -151,7 +154,7 @@ public void test_copyLayoutInformation_WhenAnElementNameAndDescriptionAndIdHaveC } @Test - public void test_copyLayoutInformation_DoesNotThrowAnExceptionWhenAddingAnElementToAView() { + void copyLayoutInformation_DoesNotThrowAnExceptionWhenAddingAnElementToAView() { Workspace workspace1 = new Workspace("1", ""); SoftwareSystem softwareSystem1A = workspace1.getModel().addSoftwareSystem("Software System A"); SoftwareSystem softwareSystem1B = workspace1.getModel().addSoftwareSystem("Software System B"); @@ -171,4 +174,104 @@ public void test_copyLayoutInformation_DoesNotThrowAnExceptionWhenAddingAnElemen strategy.copyLayoutInformation(view1, view2); } + @Test + void copyLayoutInformation_FromStaticViewWhenRelationshipDescriptionHasNotChanged() { + Workspace workspace1 = new Workspace("1", ""); + SoftwareSystem a1 = workspace1.getModel().addSoftwareSystem("A"); + SoftwareSystem b1 = workspace1.getModel().addSoftwareSystem("B"); + Relationship relationship1 = a1.uses(b1, "Uses"); + SystemLandscapeView view1 = workspace1.getViews().createSystemLandscapeView("Key", "Description"); + view1.addAllElements(); + view1.getRelationshipView(relationship1).setVertices(Collections.singletonList(new Vertex(123, 456))); + + Workspace workspace2 = new Workspace("2", ""); + SoftwareSystem a2 = workspace2.getModel().addSoftwareSystem("A"); + SoftwareSystem b2 = workspace2.getModel().addSoftwareSystem("B"); + Relationship relationship2 = a2.uses(b2, "Uses"); + SystemLandscapeView view2 = workspace2.getViews().createSystemLandscapeView("Key", "Description"); + view2.addAllElements(); + + DefaultLayoutMergeStrategy strategy = new DefaultLayoutMergeStrategy(); + strategy.copyLayoutInformation(view1, view2); + + assertEquals(1, view2.getRelationshipView(relationship2).getVertices().size()); + assertEquals(123, view2.getRelationshipView(relationship2).getVertices().iterator().next().getX()); + assertEquals(456, view2.getRelationshipView(relationship2).getVertices().iterator().next().getY()); + } + + @Test + void copyLayoutInformation_FromStaticViewWhenRelationshipDescriptionHasChanged() { + Workspace workspace1 = new Workspace("1", ""); + SoftwareSystem a1 = workspace1.getModel().addSoftwareSystem("A"); + SoftwareSystem b1 = workspace1.getModel().addSoftwareSystem("B"); + Relationship relationship1 = a1.uses(b1, "Uses"); + SystemLandscapeView view1 = workspace1.getViews().createSystemLandscapeView("Key", "Description"); + view1.addAllElements(); + view1.getRelationshipView(relationship1).setVertices(Collections.singletonList(new Vertex(123, 456))); + + Workspace workspace2 = new Workspace("2", ""); + SoftwareSystem a2 = workspace2.getModel().addSoftwareSystem("A"); + SoftwareSystem b2 = workspace2.getModel().addSoftwareSystem("B"); + Relationship relationship2 = a2.uses(b2, "Reads from and writes to"); + SystemLandscapeView view2 = workspace2.getViews().createSystemLandscapeView("Key", "Description"); + view2.addAllElements(); + + DefaultLayoutMergeStrategy strategy = new DefaultLayoutMergeStrategy(); + strategy.copyLayoutInformation(view1, view2); + + assertEquals(1, view2.getRelationshipView(relationship2).getVertices().size()); + assertEquals(123, view2.getRelationshipView(relationship2).getVertices().iterator().next().getX()); + assertEquals(456, view2.getRelationshipView(relationship2).getVertices().iterator().next().getY()); + } + + @Test + void copyLayoutInformation_FromDynamicViewWhenRelationshipDescriptionHasNotChanged() { + Workspace workspace1 = new Workspace("1", ""); + SoftwareSystem a1 = workspace1.getModel().addSoftwareSystem("A"); + SoftwareSystem b1 = workspace1.getModel().addSoftwareSystem("B"); + Relationship relationship1 = a1.uses(b1, "Uses"); + DynamicView view1 = workspace1.getViews().createDynamicView("Key", "Description"); + RelationshipView rv1 = view1.add(a1, b1); + rv1.setVertices(Collections.singletonList(new Vertex(123, 456))); + + Workspace workspace2 = new Workspace("2", ""); + SoftwareSystem a2 = workspace2.getModel().addSoftwareSystem("A"); + SoftwareSystem b2 = workspace2.getModel().addSoftwareSystem("B"); + Relationship relationship2 = a2.uses(b2, "Uses"); + DynamicView view2 = workspace2.getViews().createDynamicView("Key", "Description"); + RelationshipView rv2 = view2.add(a2, b2); + + DefaultLayoutMergeStrategy strategy = new DefaultLayoutMergeStrategy(); + strategy.copyLayoutInformation(view1, view2); + + assertEquals(1, view2.getRelationshipView(relationship2).getVertices().size()); + assertEquals(123, view2.getRelationshipView(relationship2).getVertices().iterator().next().getX()); + assertEquals(456, view2.getRelationshipView(relationship2).getVertices().iterator().next().getY()); + } + + @Test + void copyLayoutInformation_FromDynamicViewWhenRelationshipDescriptionHasChanged() { + Workspace workspace1 = new Workspace("1", ""); + SoftwareSystem a1 = workspace1.getModel().addSoftwareSystem("A"); + SoftwareSystem b1 = workspace1.getModel().addSoftwareSystem("B"); + Relationship relationship1 = a1.uses(b1, "Uses"); + DynamicView view1 = workspace1.getViews().createDynamicView("Key", "Description"); + RelationshipView rv1 = view1.add(a1, b1); + rv1.setVertices(Collections.singletonList(new Vertex(123, 456))); + + Workspace workspace2 = new Workspace("2", ""); + SoftwareSystem a2 = workspace2.getModel().addSoftwareSystem("A"); + SoftwareSystem b2 = workspace2.getModel().addSoftwareSystem("B"); + Relationship relationship2 = a2.uses(b2, "Uses"); + DynamicView view2 = workspace2.getViews().createDynamicView("Key", "Description"); + RelationshipView rv2 = view2.add(a2, "Reads from and writes to", b2); + + DefaultLayoutMergeStrategy strategy = new DefaultLayoutMergeStrategy(); + strategy.copyLayoutInformation(view1, view2); + + assertEquals(1, view2.getRelationshipView(relationship2).getVertices().size()); + assertEquals(123, view2.getRelationshipView(relationship2).getVertices().iterator().next().getX()); + assertEquals(456, view2.getRelationshipView(relationship2).getVertices().iterator().next().getY()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/DeploymentViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/DeploymentViewTests.java similarity index 87% rename from structurizr-core/test/unit/com/structurizr/view/DeploymentViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/DeploymentViewTests.java index 7d35e9220..56b0a0038 100644 --- a/structurizr-core/test/unit/com/structurizr/view/DeploymentViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/DeploymentViewTests.java @@ -2,52 +2,52 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class DeploymentViewTests extends AbstractWorkspaceTestBase { private DeploymentView deploymentView; - @Before + @BeforeEach public void setup() { } @Test - public void test_getName_WithNoSoftwareSystemAndNoEnvironment() { + void getName_WithNoSoftwareSystemAndNoEnvironment() { deploymentView = views.createDeploymentView("deployment", "Description"); - assertEquals("Deployment - Default", deploymentView.getName()); + assertEquals("Deployment View: Default", deploymentView.getName()); } @Test - public void test_getName_WithNoSoftwareSystemAndAnEnvironment() { + void getName_WithNoSoftwareSystemAndAnEnvironment() { deploymentView = views.createDeploymentView("deployment", "Description"); deploymentView.setEnvironment("Live"); - assertEquals("Deployment - Live", deploymentView.getName()); + assertEquals("Deployment View: Live", deploymentView.getName()); } @Test - public void test_getName_WithASoftwareSystemAndNoEnvironment() { + void getName_WithASoftwareSystemAndNoEnvironment() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); deploymentView = views.createDeploymentView(softwareSystem, "deployment", "Description"); - assertEquals("Software System - Deployment - Default", deploymentView.getName()); + assertEquals("Deployment View: Software System - Default", deploymentView.getName()); } @Test - public void test_getName_WithASoftwareSystemAndAnEnvironment() { + void getName_WithASoftwareSystemAndAnEnvironment() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); deploymentView = views.createDeploymentView(softwareSystem, "deployment", "Description"); deploymentView.setEnvironment("Live"); - assertEquals("Software System - Deployment - Live", deploymentView.getName()); + assertEquals("Deployment View: Software System - Live", deploymentView.getName()); } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenPassedNull() { + void addDeploymentNode_ThrowsAnException_WhenPassedNull() { try { deploymentView = views.createDeploymentView("key", "Description"); - deploymentView.add((DeploymentNode)null); + deploymentView.add((DeploymentNode) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("A deployment node must be specified.", iae.getMessage()); @@ -55,10 +55,10 @@ public void test_addDeploymentNode_ThrowsAnException_WhenPassedNull() { } @Test - public void test_addRelationship_ThrowsAnException_WhenPassedNull() { + void addRelationship_ThrowsAnException_WhenPassedNull() { try { deploymentView = views.createDeploymentView("key", "Description"); - deploymentView.add((Relationship)null); + deploymentView.add((Relationship) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("A relationship must be specified.", iae.getMessage()); @@ -66,7 +66,7 @@ public void test_addRelationship_ThrowsAnException_WhenPassedNull() { } @Test - public void test_addAllDeploymentNodes_DoesNothing_WhenThereAreNoTopLevelDeploymentNodes() { + void addAllDeploymentNodes_DoesNothing_WhenThereAreNoTopLevelDeploymentNodes() { deploymentView = views.createDeploymentView("deployment", "Description"); deploymentView.addAllDeploymentNodes(); @@ -74,7 +74,7 @@ public void test_addAllDeploymentNodes_DoesNothing_WhenThereAreNoTopLevelDeploym } @Test - public void test_addAllDeploymentNodes_DoesNothing_WhenThereAreTopLevelDeploymentNodesButNoContainerInstances() { + void addAllDeploymentNodes_DoesNothing_WhenThereAreTopLevelDeploymentNodesButNoContainerInstances() { deploymentView = views.createDeploymentView("deployment", "Description"); model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -83,7 +83,7 @@ public void test_addAllDeploymentNodes_DoesNothing_WhenThereAreTopLevelDeploymen } @Test - public void test_addAllDeploymentNodes_DoesNothing_WhenThereNoDeploymentNodesForTheDeploymentEnvironment() { + void addAllDeploymentNodes_DoesNothing_WhenThereNoDeploymentNodesForTheDeploymentEnvironment() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -96,7 +96,7 @@ public void test_addAllDeploymentNodes_DoesNothing_WhenThereNoDeploymentNodesFor } @Test - public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_WhenThereAreTopLevelDeploymentNodesWithContainerInstances() { + void addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_WhenThereAreTopLevelDeploymentNodesWithContainerInstances() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -110,7 +110,7 @@ public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_ } @Test - public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_WhenThereAreChildDeploymentNodesWithContainerInstances() { + void addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_WhenThereAreChildDeploymentNodesWithContainerInstances() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -126,7 +126,7 @@ public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstances_ } @Test - public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstancesOnlyForTheSoftwareSystemInScope() { + void addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstancesOnlyForTheSoftwareSystemInScope() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", ""); Container container1 = softwareSystem1.addContainer("Container 1", "Description", "Technology"); DeploymentNode deploymentNode1 = model.addDeploymentNode("Deployment Node 1", "Description", "Technology"); @@ -149,7 +149,7 @@ public void test_addAllDeploymentNodes_AddsDeploymentNodesAndContainerInstancesO } @Test - public void test_addDeploymentNode_AddsTheParentToo() { + void addDeploymentNode_AddsTheParentToo() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -165,7 +165,7 @@ public void test_addDeploymentNode_AddsTheParentToo() { } @Test - public void test_addDeploymentNode_ThrowsAnException_WhenAddingADeploymentNodeFromAnotherDeploymentEnvironment() { + void addDeploymentNode_ThrowsAnException_WhenAddingADeploymentNodeFromAnotherDeploymentEnvironment() { DeploymentNode devDeploymentNode = model.addDeploymentNode("Dev", "Deployment Node", "Description", "Technology"); devDeploymentNode.addInfrastructureNode("Load Balancer"); DeploymentNode liveDeploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); @@ -184,7 +184,7 @@ public void test_addDeploymentNode_ThrowsAnException_WhenAddingADeploymentNodeFr } @Test - public void test_addSoftwareSystemInstance_ThrowsAnException_WhenTheSoftwareSystemInstanceIsTheSoftwareSystemInScope() { + void addSoftwareSystemInstance_ThrowsAnException_WhenTheSoftwareSystemInstanceIsTheSoftwareSystemInScope() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node", "Description", "Technology"); SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); @@ -198,7 +198,7 @@ public void test_addSoftwareSystemInstance_ThrowsAnException_WhenTheSoftwareSyst } @Test - public void test_addSoftwareSystemInstance_DoesNotAddTheSoftwareSystemInstance_WhenAChildContainerInstanceHasAlreadyBeenAdded() { + void addSoftwareSystemInstance_DoesNotAddTheSoftwareSystemInstance_WhenAChildContainerInstanceHasAlreadyBeenAdded() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); DeploymentNode deploymentNode1 = model.addDeploymentNode("Deployment Node 1", "Description", "Technology"); @@ -217,7 +217,7 @@ public void test_addSoftwareSystemInstance_DoesNotAddTheSoftwareSystemInstance_W } @Test - public void test_addContainerInstance_DoesNotAddTheContainerInstance_WhenTheParentSoftwareSystemInstanceHasAlreadyBeenAdded() { + void addContainerInstance_DoesNotAddTheContainerInstance_WhenTheParentSoftwareSystemInstanceHasAlreadyBeenAdded() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); DeploymentNode deploymentNode1 = model.addDeploymentNode("Deployment Node 1", "Description", "Technology"); @@ -236,10 +236,10 @@ public void test_addContainerInstance_DoesNotAddTheContainerInstance_WhenThePare } @Test - public void test_addAnimationStep_ThrowsAnException_WhenNoElementInstancesAreSpecified() { + void addAnimationStep_ThrowsAnException_WhenNoElementInstancesAreSpecified() { try { deploymentView = views.createDeploymentView("deployment", "Description"); - deploymentView.addAnimation((ContainerInstance[])null); + deploymentView.addAnimation((ContainerInstance[]) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("One or more software system/container instances must be specified.", iae.getMessage()); @@ -247,10 +247,10 @@ public void test_addAnimationStep_ThrowsAnException_WhenNoElementInstancesAreSpe } @Test - public void test_addAnimationStep_ThrowsAnException_WhenNoInfrastructureNodesAreSpecified() { + void addAnimationStep_ThrowsAnException_WhenNoInfrastructureNodesAreSpecified() { try { deploymentView = views.createDeploymentView("deployment", "Description"); - deploymentView.addAnimation((InfrastructureNode[])null); + deploymentView.addAnimation((InfrastructureNode[]) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("One or more infrastructure nodes must be specified.", iae.getMessage()); @@ -258,7 +258,7 @@ public void test_addAnimationStep_ThrowsAnException_WhenNoInfrastructureNodesAre } @Test - public void test_addAnimationStep_ThrowsAnException_WhenNoElementInstancesOrInfrastructureNodesAreSpecified() { + void addAnimationStep_ThrowsAnException_WhenNoElementInstancesOrInfrastructureNodesAreSpecified() { try { deploymentView = views.createDeploymentView("deployment", "Description"); deploymentView.addAnimation(null, null); @@ -269,7 +269,7 @@ public void test_addAnimationStep_ThrowsAnException_WhenNoElementInstancesOrInfr } @Test - public void test_addAnimationStep() { + void addAnimationStep() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container webApplication = softwareSystem.addContainer("Web Application", "Description", "Technology"); Container database = softwareSystem.addContainer("Database", "Description", "Technology"); @@ -303,7 +303,7 @@ public void test_addAnimationStep() { } @Test - public void test_addAnimationStep_IgnoresContainerInstancesThatDoNotExistInTheView() { + void addAnimationStep_IgnoresContainerInstancesThatDoNotExistInTheView() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container webApplication = softwareSystem.addContainer("Web Application", "Description", "Technology"); Container database = softwareSystem.addContainer("Database", "Description", "Technology"); @@ -329,7 +329,7 @@ public void test_addAnimationStep_IgnoresContainerInstancesThatDoNotExistInTheVi } @Test - public void test_addAnimationStep_ThrowsAnException_WhenContainerInstancesAreSpecifiedButNoneOfThemExistInTheView() { + void addAnimationStep_ThrowsAnException_WhenContainerInstancesAreSpecifiedButNoneOfThemExistInTheView() { try { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container webApplication = softwareSystem.addContainer("Web Application", "Description", "Technology"); @@ -347,12 +347,12 @@ public void test_addAnimationStep_ThrowsAnException_WhenContainerInstancesAreSpe deploymentView.addAnimation(webApplicationInstance, databaseInstance); fail(); } catch (IllegalArgumentException iae) { - assertEquals("None of the specified container instances exist in this view.", iae.getMessage()); + assertEquals("None of the specified elements exist in this view.", iae.getMessage()); } } @Test - public void test_remove_RemovesTheInfrastructureNode() { + void remove_RemovesTheInfrastructureNode() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -372,7 +372,7 @@ public void test_remove_RemovesTheInfrastructureNode() { } @Test - public void test_remove_RemovesTheSoftwareSystemInstance() { + void remove_RemovesTheSoftwareSystemInstance() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); DeploymentNode deploymentNodeChild = deploymentNodeParent.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -391,7 +391,7 @@ public void test_remove_RemovesTheSoftwareSystemInstance() { } @Test - public void test_remove_RemovesTheContainerInstance() { + void remove_RemovesTheContainerInstance() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -411,7 +411,7 @@ public void test_remove_RemovesTheContainerInstance() { } @Test - public void test_remove_RemovesTheDeploymentNodeAndChildren() { + void remove_RemovesTheDeploymentNodeAndChildren() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -429,7 +429,7 @@ public void test_remove_RemovesTheDeploymentNodeAndChildren() { } @Test - public void test_remove_RemovesTheChildDeploymentNodeAndChildren() { + void remove_RemovesTheChildDeploymentNodeAndChildren() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", ""); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -446,7 +446,7 @@ public void test_remove_RemovesTheChildDeploymentNodeAndChildren() { } @Test - public void test_add_AddsTheInfrastructureNode() { + void add_AddsTheInfrastructureNode() { DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); DeploymentNode deploymentNodeChild = deploymentNodeParent.addDeploymentNode("Deployment Node", "Description", "Technology"); InfrastructureNode infrastructureNode1 = deploymentNodeChild.addInfrastructureNode("Infrastructure Node 1"); @@ -462,7 +462,7 @@ public void test_add_AddsTheInfrastructureNode() { } @Test - public void test_add_AddsTheSoftwareSystemInstance() { + void add_AddsTheSoftwareSystemInstance() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); DeploymentNode deploymentNodeChild = deploymentNodeParent.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -479,7 +479,7 @@ public void test_add_AddsTheSoftwareSystemInstance() { } @Test - public void test_addSoftwareSystemInstance_ThrowsAnException_WhenAChildContainerInstanceHasAlreadyBeenAdded() { + void addSoftwareSystemInstance_ThrowsAnException_WhenAChildContainerInstanceHasAlreadyBeenAdded() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -499,7 +499,7 @@ public void test_addSoftwareSystemInstance_ThrowsAnException_WhenAChildContainer } @Test - public void test_add_AddsTheContainerInstance() { + void add_AddsTheContainerInstance() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); @@ -517,7 +517,7 @@ public void test_add_AddsTheContainerInstance() { } @Test - public void test_addContainerInstance_ThrowsAnException_WhenTheParentSoftwareSystemInstanceHasAlreadyBeenAdded() { + void addContainerInstance_ThrowsAnException_WhenTheParentSoftwareSystemInstanceHasAlreadyBeenAdded() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); DeploymentNode deploymentNodeParent = model.addDeploymentNode("Deployment Node", "Description", "Technology"); diff --git a/structurizr-core/test/unit/com/structurizr/view/DimensionsTests.java b/structurizr-core/src/test/java/com/structurizr/view/DimensionsTests.java similarity index 71% rename from structurizr-core/test/unit/com/structurizr/view/DimensionsTests.java rename to structurizr-core/src/test/java/com/structurizr/view/DimensionsTests.java index fa2f7ff0b..da073721b 100644 --- a/structurizr-core/test/unit/com/structurizr/view/DimensionsTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/DimensionsTests.java @@ -1,13 +1,14 @@ package com.structurizr.view; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; public class DimensionsTests { @Test - public void test_construction() { + void construction() { Dimensions dimensions = new Dimensions(123, 456); assertEquals(123, dimensions.getWidth()); @@ -15,7 +16,7 @@ public void test_construction() { } @Test - public void test_setWidth_ThrowsAnException_WhenANegativeIntegerIsSpecified() { + void setWidth_ThrowsAnException_WhenANegativeIntegerIsSpecified() { try { Dimensions dimensions = new Dimensions(); dimensions.setWidth(-100); @@ -26,7 +27,7 @@ public void test_setWidth_ThrowsAnException_WhenANegativeIntegerIsSpecified() { } @Test - public void test_setHeight_ThrowsAnException_WhenANegativeIntegerIsSpecified() { + void setHeight_ThrowsAnException_WhenANegativeIntegerIsSpecified() { try { Dimensions dimensions = new Dimensions(); dimensions.setHeight(-100); diff --git a/structurizr-core/test/unit/com/structurizr/view/DynamicViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/DynamicViewTests.java similarity index 74% rename from structurizr-core/test/unit/com/structurizr/view/DynamicViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/DynamicViewTests.java index f26997d7d..bcbea49d6 100644 --- a/structurizr-core/test/unit/com/structurizr/view/DynamicViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/DynamicViewTests.java @@ -3,14 +3,14 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class DynamicViewTests extends AbstractWorkspaceTestBase { @@ -28,7 +28,7 @@ public class DynamicViewTests extends AbstractWorkspaceTestBase { private Relationship relationship; - @Before + @BeforeEach public void setup() { person = model.addPerson("Person", ""); softwareSystemA = model.addSoftwareSystem("Software System A", ""); @@ -44,10 +44,17 @@ public void setup() { } @Test - public void test_add_ThrowsAnException_WhenPassedANullSourceElement() { + void name() { + assertEquals("Dynamic View", views.createDynamicView("key1").getName()); + assertEquals("Dynamic View: Software System A", views.createDynamicView(softwareSystemA, "key2").getName()); + assertEquals("Dynamic View: Software System A - Container A1", views.createDynamicView(containerA1, "key3").getName()); + } + + @Test + void add_ThrowsAnException_WhenPassedANullSourceElement() { try { DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); - dynamicView.add(null, softwareSystemA); + dynamicView.add((StaticStructureElement)null, softwareSystemA); fail(); } catch (IllegalArgumentException iae) { assertEquals("A source element must be specified.", iae.getMessage()); @@ -55,10 +62,10 @@ public void test_add_ThrowsAnException_WhenPassedANullSourceElement() { } @Test - public void test_add_ThrowsAnException_WhenPassedANullDestinationElement() { + void add_ThrowsAnException_WhenPassedANullDestinationElement() { try { DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); - dynamicView.add(person, null); + dynamicView.add(person, (StaticStructureElement)null); fail(); } catch (IllegalArgumentException iae) { assertEquals("A destination element must be specified.", iae.getMessage()); @@ -66,7 +73,7 @@ public void test_add_ThrowsAnException_WhenPassedANullDestinationElement() { } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifiedButAContainerIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifiedButAContainerIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); dynamicView.add(containerA1, containerA1); @@ -77,7 +84,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifie } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifiedButAComponentIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifiedButAComponentIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); dynamicView.add(componentA1, componentA1); @@ -88,7 +95,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsNotSpecifie } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSystemButAComponentIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSystemButAComponentIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); dynamicView.add(componentA1, containerA1); @@ -99,7 +106,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSy } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSystemAndTheSameSoftwareSystemIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSystemAndTheSameSoftwareSystemIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); dynamicView.add(softwareSystemA, containerA1); @@ -110,7 +117,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsASoftwareSy } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerAndTheSameContainerIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerAndTheSameContainerIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView(containerA1, "key", "Description"); dynamicView.add(containerA1, containerA2); @@ -121,7 +128,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerA } @Test - public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerAndTheParentSoftwareSystemIsAdded() { + void add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerAndTheParentSoftwareSystemIsAdded() { try { DynamicView dynamicView = workspace.getViews().createDynamicView(containerA1, "key", "Description"); dynamicView.add(softwareSystemA, containerA2); @@ -132,7 +139,7 @@ public void test_add_ThrowsAnException_WhenTheScopeOfTheDynamicViewIsAContainerA } @Test - public void test_add_ThrowsAnException_WhenTheParentOfAnElementHasAlreadyBeenAdded() { + void add_ThrowsAnException_WhenTheParentOfAnElementHasAlreadyBeenAdded() { try { SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); Container container1 = softwareSystem.addContainer("Container 1", "", ""); @@ -154,7 +161,7 @@ public void test_add_ThrowsAnException_WhenTheParentOfAnElementHasAlreadyBeenAdd } @Test - public void test_add_ThrowsAnException_WhenTheChildOfAnElementHasAlreadyBeenAdded() { + void add_ThrowsAnException_WhenTheChildOfAnElementHasAlreadyBeenAdded() { try { SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); Container container1 = softwareSystem.addContainer("Container 1", "", ""); @@ -176,7 +183,7 @@ public void test_add_ThrowsAnException_WhenTheChildOfAnElementHasAlreadyBeenAdde } @Test - public void test_add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDestinationElementsDoesNotExist() { + void add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDestinationElementsDoesNotExist() { try { DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); SoftwareSystem ss1 = workspace.getModel().addSoftwareSystem("Software System 1", ""); @@ -189,7 +196,7 @@ public void test_add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDesti } @Test - public void test_add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDestinationElementsWithTheSpecifiedTechnologyDoesNotExist() { + void add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDestinationElementsWithTheSpecifiedTechnologyDoesNotExist() { try { workspace = new Workspace("Name", "Description"); model = workspace.getModel(); @@ -209,14 +216,48 @@ public void test_add_ThrowsAnException_WhenARelationshipBetweenTheSourceAndDesti } @Test - public void test_add_AddsTheSourceAndDestinationElements_WhenARelationshipBetweenThemExists() { + void addRelationshipWithOriginalDescription() { + workspace = new Workspace("Name", "Description"); + model = workspace.getModel(); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(relationship); + + assertEquals(2, view.getElements().size()); + assertSame(relationship, view.getRelationships().iterator().next().getRelationship()); + assertEquals("", view.getRelationships().iterator().next().getDescription()); + } + + @Test + void addRelationshipWithOveriddenDescription() { + workspace = new Workspace("Name", "Description"); + model = workspace.getModel(); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(relationship, "New description"); + + assertEquals(2, view.getElements().size()); + assertSame(relationship, view.getRelationships().iterator().next().getRelationship()); + assertEquals("New description", view.getRelationships().iterator().next().getDescription()); + } + + @Test + void add_AddsTheSourceAndDestinationElements_WhenARelationshipBetweenThemExists() { final DynamicView dynamicView = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); dynamicView.add(containerA1, containerA2); assertEquals(2, dynamicView.getElements().size()); } @Test - public void test_add_AddsTheSourceAndDestinationElements_WhenARelationshipBetweenThemExistsAndTheDestinationIsAnExternalSoftwareSystem() { + void add_AddsTheSourceAndDestinationElements_WhenARelationshipBetweenThemExistsAndTheDestinationIsAnExternalSoftwareSystem() { DynamicView dynamicView = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); containerA2.uses(softwareSystemB, "", ""); dynamicView.add(containerA2, softwareSystemB); @@ -224,7 +265,7 @@ public void test_add_AddsTheSourceAndDestinationElements_WhenARelationshipBetwee } @Test - public void test_normalSequence() { + void normalSequence() { workspace = new Workspace("Name", "Description"); model = workspace.getModel(); @@ -246,7 +287,27 @@ public void test_normalSequence() { } @Test - public void test_normalSequence_WhenThereAreMultipleTechnologies() { + void normalSequence_WhenThereAreMultipleDescriptions() { + workspace = new Workspace("Name", "Description"); + model = workspace.getModel(); + + SoftwareSystem ss1 = workspace.getModel().addSoftwareSystem("Software System 1", ""); + SoftwareSystem ss2 = workspace.getModel().addSoftwareSystem("Software System 2", ""); + + Relationship r1 = ss1.uses(ss2, "Uses 1"); + Relationship r2 = ss1.uses(ss2, "Uses 2"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + + RelationshipView rv1 = view.add(ss1, "Uses 1", ss2); + RelationshipView rv2 = view.add(ss1, "Uses 2", ss2); + + assertSame(r1, rv1.getRelationship()); + assertSame(r2, rv2.getRelationship()); + } + + @Test + void normalSequence_WhenThereAreMultipleTechnologies() { workspace = new Workspace("Name", "Description"); model = workspace.getModel(); @@ -266,7 +327,7 @@ public void test_normalSequence_WhenThereAreMultipleTechnologies() { } @Test - public void test_parallelSequence() { + void parallelSequence() { workspace = new Workspace("Name", "Description"); model = workspace.getModel(); SoftwareSystem softwareSystemA = model.addSoftwareSystem("A", ""); @@ -305,7 +366,45 @@ public void test_parallelSequence() { } @Test - public void test_getRelationships_WhenTheOrderPropertyIsAnInteger() { + void parallelSequence2() { + workspace = new Workspace("Name", "Description"); + model = workspace.getModel(); + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + SoftwareSystem d = model.addSoftwareSystem("D"); + + a.uses(b, ""); + b.uses(c, ""); + b.uses(d, ""); + b.uses(a, ""); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + + RelationshipView rv1 = view.add(a, b); + + view.startParallelSequence(); + + view.startParallelSequence(); + RelationshipView rv2 = view.add(b, c); + view.endParallelSequence(); + + view.startParallelSequence(); + RelationshipView rv3 = view.add(b, d); + view.endParallelSequence(); + + view.endParallelSequence(true); + + RelationshipView rv4 = view.add(b, a); + + assertEquals("1", rv1.getOrder()); + assertEquals("2", rv2.getOrder()); + assertEquals("2", rv3.getOrder()); + assertEquals("3", rv4.getOrder()); + } + + @Test + void getRelationships_WhenTheOrderPropertyIsAnInteger() { containerA1.uses(containerA2, "uses"); DynamicView view = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); for (int i = 0; i < 10; i++) { @@ -326,7 +425,7 @@ public void test_getRelationships_WhenTheOrderPropertyIsAnInteger() { } @Test - public void test_getRelationships_WhenTheOrderPropertyIsADecimal() { + void getRelationships_WhenTheOrderPropertyIsADecimal() { containerA1.uses(containerA2, "uses"); DynamicView view = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); for (int i = 0; i < 10; i++) { @@ -348,7 +447,7 @@ public void test_getRelationships_WhenTheOrderPropertyIsADecimal() { } @Test - public void test_getRelationships_WhenTheOrderPropertyIsAString() { + void getRelationships_WhenTheOrderPropertyIsAString() { String characters = "abcdefghij"; containerA1.uses(containerA2, "uses"); DynamicView view = workspace.getViews().createDynamicView(softwareSystemA, "key", "Description"); @@ -371,14 +470,14 @@ public void test_getRelationships_WhenTheOrderPropertyIsAString() { } @Test - public void test_response() { + void response() { workspace = new Workspace("Name", "Description"); model = workspace.getModel(); SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); Relationship relationship = softwareSystem1.uses(softwareSystem2, "Uses"); - + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); view.add(softwareSystem1, "Asks for X", softwareSystem2); view.add(softwareSystem2, "Returns X", softwareSystem1); // this relationship doesn't exist, so is assumed to be a response diff --git a/structurizr-core/test/unit/com/structurizr/view/ElementStyleTests.java b/structurizr-core/src/test/java/com/structurizr/view/ElementStyleTests.java similarity index 87% rename from structurizr-core/test/unit/com/structurizr/view/ElementStyleTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ElementStyleTests.java index 40ec16c6a..c26c44509 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ElementStyleTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ElementStyleTests.java @@ -1,16 +1,16 @@ package com.structurizr.view; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ElementStyleTests { @Test - public void test_setOpacity() { + void setOpacity() { ElementStyle style = new ElementStyle(); assertNull(style.getOpacity()); @@ -31,7 +31,7 @@ public void test_setOpacity() { } @Test - public void test_opacity() { + void opacity() { ElementStyle style = new ElementStyle(); assertNull(style.getOpacity()); @@ -52,7 +52,7 @@ public void test_opacity() { } @Test - public void test_setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { + void setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.setColor("#ffffff"); assertEquals("#ffffff", style.getColor()); @@ -65,7 +65,7 @@ public void test_setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified } @Test - public void test_color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { + void color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.color("#ffffff"); assertEquals("#ffffff", style.getColor()); @@ -77,20 +77,38 @@ public void test_color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() assertEquals("#123456", style.getColor()); } - @Test(expected = IllegalArgumentException.class) - public void test_setColor_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void setColor_SetsTheColorProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.setColor("white"); + style.setColor("yellow"); + assertEquals("#ffff00", style.getColor()); } - @Test(expected = IllegalArgumentException.class) - public void test_color_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void color_SetsTheColorProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.color("white"); + style.color("yellow"); + assertEquals("#ffff00", style.getColor()); + } + + @Test + void setColor_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.setColor("hello"); + }); + } + + @Test + void color_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.color("hello"); + }); } @Test - public void test_setBackground_SetsTheBackgroundProperty_WhenAValidHexColorCodeIsSpecified() { + void setBackground_SetsTheBackgroundProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.setBackground("#ffffff"); assertEquals("#ffffff", style.getBackground()); @@ -103,7 +121,7 @@ public void test_setBackground_SetsTheBackgroundProperty_WhenAValidHexColorCodeI } @Test - public void test_background_SetsTheBackgroundProperty_WhenAValidHexColorCodeIsSpecified() { + void background_SetsTheBackgroundProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.background("#ffffff"); assertEquals("#ffffff", style.getBackground()); @@ -115,61 +133,81 @@ public void test_background_SetsTheBackgroundProperty_WhenAValidHexColorCodeIsSp assertEquals("#123456", style.getBackground()); } - @Test(expected = IllegalArgumentException.class) - public void test_setBackground_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void setBackground_SetsTheBackgroundProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.setBackground("white"); + style.setBackground("yellow"); + assertEquals("#ffff00", style.getBackground()); } - @Test(expected = IllegalArgumentException.class) - public void test_background_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void background_SetsTheBackgroundProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.background("white"); + style.background("yellow"); + assertEquals("#ffff00", style.getBackground()); } @Test - public void test_setIcon_WithAUrl() { + void setBackground_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.setBackground("hello"); + }); + } + + @Test + void background_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.background("hello"); + }); + } + + @Test + void setIcon_WithAUrl() { ElementStyle style = new ElementStyle(); style.setIcon("https://structurizr.com/static/img/structurizr-logo.png"); assertEquals("https://structurizr.com/static/img/structurizr-logo.png", style.getIcon()); } @Test - public void test_setIcon_WithAUrlThatHasATrailingSpaceCharacter() { + void setIcon_WithAUrlThatHasATrailingSpaceCharacter() { ElementStyle style = new ElementStyle(); style.setIcon("https://structurizr.com/static/img/structurizr-logo.png "); assertEquals("https://structurizr.com/static/img/structurizr-logo.png", style.getIcon()); } @Test - public void test_setIcon_WithADataUri() { + void setIcon_WithADataUri() { ElementStyle style = new ElementStyle(); style.setIcon("data:image/png;base64,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"); assertEquals("data:image/png;base64,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", style.getIcon()); } - @Test(expected = IllegalArgumentException.class) - public void test_setIcon_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - ElementStyle style = new ElementStyle(); - style.setIcon("htt://blah"); + @Test + void setIcon_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.setIcon("htt://blah"); + }); } @Test - public void test_setIcon_DoesNothing_WhenANullUrlIsSpecified() { + void setIcon_DoesNothing_WhenANullUrlIsSpecified() { ElementStyle style = new ElementStyle(); style.setIcon(null); assertNull(style.getIcon()); } @Test - public void test_setIcon_DoesNothing_WhenAnEmptyUrlIsSpecified() { + void setIcon_DoesNothing_WhenAnEmptyUrlIsSpecified() { ElementStyle style = new ElementStyle(); style.setIcon(" "); assertNull(style.getIcon()); } @Test - public void test_setStroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecified() { + void setStroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.setStroke("#ffffff"); assertEquals("#ffffff", style.getStroke()); @@ -182,7 +220,7 @@ public void test_setStroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecifi } @Test - public void test_Stroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecified() { + void Stroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecified() { ElementStyle style = new ElementStyle(); style.stroke("#ffffff"); assertEquals("#ffffff", style.getStroke()); @@ -194,26 +232,44 @@ public void test_Stroke_SetsTheStrokeProperty_WhenAValidHexColorCodeIsSpecified( assertEquals("#123456", style.getStroke()); } - @Test(expected = IllegalArgumentException.class) - public void test_setStroke_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void setStroke_SetsTheStrokeProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.setStroke("white"); + style.setStroke("yellow"); + assertEquals("#ffff00", style.getStroke()); } - @Test(expected = IllegalArgumentException.class) - public void test_Stroke_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void Stroke_SetsTheStrokeProperty_WhenAValidColorNameIsSpecified() { ElementStyle style = new ElementStyle(); - style.stroke("white"); + style.stroke("yellow"); + assertEquals("#ffff00", style.getStroke()); } @Test - public void test_getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { + void setStroke_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.setStroke("hello"); + }); + } + + @Test + void Stroke_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + ElementStyle style = new ElementStyle(); + style.stroke("hello"); + }); + } + + @Test + void getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { ElementStyle style = new ElementStyle(); assertEquals(0, style.getProperties().size()); } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { + void addProperty_ThrowsAnException_WhenTheNameIsNull() { try { ElementStyle style = new ElementStyle(); style.addProperty(null, "value"); @@ -224,7 +280,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { + void addProperty_ThrowsAnException_WhenTheNameIsEmpty() { try { ElementStyle style = new ElementStyle(); style.addProperty(" ", "value"); @@ -235,7 +291,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { + void addProperty_ThrowsAnException_WhenTheValueIsNull() { try { ElementStyle style = new ElementStyle(); style.addProperty("name", null); @@ -246,7 +302,7 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { + void addProperty_ThrowsAnException_WhenTheValueIsEmpty() { try { ElementStyle style = new ElementStyle(); style.addProperty("name", " "); @@ -257,21 +313,21 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { } @Test - public void test_addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { + void addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { ElementStyle style = new ElementStyle(); style.addProperty("name", "value"); assertEquals("value", style.getProperties().get("name")); } @Test - public void test_setProperties_DoesNothing_WhenNullIsSpecified() { + void setProperties_DoesNothing_WhenNullIsSpecified() { ElementStyle style = new ElementStyle(); style.setProperties(null); assertEquals(0, style.getProperties().size()); } @Test - public void test_setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { + void setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { ElementStyle style = new ElementStyle(); Map properties = new HashMap<>(); properties.put("name", "value"); @@ -280,4 +336,40 @@ public void test_setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { assertEquals("value", style.getProperties().get("name")); } + @Test + void setStrokeWidth_WhenNullIsSpecified() { + ElementStyle style = new ElementStyle(); + style.setStrokeWidth(10); + style.setStrokeWidth(null); + assertNull(style.getStrokeWidth()); + } + + @Test + void setStrokeWidth_WhenANegativeIntegerIsSpecified() { + ElementStyle style = new ElementStyle(); + style.setStrokeWidth(-1); + assertEquals(1, style.getStrokeWidth()); + } + + @Test + void setStrokeWidth_WhenZeroIsSpecified() { + ElementStyle style = new ElementStyle(); + style.setStrokeWidth(0); + assertEquals(1, style.getStrokeWidth()); + } + + @Test + void setStrokeWidth_WhenAPositiveIntegerIsSpecified() { + ElementStyle style = new ElementStyle(); + style.setStrokeWidth(10); + assertEquals(10, style.getStrokeWidth()); + } + + @Test + void setStrokeWidth_WhenAPositiveIntegerOver10IsSpecified() { + ElementStyle style = new ElementStyle(); + style.setStrokeWidth(20); + assertEquals(10, style.getStrokeWidth()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/ElementViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/ElementViewTests.java similarity index 63% rename from structurizr-core/test/unit/com/structurizr/view/ElementViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ElementViewTests.java index a1f1fcd63..661308e4e 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ElementViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ElementViewTests.java @@ -2,23 +2,22 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.Element; -import com.structurizr.model.Location; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class ElementViewTests extends AbstractWorkspaceTestBase { @Test - public void test_copyLayoutInformationFrom_DoesNothing_WhenNullIsPassed() { - Element element = model.addSoftwareSystem(Location.External, "SystemA", ""); + void copyLayoutInformationFrom_DoesNothing_WhenNullIsPassed() { + Element element = model.addSoftwareSystem("SystemA", ""); ElementView elementView = new ElementView(element); elementView.copyLayoutInformationFrom(null); } @Test - public void test_copyLayoutInformationFrom_CopiesXAndY_WhenANonNullElementViewIsPassed() { - Element element = model.addSoftwareSystem(Location.External, "SystemA", ""); + void copyLayoutInformationFrom_CopiesXAndY_WhenANonNullElementViewIsPassed() { + Element element = model.addSoftwareSystem("SystemA", ""); ElementView elementView1 = new ElementView(element); assertEquals(0, elementView1.getX()); assertEquals(0, elementView1.getY()); diff --git a/structurizr-core/test/unit/com/structurizr/view/FilteredViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/FilteredViewTests.java similarity index 91% rename from structurizr-core/test/unit/com/structurizr/view/FilteredViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/FilteredViewTests.java index 83f4ee965..a21074f97 100644 --- a/structurizr-core/test/unit/com/structurizr/view/FilteredViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/FilteredViewTests.java @@ -2,14 +2,14 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.SoftwareSystem; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class FilteredViewTests extends AbstractWorkspaceTestBase { @Test - public void test_construction() { + void construction() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); SystemContextView systemContextView = views.createSystemContextView(softwareSystem, "SystemContext", "Description"); FilteredView filteredView = views.createFilteredView( diff --git a/structurizr-core/test/unit/com/structurizr/view/FontTests.java b/structurizr-core/src/test/java/com/structurizr/view/FontTests.java similarity index 59% rename from structurizr-core/test/unit/com/structurizr/view/FontTests.java rename to structurizr-core/src/test/java/com/structurizr/view/FontTests.java index 435c759df..b5cee1446 100644 --- a/structurizr-core/test/unit/com/structurizr/view/FontTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/FontTests.java @@ -1,52 +1,53 @@ package com.structurizr.view; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.*; public class FontTests { private Font font; - @Before + @BeforeEach public void setUp() { this.font = new Font(); } @Test - public void construction_WithANameOnly() { + void construction_WithANameOnly() { this.font = new Font("Times New Roman"); assertEquals("Times New Roman", font.getName()); } @Test - public void construction_WithANameAndUrl() { + void construction_WithANameAndUrl() { this.font = new Font("Open Sans", "https://fonts.googleapis.com/css?family=Open+Sans:400,700"); assertEquals("Open Sans", font.getName()); assertEquals("https://fonts.googleapis.com/css?family=Open+Sans:400,700", font.getUrl()); } @Test - public void test_setUrl_WithAUrl() { + void setUrl_WithAUrl() { font.setUrl("https://fonts.googleapis.com/css?family=Open+Sans:400,700"); assertEquals("https://fonts.googleapis.com/css?family=Open+Sans:400,700", font.getUrl()); } - @Test(expected = IllegalArgumentException.class) - public void test_setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - font.setUrl("htt://blah"); + @Test + void setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + font.setUrl("htt://blah"); + }); } @Test - public void test_setUrl_DoesNothing_WhenANullUrlIsSpecified() { + void setUrl_DoesNothing_WhenANullUrlIsSpecified() { font.setUrl(null); assertNull(font.getUrl()); } @Test - public void test_setUrl_DoesNothing_WhenAnEmptyUrlIsSpecified() { + void setUrl_DoesNothing_WhenAnEmptyUrlIsSpecified() { font.setUrl(" "); assertNull(font.getUrl()); } diff --git a/structurizr-core/src/test/java/com/structurizr/view/ImageViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/ImageViewTests.java new file mode 100644 index 000000000..97056284e --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/view/ImageViewTests.java @@ -0,0 +1,57 @@ +package com.structurizr.view; + +import com.structurizr.AbstractWorkspaceTestBase; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ImageViewTests extends AbstractWorkspaceTestBase { + + @Test + void construction_WhenNoElementIsSpecified() { + ImageView view = views.createImageView("key"); + + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + } + + @Test + void construction_WhenAnElementIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ImageView view = views.createImageView(softwareSystem, "key"); + + assertEquals("key", view.getKey()); + assertSame(softwareSystem, view.getElement()); + assertEquals(softwareSystem.getId(), view.getElementId()); + } + + @Test + void hasContent_WhenNoContent() { + ImageView view = views.createImageView("key"); + assertFalse(view.hasContent()); + } + + @Test + void hasContent_WhenContent() { + ImageView view = views.createImageView("key"); + view.setContent("https://example.com/image.png"); + assertTrue(view.hasContent()); + } + + @Test + void hasContent_WhenContentLight() { + ImageView view = views.createImageView("key"); + view.setContent("https://example.com/image.png", ColorScheme.Light); + assertTrue(view.hasContent()); + } + + @Test + void hasContent_WhenContentDark() { + ImageView view = views.createImageView("key"); + view.setContent("https://example.com/image.png", ColorScheme.Dark); + assertTrue(view.hasContent()); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/PaperSizeTests.java b/structurizr-core/src/test/java/com/structurizr/view/PaperSizeTests.java similarity index 92% rename from structurizr-core/test/unit/com/structurizr/view/PaperSizeTests.java rename to structurizr-core/src/test/java/com/structurizr/view/PaperSizeTests.java index 70f4e1ac3..bf4af3932 100644 --- a/structurizr-core/test/unit/com/structurizr/view/PaperSizeTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/PaperSizeTests.java @@ -1,15 +1,15 @@ package com.structurizr.view; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.List; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertEquals; public class PaperSizeTests { @Test - public void test_getOrderedPaperSizes_WhenOrientationIsLandscape() { + void getOrderedPaperSizes_WhenOrientationIsLandscape() { List paperSizes = PaperSize.getOrderedPaperSizes(PaperSize.Orientation.Landscape); assertEquals(12, paperSizes.size()); @@ -29,7 +29,7 @@ public void test_getOrderedPaperSizes_WhenOrientationIsLandscape() { } @Test - public void test_getOrderedPaperSizes_WhenOrientationIsPortrait() { + void getOrderedPaperSizes_WhenOrientationIsPortrait() { List paperSizes = PaperSize.getOrderedPaperSizes(PaperSize.Orientation.Portrait); assertEquals(9, paperSizes.size()); @@ -46,7 +46,7 @@ public void test_getOrderedPaperSizes_WhenOrientationIsPortrait() { } @Test - public void test_getOrderedPaperSizes() { + void getOrderedPaperSizes() { List paperSizes = PaperSize.getOrderedPaperSizes(); assertEquals(21, paperSizes.size()); diff --git a/structurizr-core/test/unit/com/structurizr/view/RelationshipStyleTests.java b/structurizr-core/src/test/java/com/structurizr/view/RelationshipStyleTests.java similarity index 72% rename from structurizr-core/test/unit/com/structurizr/view/RelationshipStyleTests.java rename to structurizr-core/src/test/java/com/structurizr/view/RelationshipStyleTests.java index 939770e33..e34cff717 100644 --- a/structurizr-core/test/unit/com/structurizr/view/RelationshipStyleTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/RelationshipStyleTests.java @@ -1,36 +1,36 @@ package com.structurizr.view; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class RelationshipStyleTests { private RelationshipStyle relationshipStyle = new RelationshipStyle("tag"); @Test - public void test_setPosition_SetsPositionToNull_WhenNullIsSpecified() { + void setPosition_SetsPositionToNull_WhenNullIsSpecified() { relationshipStyle.setPosition(null); assertNull(relationshipStyle.getPosition()); } @Test - public void test_setPosition_SetsPositionToZero_WhenANegativeNumberIsSpecified() { + void setPosition_SetsPositionToZero_WhenANegativeNumberIsSpecified() { relationshipStyle.setPosition(-1); assertEquals(Integer.valueOf(0), relationshipStyle.getPosition()); } @Test - public void test_setPosition_SetsPositionToOneHundred_WhenANumberGreaterThanOneHundredIsSpecified() { + void setPosition_SetsPositionToOneHundred_WhenANumberGreaterThanOneHundredIsSpecified() { relationshipStyle.setPosition(101); assertEquals(Integer.valueOf(100), relationshipStyle.getPosition()); } @Test - public void test_setPosition_SetsPosition_WhenANumberBetweenZeroAndOneHundredIsSpecified() { + void setPosition_SetsPosition_WhenANumberBetweenZeroAndOneHundredIsSpecified() { relationshipStyle.setPosition(0); assertEquals(Integer.valueOf(0), relationshipStyle.getPosition()); @@ -49,7 +49,7 @@ public void test_setPosition_SetsPosition_WhenANumberBetweenZeroAndOneHundredIsS } @Test - public void test_setOpacity() { + void setOpacity() { RelationshipStyle style = new RelationshipStyle(); assertNull(style.getOpacity()); @@ -70,7 +70,7 @@ public void test_setOpacity() { } @Test - public void test_opacity() { + void opacity() { RelationshipStyle style = new RelationshipStyle(); assertNull(style.getOpacity()); @@ -91,7 +91,7 @@ public void test_opacity() { } @Test - public void test_setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { + void setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { RelationshipStyle style = new RelationshipStyle(); style.setColor("#ffffff"); assertEquals("#ffffff", style.getColor()); @@ -104,7 +104,7 @@ public void test_setColor_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified } @Test - public void test_color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { + void color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() { RelationshipStyle style = new RelationshipStyle(); style.color("#ffffff"); assertEquals("#ffffff", style.getColor()); @@ -116,26 +116,44 @@ public void test_color_SetsTheColorProperty_WhenAValidHexColorCodeIsSpecified() assertEquals("#123456", style.getColor()); } - @Test(expected = IllegalArgumentException.class) - public void test_setColor_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void setColor_SetsTheColorProperty_WhenAValidColorNameIsSpecified() { RelationshipStyle style = new RelationshipStyle(); - style.setColor("white"); + style.setColor("yellow"); + assertEquals("#ffff00", style.getColor()); } - @Test(expected = IllegalArgumentException.class) - public void test_color_ThrowsAnException_WhenAnInvalidHexColorCodeIsSpecified() { + @Test + void color_SetsTheColorProperty_WhenAValidColorNameIsSpecified() { RelationshipStyle style = new RelationshipStyle(); - style.color("white"); + style.color("yellow"); + assertEquals("#ffff00", style.getColor()); + } + + @Test + void setColor_ThrowsAnException_WhenAnInvalidColorIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + RelationshipStyle style = new RelationshipStyle(); + style.setColor("hello"); + }); + } + + @Test + void color_ThrowsAnException_WhenAnInvalidColorIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + RelationshipStyle style = new RelationshipStyle(); + style.color("hello"); + }); } @Test - public void test_getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { + void getProperties_ReturnsAnEmptyList_WhenNoPropertiesHaveBeenAdded() { RelationshipStyle style = new RelationshipStyle(); assertEquals(0, style.getProperties().size()); } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { + void addProperty_ThrowsAnException_WhenTheNameIsNull() { try { RelationshipStyle style = new RelationshipStyle(); style.addProperty(null, "value"); @@ -146,7 +164,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { + void addProperty_ThrowsAnException_WhenTheNameIsEmpty() { try { RelationshipStyle style = new RelationshipStyle(); style.addProperty(" ", "value"); @@ -157,7 +175,7 @@ public void test_addProperty_ThrowsAnException_WhenTheNameIsEmpty() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { + void addProperty_ThrowsAnException_WhenTheValueIsNull() { try { RelationshipStyle style = new RelationshipStyle(); style.addProperty("name", null); @@ -168,7 +186,7 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsNull() { } @Test - public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { + void addProperty_ThrowsAnException_WhenTheValueIsEmpty() { try { RelationshipStyle style = new RelationshipStyle(); style.addProperty("name", " "); @@ -179,21 +197,21 @@ public void test_addProperty_ThrowsAnException_WhenTheValueIsEmpty() { } @Test - public void test_addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { + void addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { RelationshipStyle style = new RelationshipStyle(); style.addProperty("name", "value"); assertEquals("value", style.getProperties().get("name")); } @Test - public void test_setProperties_DoesNothing_WhenNullIsSpecified() { + void setProperties_DoesNothing_WhenNullIsSpecified() { RelationshipStyle style = new RelationshipStyle(); style.setProperties(null); assertEquals(0, style.getProperties().size()); } @Test - public void test_setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { + void setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { RelationshipStyle style = new RelationshipStyle(); Map properties = new HashMap<>(); properties.put("name", "value"); diff --git a/structurizr-core/src/test/java/com/structurizr/view/RelationshipViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/RelationshipViewTests.java new file mode 100644 index 000000000..635414970 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/view/RelationshipViewTests.java @@ -0,0 +1,98 @@ +package com.structurizr.view; + +import com.structurizr.AbstractWorkspaceTestBase; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class RelationshipViewTests extends AbstractWorkspaceTestBase { + + @Test + void getProperties_ReturnsAnEmptyMap_WhenNoPropertiesHaveBeenAdded() { + RelationshipView relationshipView = new RelationshipView(); + assertEquals(0, relationshipView.getProperties().size()); + } + + @Test + void getProperties_ReturnsAnUnmodifiableMap() { + RelationshipView relationshipView = new RelationshipView(); + try { + relationshipView.getProperties().put("name", "value"); + fail(); + } catch (Exception e) { + assertTrue(e instanceof UnsupportedOperationException); + } + } + + @Test + void addProperty_ThrowsAnException_WhenTheNameIsNull() { + try { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.addProperty(null, "value"); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("A property name must be specified.", e.getMessage()); + } + } + + @Test + void addProperty_ThrowsAnException_WhenTheNameIsEmpty() { + try { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.addProperty(" ", "value"); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("A property name must be specified.", e.getMessage()); + } + } + + @Test + void addProperty_ThrowsAnException_WhenTheValueIsNull() { + try { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.addProperty("name", null); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("A property value must be specified.", e.getMessage()); + } + } + + @Test + void addProperty_ThrowsAnException_WhenTheValueIsEmpty() { + try { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.addProperty("name", " "); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("A property value must be specified.", e.getMessage()); + } + } + + @Test + void addProperty_AddsTheProperty_WhenANameAndValueAreSpecified() { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.addProperty("name", "value"); + assertEquals("value", relationshipView.getProperties().get("name")); + } + + @Test + void setProperties_DoesNothing_WhenNullIsSpecified() { + RelationshipView relationshipView = new RelationshipView(); + relationshipView.setProperties(null); + assertEquals(0, relationshipView.getProperties().size()); + } + + @Test + void setProperties_SetsTheProperties_WhenANonEmptyMapIsSpecified() { + RelationshipView relationshipView = new RelationshipView(); + Map properties = new HashMap<>(); + properties.put("name", "value"); + relationshipView.setProperties(properties); + assertEquals(1, relationshipView.getProperties().size()); + assertEquals("value", relationshipView.getProperties().get("name")); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/SequenceCounterTests.java b/structurizr-core/src/test/java/com/structurizr/view/SequenceCounterTests.java similarity index 68% rename from structurizr-core/test/unit/com/structurizr/view/SequenceCounterTests.java rename to structurizr-core/src/test/java/com/structurizr/view/SequenceCounterTests.java index 69da2fd5c..3b8242421 100644 --- a/structurizr-core/test/unit/com/structurizr/view/SequenceCounterTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/SequenceCounterTests.java @@ -1,13 +1,13 @@ package com.structurizr.view; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class SequenceCounterTests { @Test - public void test_increment_IncrementsTheCounter_WhenThereIsNoParent() { + void increment_IncrementsTheCounter_WhenThereIsNoParent() { SequenceCounter counter = new SequenceCounter(); assertEquals("0", counter.toString()); diff --git a/structurizr-core/src/test/java/com/structurizr/view/SequenceNumberTests.java b/structurizr-core/src/test/java/com/structurizr/view/SequenceNumberTests.java new file mode 100644 index 000000000..6735450c6 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/view/SequenceNumberTests.java @@ -0,0 +1,52 @@ +package com.structurizr.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SequenceNumberTests { + + @Test + void increment() { + SequenceNumber sequenceNumber = new SequenceNumber(); + assertEquals("1", sequenceNumber.getNext()); + assertEquals("2", sequenceNumber.getNext()); + } + + @Test + void parallelSequences_1() { + SequenceNumber sequenceNumber = new SequenceNumber(); + assertEquals("1", sequenceNumber.getNext()); + + sequenceNumber.startParallelSequence(); + assertEquals("2", sequenceNumber.getNext()); + sequenceNumber.endParallelSequence(false); + + sequenceNumber.startParallelSequence(); + assertEquals("2", sequenceNumber.getNext()); + sequenceNumber.endParallelSequence(true); + + assertEquals("3", sequenceNumber.getNext()); + } + + @Test + void parallelSequences_2() { + SequenceNumber sequenceNumber = new SequenceNumber(); + assertEquals("1", sequenceNumber.getNext()); + + sequenceNumber.startParallelSequence(); + + sequenceNumber.startParallelSequence(); + assertEquals("2", sequenceNumber.getNext()); + sequenceNumber.endParallelSequence(false); + + sequenceNumber.startParallelSequence(); + assertEquals("2", sequenceNumber.getNext()); + sequenceNumber.endParallelSequence(false); + + sequenceNumber.endParallelSequence(true); + + assertEquals("3", sequenceNumber.getNext()); + } + +} diff --git a/structurizr-core/test/unit/com/structurizr/view/StaticViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/StaticViewTests.java similarity index 89% rename from structurizr-core/test/unit/com/structurizr/view/StaticViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/StaticViewTests.java index b15f4aa05..722bd5c39 100644 --- a/structurizr-core/test/unit/com/structurizr/view/StaticViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/StaticViewTests.java @@ -3,14 +3,14 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.Relationship; import com.structurizr.model.SoftwareSystem; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class StaticViewTests extends AbstractWorkspaceTestBase { @Test - public void test_addAnimationStep_ThrowsAnException_WhenNoElementsAreSpecified() { + void addAnimationStep_ThrowsAnException_WhenNoElementsAreSpecified() { try { SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); view.addAnimation(); @@ -21,7 +21,7 @@ public void test_addAnimationStep_ThrowsAnException_WhenNoElementsAreSpecified() } @Test - public void test_addAnimationStep() { + void addAnimationStep() { SoftwareSystem element1 = model.addSoftwareSystem("Software System 1", ""); SoftwareSystem element2 = model.addSoftwareSystem("Software System 2", ""); SoftwareSystem element3 = model.addSoftwareSystem("Software System 3", ""); @@ -55,7 +55,7 @@ public void test_addAnimationStep() { } @Test - public void test_addAnimationStep_IgnoresElementsThatDoNotExistInTheView() { + void addAnimationStep_IgnoresElementsThatDoNotExistInTheView() { SoftwareSystem element1 = model.addSoftwareSystem("Software System 1", ""); SoftwareSystem element2 = model.addSoftwareSystem("Software System 2", ""); @@ -69,7 +69,7 @@ public void test_addAnimationStep_IgnoresElementsThatDoNotExistInTheView() { } @Test - public void test_addAnimationStep_ThrowsAnException_WhenElementsAreSpecifiedButNoneOfThemExistInTheView() { + void addAnimationStep_ThrowsAnException_WhenElementsAreSpecifiedButNoneOfThemExistInTheView() { try { SoftwareSystem element1 = model.addSoftwareSystem("Software System 1", ""); diff --git a/structurizr-core/test/unit/com/structurizr/view/StylesTests.java b/structurizr-core/src/test/java/com/structurizr/view/StylesTests.java similarity index 51% rename from structurizr-core/test/unit/com/structurizr/view/StylesTests.java rename to structurizr-core/src/test/java/com/structurizr/view/StylesTests.java index 5857a8076..befbcebcd 100644 --- a/structurizr-core/test/unit/com/structurizr/view/StylesTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/StylesTests.java @@ -2,56 +2,117 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; public class StylesTests extends AbstractWorkspaceTestBase { - private Styles styles = new Styles(); + private final Styles styles = new Styles(); + + @Test + void test_sortingOfElementStyles() { + ElementStyle softwareLight = styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Light); + ElementStyle softwareDark = styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Dark); + ElementStyle software = styles.addElementStyle(Tags.SOFTWARE_SYSTEM); + ElementStyle elementDark = styles.addElementStyle(Tags.ELEMENT, ColorScheme.Dark); + ElementStyle elementLight = styles.addElementStyle(Tags.ELEMENT, ColorScheme.Light); + ElementStyle element = styles.addElementStyle(Tags.ELEMENT); + + List elementStyles = new LinkedList<>(styles.getElements()); + assertSame(element, elementStyles.get(0)); + assertSame(software, elementStyles.get(1)); + assertSame(elementDark, elementStyles.get(2)); + assertSame(softwareDark, elementStyles.get(3)); + assertSame(elementLight, elementStyles.get(4)); + assertSame(softwareLight, elementStyles.get(5)); + } + + @Test + void test_sortingOfRelationshipStyles() { + RelationshipStyle tag2Light = styles.addRelationshipStyle("Tag 2", ColorScheme.Light); + RelationshipStyle tag2Dark = styles.addRelationshipStyle("Tag 2", ColorScheme.Dark); + RelationshipStyle tag2 = styles.addRelationshipStyle("Tag 2"); + RelationshipStyle tag1Light = styles.addRelationshipStyle("Tag 1", ColorScheme.Light); + RelationshipStyle tag1Dark = styles.addRelationshipStyle("Tag 1", ColorScheme.Dark); + RelationshipStyle tag1 = styles.addRelationshipStyle("Tag 1"); + + List relationshipStyles = new LinkedList<>(styles.getRelationships()); + assertSame(tag1, relationshipStyles.get(0)); + assertSame(tag2, relationshipStyles.get(1)); + assertSame(tag1Dark, relationshipStyles.get(2)); + assertSame(tag2Dark, relationshipStyles.get(3)); + assertSame(tag1Light, relationshipStyles.get(4)); + assertSame(tag2Light, relationshipStyles.get(5)); + } @Test - public void test_findElementStyle_ReturnsTheDefaultStyle_WhenPassedNull() { - ElementStyle style = styles.findElementStyle((Element)null); - assertEquals(Integer.valueOf(450), style.getWidth()); - assertEquals(Integer.valueOf(300), style.getHeight()); - assertEquals("#dddddd", style.getBackground()); - assertEquals("#000000", style.getColor()); + void findElementStyle_ReturnsTheDefaultStyle_WhenPassedNull() { + ElementStyle style = styles.findElementStyle((Element) null); + assertNull(style.getWidth()); + assertNull(style.getHeight()); + assertEquals("#ffffff", style.getBackground()); + assertEquals("#444444", style.getColor()); + assertEquals("#444444", style.getStroke()); assertEquals(Integer.valueOf(24), style.getFontSize()); assertEquals(Shape.Box, style.getShape()); assertNull(style.getIcon()); assertEquals(Border.Solid, style.getBorder()); - assertEquals("#9a9a9a", style.getStroke()); + assertNull(style.getStrokeWidth()); assertEquals(Integer.valueOf(100), style.getOpacity()); assertEquals(true, style.getMetadata()); assertEquals(true, style.getDescription()); } @Test - public void test_findElementStyle_ReturnsTheDefaultStyle_WhenNoStylesAreDefined() { + void findElementStyle_ReturnsTheDefaultStyle_WhenNoStylesAreDefined() { SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); ElementStyle style = styles.findElementStyle(element); - assertEquals(Integer.valueOf(450), style.getWidth()); - assertEquals(Integer.valueOf(300), style.getHeight()); - assertEquals("#dddddd", style.getBackground()); - assertEquals("#000000", style.getColor()); + assertNull(style.getWidth()); + assertNull(style.getHeight()); + assertEquals("#ffffff", style.getBackground()); + assertEquals("#444444", style.getColor()); + assertEquals("#444444", style.getStroke()); + assertEquals(Integer.valueOf(24), style.getFontSize()); + assertEquals(Shape.Box, style.getShape()); + assertNull(style.getIcon()); + assertEquals(Border.Solid, style.getBorder()); + assertNull(style.getStrokeWidth()); + assertEquals(Integer.valueOf(100), style.getOpacity()); + assertEquals(true, style.getMetadata()); + assertEquals(true, style.getDescription()); + } + + @Test + void findElementStyleForDarkMode_ReturnsTheDefaultStyle_WhenNoStylesAreDefined() { + SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); + ElementStyle style = styles.findElementStyle(element, ColorScheme.Dark); + assertNull(style.getWidth()); + assertNull(style.getHeight()); + assertEquals("#111111", style.getBackground()); + assertEquals("#cccccc", style.getColor()); + assertEquals("#cccccc", style.getStroke()); assertEquals(Integer.valueOf(24), style.getFontSize()); assertEquals(Shape.Box, style.getShape()); assertNull(style.getIcon()); assertEquals(Border.Solid, style.getBorder()); - assertEquals("#9a9a9a", style.getStroke()); + assertNull(style.getStrokeWidth()); assertEquals(Integer.valueOf(100), style.getOpacity()); assertEquals(true, style.getMetadata()); assertEquals(true, style.getDescription()); } @Test - public void test_findElementStyle_ReturnsTheCorrectStyle_WhenStylesAreDefined() { + void findElementStyle_ReturnsTheCorrectStyle_WhenStylesAreDefined() { SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); element.addTags("Some Tag"); styles.addElementStyle(Tags.SOFTWARE_SYSTEM).background("#ff0000").color("#ffffff"); - styles.addElementStyle("Some Tag").color("#0000ff").stroke("#00ff00").shape(Shape.RoundedBox).width(123).height(456); + styles.addElementStyle("Some Tag").color("#0000ff").stroke("#00ff00").strokeWidth(2).shape(Shape.RoundedBox).width(123).height(456); ElementStyle style = styles.findElementStyle(element); assertEquals(Integer.valueOf(123), style.getWidth()); @@ -63,13 +124,14 @@ public void test_findElementStyle_ReturnsTheCorrectStyle_WhenStylesAreDefined() assertNull(style.getIcon()); assertEquals(Border.Solid, style.getBorder()); assertEquals("#00ff00", style.getStroke()); + assertEquals(2, style.getStrokeWidth()); assertEquals(Integer.valueOf(100), style.getOpacity()); assertEquals(true, style.getMetadata()); assertEquals(true, style.getDescription()); } @Test - public void test_findElementStyle_ReturnsTheCorrectStyleForAnElementInstance_WhenStylesAreDefined() { + void findElementStyle_ReturnsTheCorrectStyleForAnElementInstance_WhenStylesAreDefined() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name"); softwareSystem.addTags("Some Tag"); @@ -77,7 +139,7 @@ public void test_findElementStyle_ReturnsTheCorrectStyleForAnElementInstance_Whe SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); styles.addElementStyle(Tags.SOFTWARE_SYSTEM).background("#ff0000").color("#ffffff"); - styles.addElementStyle("Some Tag").color("#0000ff").stroke("#00ff00").shape(Shape.RoundedBox).width(123).height(456); + styles.addElementStyle("Some Tag").color("#0000ff").stroke("#00ff00").shape(Shape.RoundedBox).width(123).height(456).addProperty("name", "value"); ElementStyle style = styles.findElementStyle(softwareSystemInstance); assertEquals(Integer.valueOf(123), style.getWidth()); @@ -89,44 +151,31 @@ public void test_findElementStyle_ReturnsTheCorrectStyleForAnElementInstance_Whe assertNull(style.getIcon()); assertEquals(Border.Solid, style.getBorder()); assertEquals("#00ff00", style.getStroke()); + assertNull(style.getStrokeWidth()); assertEquals(Integer.valueOf(100), style.getOpacity()); assertEquals(true, style.getMetadata()); assertEquals(true, style.getDescription()); + assertEquals("value", style.getProperties().get("name")); } @Test - public void test_findElementStyle_ReturnsTheDefaultElementSize_WhenTheShapeIsABox() { - SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); - element.addTags("Some Tag"); - - styles.addElementStyle(Tags.SOFTWARE_SYSTEM).background("#ff0000").color("#ffffff"); - styles.addElementStyle("Some Tag").shape(Shape.Box); - - ElementStyle style = styles.findElementStyle(element); - assertEquals(Shape.Box, style.getShape()); - assertEquals(Integer.valueOf(450), style.getWidth()); - assertEquals(Integer.valueOf(300), style.getHeight()); - } - - @Test - public void test_findElementStyle_ReturnsTheDefaultElementSize_WhenTheShapeIsAPerson() { - SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); - element.addTags("Some Tag"); - - styles.addElementStyle(Tags.SOFTWARE_SYSTEM).background("#ff0000").color("#ffffff"); - styles.addElementStyle("Some Tag").shape(Shape.Person); - - ElementStyle style = styles.findElementStyle(element); - assertEquals(Shape.Person, style.getShape()); - assertEquals(Integer.valueOf(400), style.getWidth()); - assertEquals(Integer.valueOf(400), style.getHeight()); + void findRelationshipStyle_ReturnsTheDefaultStyle_WhenPassedNull_ForLightColorScheme() { + RelationshipStyle style = styles.findRelationshipStyle((Relationship) null); + assertEquals(Integer.valueOf(2), style.getThickness()); + assertEquals("#444444", style.getColor()); + assertTrue(style.getDashed()); + assertEquals(Routing.Direct, style.getRouting()); + assertEquals(Integer.valueOf(24), style.getFontSize()); + assertEquals(Integer.valueOf(200), style.getWidth()); + assertEquals(Integer.valueOf(50), style.getPosition()); + assertEquals(Integer.valueOf(100), style.getOpacity()); } @Test - public void test_findRelationshipStyle_ReturnsTheDefaultStyle_WhenPassedNull() { - RelationshipStyle style = styles.findRelationshipStyle((Relationship)null); + void findRelationshipStyle_ReturnsTheDefaultStyle_WhenPassedNull_ForDarkColorScheme() { + RelationshipStyle style = styles.findRelationshipStyle((Relationship) null, ColorScheme.Dark); assertEquals(Integer.valueOf(2), style.getThickness()); - assertEquals("#707070", style.getColor()); + assertEquals("#cccccc", style.getColor()); assertTrue(style.getDashed()); assertEquals(Routing.Direct, style.getRouting()); assertEquals(Integer.valueOf(24), style.getFontSize()); @@ -136,12 +185,12 @@ public void test_findRelationshipStyle_ReturnsTheDefaultStyle_WhenPassedNull() { } @Test - public void test_findRelationshipStyle_ReturnsTheDefaultStyle_WhenNoStylesAreDefined() { + void findRelationshipStyle_ReturnsTheDefaultStyle_WhenNoStylesAreDefined() { SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); Relationship relationship = element.uses(element, "Uses"); RelationshipStyle style = styles.findRelationshipStyle(relationship); assertEquals(Integer.valueOf(2), style.getThickness()); - assertEquals("#707070", style.getColor()); + assertEquals("#444444", style.getColor()); assertTrue(style.getDashed()); assertEquals(Routing.Direct, style.getRouting()); assertEquals(Integer.valueOf(24), style.getFontSize()); @@ -151,13 +200,13 @@ public void test_findRelationshipStyle_ReturnsTheDefaultStyle_WhenNoStylesAreDef } @Test - public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenStylesAreDefined() { + void findRelationshipStyle_ReturnsTheCorrectStyle_WhenStylesAreDefined() { SoftwareSystem element = model.addSoftwareSystem("Name", "Description"); Relationship relationship = element.uses(element, "Uses"); relationship.addTags("Some Tag"); styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); - styles.addRelationshipStyle("Some Tag").color("#0000ff"); + styles.addRelationshipStyle("Some Tag").color("#0000ff").addProperty("name", "value"); RelationshipStyle style = styles.findRelationshipStyle(relationship); assertEquals(Integer.valueOf(2), style.getThickness()); @@ -168,10 +217,11 @@ public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenStylesAreDefin assertEquals(Integer.valueOf(200), style.getWidth()); assertEquals(Integer.valueOf(50), style.getPosition()); assertEquals(Integer.valueOf(100), style.getOpacity()); + assertEquals("value", style.getProperties().get("name")); } @Test - public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinkedRelationship() { + void findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinkedRelationship() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); Container container1 = softwareSystem.addContainer("Container 1", "Description", "Technology"); Container container2 = softwareSystem.addContainer("Container 2", "Description", "Technology"); @@ -194,7 +244,7 @@ public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinked } @Test - public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinkedRelationshipBasedUponAnImpliedRelationship() { + void findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinkedRelationshipBasedUponAnImpliedRelationship() { model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); Container container1 = softwareSystem.addContainer("Container 1"); @@ -220,7 +270,7 @@ public void test_findRelationshipStyle_ReturnsTheCorrectStyle_WhenThereIsALinked } @Test - public void test_addElementStyle_ThrowsAnException_WhenATagIsNotSpecified() { + void addElementStyle_ThrowsAnException_WhenATagIsNotSpecified() { try { styles.addElementStyle(""); fail(); @@ -244,7 +294,7 @@ public void test_addElementStyle_ThrowsAnException_WhenATagIsNotSpecified() { } @Test - public void test_addElementStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { + void addElementStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { try { styles.addElementStyle(Tags.SOFTWARE_SYSTEM).color("#ff0000"); styles.addElementStyle(Tags.SOFTWARE_SYSTEM).color("#ff0000"); @@ -256,7 +306,28 @@ public void test_addElementStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTag } @Test - public void test_addElementStyle_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { + void addElementStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagAndColorSchemeExistsAlready() { + try { + styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Dark); + styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Dark); + + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("An element style for the tag \"Software System\" and color scheme Dark already exists.", iae.getMessage()); + } + } + + @Test + void addElementStyleByTag_WithDifferentColorSchemes() { + styles.addElementStyle(Tags.SOFTWARE_SYSTEM); + styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Dark); + styles.addElementStyle(Tags.SOFTWARE_SYSTEM, ColorScheme.Light); + + assertEquals(3, styles.getElements().size()); + } + + @Test + void addElementStyle_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { try { ElementStyle style = styles.addElementStyle(Tags.SOFTWARE_SYSTEM).color("#ff0000"); styles.add(style); @@ -268,7 +339,7 @@ public void test_addElementStyle_ThrowsAnException_WhenAStyleWithTheSameTagExist } @Test - public void test_addRelationshipStyle_ThrowsAnException_WhenATagIsNotSpecified() { + void addRelationshipStyle_ThrowsAnException_WhenATagIsNotSpecified() { try { styles.addRelationshipStyle(""); fail(); @@ -292,7 +363,7 @@ public void test_addRelationshipStyle_ThrowsAnException_WhenATagIsNotSpecified() } @Test - public void test_addRelationshipStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { + void addRelationshipStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { try { styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); @@ -304,7 +375,28 @@ public void test_addRelationshipStyleByTag_ThrowsAnException_WhenAStyleWithTheSa } @Test - public void test_addRelationshipStyle_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { + void addRelationshipStyleByTag_ThrowsAnException_WhenAStyleWithTheSameTagAndColorSchemeExistsAlready() { + try { + styles.addRelationshipStyle(Tags.RELATIONSHIP, ColorScheme.Light); + styles.addRelationshipStyle(Tags.RELATIONSHIP, ColorScheme.Light); + + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A relationship style for the tag \"Relationship\" and color scheme Light already exists.", iae.getMessage()); + } + } + + @Test + void addRelationshipStyleByTag_WithDifferentColorSchemes() { + styles.addRelationshipStyle(Tags.RELATIONSHIP); + styles.addRelationshipStyle(Tags.RELATIONSHIP, ColorScheme.Dark); + styles.addRelationshipStyle(Tags.RELATIONSHIP, ColorScheme.Light); + + assertEquals(3, styles.getRelationships().size()); + } + + @Test + void addRelationshipStyle_ThrowsAnException_WhenAStyleWithTheSameTagExistsAlready() { try { RelationshipStyle style = styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); styles.add(style); @@ -316,7 +408,7 @@ public void test_addRelationshipStyle_ThrowsAnException_WhenAStyleWithTheSameTag } @Test - public void test_clearElementStyles_RemovesAllElementStyles() { + void clearElementStyles_RemovesAllElementStyles() { styles.addElementStyle(Tags.SOFTWARE_SYSTEM).color("#ff0000"); assertEquals(1, styles.getElements().size()); @@ -325,7 +417,7 @@ public void test_clearElementStyles_RemovesAllElementStyles() { } @Test - public void test_clearRelationshipStyles_RemovesAllRelationshipStyles() { + void clearRelationshipStyles_RemovesAllRelationshipStyles() { styles.addRelationshipStyle(Tags.RELATIONSHIP).color("#ff0000"); assertEquals(1, styles.getRelationships().size()); @@ -333,4 +425,81 @@ public void test_clearRelationshipStyles_RemovesAllRelationshipStyles() { assertEquals(0, styles.getRelationships().size()); } + @Test + void getElementStyle_ThrowsAnException_WhenGivenNoTag() { + try { + styles.getElementStyle(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A tag must be specified.", iae.getMessage()); + } + + try { + styles.getElementStyle(" "); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A tag must be specified.", iae.getMessage()); + } + } + + @Test + void getElementStyle_ReturnsNull_WhenThereIsNoStyleForTheGivenTag() { + ElementStyle style = styles.getElementStyle("Tag"); + assertNull(style); + } + + @Test + void getElementStyle_ReturnsTheElementStyle_WhenThereIsAStyleForTheGivenTag() { + styles.addElementStyle("Tag").background("#ffffff"); + + ElementStyle style = styles.getElementStyle("Tag"); + assertEquals("#ffffff", style.getBackground()); + } + + @Test + void getRelationshipStyle_ThrowsAnException_WhenGivenNoTag() { + try { + styles.getRelationshipStyle(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A tag must be specified.", iae.getMessage()); + } + + try { + styles.getRelationshipStyle(" "); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A tag must be specified.", iae.getMessage()); + } + } + + @Test + void getRelationshipStyle_ReturnsNull_WhenThereIsNoStyleForTheGivenTag() { + RelationshipStyle style = styles.getRelationshipStyle("Tag"); + assertNull(style); + } + + @Test + void getRelationshipStyle_ReturnsTheRelationshipStyle_WhenThereIsAStyleForTheGivenTag() { + styles.addRelationshipStyle("Tag").color("#ffffff"); + + RelationshipStyle style = styles.getRelationshipStyle("Tag"); + assertEquals("#ffffff", style.getColor()); + } + + @Test + void inlineTheme() { + Theme theme = new Theme( + Set.of(new ElementStyle("Tag").background("#ff0000")), + Set.of(new RelationshipStyle("Tag").color("#00ff00")) + ); + + styles.addElementStyle("Tag").background("#ffffff"); + styles.addRelationshipStyle("Tag").color("#ffffff"); + styles.inlineTheme(theme); // this will override the existing styles + + assertEquals("#ff0000", styles.getElementStyle("Tag").getBackground()); + assertEquals("#00ff00", styles.getRelationshipStyle("Tag").getColor()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/SystemContextViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/SystemContextViewTests.java similarity index 74% rename from structurizr-core/test/unit/com/structurizr/view/SystemContextViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/SystemContextViewTests.java index 61517f64b..b35075f43 100644 --- a/structurizr-core/test/unit/com/structurizr/view/SystemContextViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/SystemContextViewTests.java @@ -2,25 +2,25 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class SystemContextViewTests extends AbstractWorkspaceTestBase { private SoftwareSystem softwareSystem; private SystemContextView view; - @Before + @BeforeEach public void setUp() { - softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + softwareSystem = model.addSoftwareSystem( "The System", "Description"); view = new SystemContextView(softwareSystem, "context", "Description"); } @Test - public void test_construction() { - assertEquals("The System - System Context", view.getName()); + void construction() { + assertEquals("System Context View: The System", view.getName()); assertEquals(1, view.getElements().size()); assertSame(view.getElements().iterator().next().getElement(), softwareSystem); assertSame(softwareSystem, view.getSoftwareSystem()); @@ -29,16 +29,16 @@ public void test_construction() { } @Test - public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { + void addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { assertEquals(1, view.getElements().size()); view.addAllSoftwareSystems(); assertEquals(1, view.getElements().size()); } @Test - public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); + void addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); view.addAllSoftwareSystems(); @@ -49,16 +49,16 @@ public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSo } @Test - public void test_addAllPeople_DoesNothing_WhenThereAreNoPeople() { + void addAllPeople_DoesNothing_WhenThereAreNoPeople() { assertEquals(1, view.getElements().size()); view.addAllPeople(); assertEquals(1, view.getElements().size()); } @Test - public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { + Person userA = model.addPerson( "User A", "Description"); + Person userB = model.addPerson( "User B", "Description"); view.addAllPeople(); @@ -69,18 +69,18 @@ public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { } @Test - public void test_addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { + void addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { assertEquals(1, view.getElements().size()); view.addAllElements(); assertEquals(1, view.getElements().size()); } @Test - public void test_addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSomeSoftwareSystemsAndPeopleInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); - Person userA = model.addPerson(Location.External, "User A", "Description"); - Person userB = model.addPerson(Location.External, "User B", "Description"); + void addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSomeSoftwareSystemsAndPeopleInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); + Person userA = model.addPerson( "User A", "Description"); + Person userB = model.addPerson( "User B", "Description"); view.addAllElements(); @@ -93,7 +93,7 @@ public void test_addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSome } @Test - public void test_addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecified() { + void addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecified() { try { view.addNearestNeighbours(null); fail(); @@ -103,7 +103,7 @@ public void test_addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecif } @Test - public void test_addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAPersonOrSoftwareSystemIsSpecified() { + void addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAPersonOrSoftwareSystemIsSpecified() { Container container = softwareSystem.addContainer("Container", "Description", "Technology"); try { view.addNearestNeighbours(container); @@ -114,14 +114,14 @@ public void test_addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAP } @Test - public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { + void addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { view.addNearestNeighbours(softwareSystem); assertEquals(1, view.getElements().size()); } @Test - public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { + void addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { SoftwareSystem softwareSystemA = model.addSoftwareSystem("A", "Description"); SoftwareSystem softwareSystemB = model.addSoftwareSystem("B", "Description"); Person userA = model.addPerson("User A", "Description"); @@ -170,9 +170,9 @@ public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNear } @Test - public void test_removeSoftwareSystem_ThrowsAnException_WhenPassedNull() { + void removeSoftwareSystem_ThrowsAnException_WhenPassedNull() { try { - view.remove((SoftwareSystem)null); + view.remove((SoftwareSystem) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -180,7 +180,7 @@ public void test_removeSoftwareSystem_ThrowsAnException_WhenPassedNull() { } @Test - public void test_removeSoftwareSystem_DoesNothing_WhenTheSoftwareSystemIsNotInTheView() { + void removeSoftwareSystem_DoesNothing_WhenTheSoftwareSystemIsNotInTheView() { SoftwareSystem anotherSoftwareSystem = model.addSoftwareSystem("Another software system", ""); assertEquals(1, view.getElements().size()); @@ -189,7 +189,7 @@ public void test_removeSoftwareSystem_DoesNothing_WhenTheSoftwareSystemIsNotInTh } @Test - public void test_removeSoftwareSystem_DoesNotRemoveTheSoftwareSystemInFocus() { + void removeSoftwareSystem_DoesNotRemoveTheSoftwareSystemInFocus() { try { view.remove(softwareSystem); fail(); @@ -199,7 +199,7 @@ public void test_removeSoftwareSystem_DoesNotRemoveTheSoftwareSystemInFocus() { } @Test - public void test_removeSoftwareSystem_RemovesTheSoftwareSystemAndRelationshipsFromTheView() { + void removeSoftwareSystem_RemovesTheSoftwareSystemAndRelationshipsFromTheView() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software system 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software system 2", ""); softwareSystem1.uses(softwareSystem2, "uses"); @@ -215,9 +215,9 @@ public void test_removeSoftwareSystem_RemovesTheSoftwareSystemAndRelationshipsFr } @Test - public void test_removePerson_ThrowsAnException_WhenPassedNull() { + void removePerson_ThrowsAnException_WhenPassedNull() { try { - view.remove((Person)null); + view.remove((Person) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -225,7 +225,7 @@ public void test_removePerson_ThrowsAnException_WhenPassedNull() { } @Test - public void test_removePerson_DoesNothing_WhenThePersonIsNotInTheView() { + void removePerson_DoesNothing_WhenThePersonIsNotInTheView() { Person person = model.addPerson("Person", ""); assertEquals(1, view.getElements().size()); @@ -234,7 +234,7 @@ public void test_removePerson_DoesNothing_WhenThePersonIsNotInTheView() { } @Test - public void test_removePerson_RemovesThePersonAndRelationshipsFromTheView() { + void removePerson_RemovesThePersonAndRelationshipsFromTheView() { Person person = model.addPerson("Person", ""); person.uses(softwareSystem, "uses"); softwareSystem.delivers(person, "delivers something to"); @@ -249,7 +249,7 @@ public void test_removePerson_RemovesThePersonAndRelationshipsFromTheView() { } @Test - public void test_addSoftwareSystemWithoutRelationships_DoesNotAddRelationships() { + void addSoftwareSystemWithoutRelationships_DoesNotAddRelationships() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software system 1", ""); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software system 2", ""); softwareSystem1.uses(softwareSystem2, "uses"); @@ -261,7 +261,7 @@ public void test_addSoftwareSystemWithoutRelationships_DoesNotAddRelationships() } @Test - public void test_addPersonWithoutRelationships_DoesNotAddRelationships() { + void addPersonWithoutRelationships_DoesNotAddRelationships() { Person user = model.addPerson("User", ""); SoftwareSystem softwareSystem = model.addSoftwareSystem("Software system 2", ""); user.uses(softwareSystem, "uses"); @@ -273,15 +273,7 @@ public void test_addPersonWithoutRelationships_DoesNotAddRelationships() { } @Test - public void test_isEnterpriseBoundaryVisible() { - assertTrue(view.isEnterpriseBoundaryVisible()); // default is true - - view.setEnterpriseBoundaryVisible(false); - assertFalse(view.isEnterpriseBoundaryVisible()); - } - - @Test - public void test_addDefaultElements() { + void addDefaultElements() { CustomElement element = model.addCustomElement("Custom"); Person user1 = model.addPerson("User 1"); Person user2 = model.addPerson("User 2"); @@ -313,4 +305,42 @@ public void test_addDefaultElements() { assertTrue(view.getElements().contains(new ElementView(softwareSystem2))); } + @Test + void addDefaultElements_WhenGreedyIsTrue() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + Relationship ab = a.uses(b, "Uses"); + Relationship ac = a.uses(c, "Uses"); + Relationship bc = b.uses(c, "Uses"); + + view = views.createSystemContextView(a, "key", "description"); + view.addDefaultElements(true); + + assertEquals(3, view.getElements().size()); + assertNotNull(view.getRelationshipView(ab)); + assertNotNull(view.getRelationshipView(ac)); + assertNotNull(view.getRelationshipView(bc)); + } + + @Test + void addDefaultElements_WhenGreedyIsFalse() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + Relationship ab = a.uses(b, "Uses"); + Relationship ac = a.uses(c, "Uses"); + Relationship bc = b.uses(c, "Uses"); + + view = views.createSystemContextView(a, "key", "description"); + view.addDefaultElements(false); + + assertEquals(3, view.getElements().size()); + assertNotNull(view.getRelationshipView(ab)); + assertNotNull(view.getRelationshipView(ac)); + assertNull(view.getRelationshipView(bc)); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/SystemLandscapeViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/SystemLandscapeViewTests.java similarity index 74% rename from structurizr-core/test/unit/com/structurizr/view/SystemLandscapeViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/SystemLandscapeViewTests.java index 833bda34d..481c1c77c 100644 --- a/structurizr-core/test/unit/com/structurizr/view/SystemLandscapeViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/SystemLandscapeViewTests.java @@ -2,58 +2,41 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.model.*; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class SystemLandscapeViewTests extends AbstractWorkspaceTestBase { private SystemLandscapeView view; - @Before + @BeforeEach public void setUp() { view = new SystemLandscapeView(model, "context", "Description"); } @Test - public void test_construction() { - assertEquals("System Landscape", view.getName()); + void construction() { assertEquals(0, view.getElements().size()); assertSame(model, view.getModel()); } @Test - public void test_getName_WhenNoEnterpriseIsSpecified() { - assertEquals("System Landscape", view.getName()); + void getName() { + assertEquals("System Landscape View", view.getName()); } @Test - public void test_getName_WhenAnEnterpriseIsSpecified() { - model.setEnterprise(new Enterprise("Widgets Limited")); - assertEquals("System Landscape for Widgets Limited", view.getName()); - } - - @Test(expected = IllegalArgumentException.class) - public void test_getName_WhenAnEmptyEnterpriseNameIsSpecified() { - model.setEnterprise(new Enterprise("")); - } - - @Test(expected = IllegalArgumentException.class) - public void test_getName_WhenANullEnterpriseNameIsSpecified() { - model.setEnterprise(new Enterprise(null)); - } - - @Test - public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { + void addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystems() { view.addAllSoftwareSystems(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.External, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.External, "System B", "Description"); + void addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); view.addAllSoftwareSystems(); @@ -63,13 +46,13 @@ public void test_addAllSoftwareSystems_AddsAllSoftwareSystems_WhenThereAreSomeSo } @Test - public void test_addAllPeople_DoesNothing_WhenThereAreNoPeople() { + void addAllPeople_DoesNothing_WhenThereAreNoPeople() { view.addAllPeople(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { + void addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { Person userA = model.addPerson("User A", "Description"); Person userB = model.addPerson("User B", "Description"); @@ -81,13 +64,13 @@ public void test_addAllPeople_AddsAllPeople_WhenThereAreSomePeopleInTheModel() { } @Test - public void test_addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { + void addAllElements_DoesNothing_WhenThereAreNoSoftwareSystemsOrPeople() { view.addAllElements(); assertEquals(0, view.getElements().size()); } @Test - public void test_addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSomeSoftwareSystemsAndPeopleInTheModel() { + void addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSomeSoftwareSystemsAndPeopleInTheModel() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); Person person = model.addPerson("Person", "Description"); @@ -99,15 +82,7 @@ public void test_addAllElements_AddsAllSoftwareSystemsAndPeople_WhenThereAreSome } @Test - public void test_isEnterpriseBoundaryVisible() { - assertTrue(view.isEnterpriseBoundaryVisible()); // default is true - - view.setEnterpriseBoundaryVisible(false); - assertFalse(view.isEnterpriseBoundaryVisible()); - } - - @Test - public void test_addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecified() { + void addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecified() { try { view.addNearestNeighbours(null); fail(); @@ -117,7 +92,7 @@ public void test_addNearestNeighbours_ThrowsAnException_WhenANullElementIsSpecif } @Test - public void test_addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAPersonOrSoftwareSystemIsSpecified() { + void addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAPersonOrSoftwareSystemIsSpecified() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); try { @@ -129,7 +104,7 @@ public void test_addNearestNeighbours_ThrowsAnException_WhenAnElementThatIsNotAP } @Test - public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { + void addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); view.addNearestNeighbours(softwareSystem); @@ -137,7 +112,7 @@ public void test_addNearestNeighbours_DoesNothing_WhenThereAreNoNeighbours() { } @Test - public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { + void addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNearestNeighbours() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); @@ -187,7 +162,7 @@ public void test_addNearestNeighbours_AddsNearestNeighbours_WhenThereAreSomeNear } @Test - public void test_addDefaultElements() { + void addDefaultElements() { CustomElement element = model.addCustomElement("Custom"); Person user = model.addPerson("User"); SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); diff --git a/structurizr-core/test/unit/com/structurizr/view/TerminologyTests.java b/structurizr-core/src/test/java/com/structurizr/view/TerminologyTests.java similarity index 90% rename from structurizr-core/test/unit/com/structurizr/view/TerminologyTests.java rename to structurizr-core/src/test/java/com/structurizr/view/TerminologyTests.java index 7c6bb38ea..316a23bb7 100644 --- a/structurizr-core/test/unit/com/structurizr/view/TerminologyTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/TerminologyTests.java @@ -2,16 +2,17 @@ import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; public class TerminologyTests { @Test - public void test_findTerminology() { + void findTerminology() { Workspace workspace = new Workspace("Name", "Description"); Terminology terminology = workspace.getViews().getConfiguration().getTerminology(); + CustomElement element = workspace.getModel().addCustomElement("Element", "Hardware Device", "Description"); Person person = workspace.getModel().addPerson("Name"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); Container container = softwareSystem.addContainer("Container"); @@ -22,6 +23,7 @@ public void test_findTerminology() { ContainerInstance containerInstance = deploymentNode.add(container); Relationship relationship = person.uses(softwareSystem, "Uses"); + assertEquals("Hardware Device", terminology.findTerminology(element)); assertEquals("Person", terminology.findTerminology(person)); assertEquals("Software System", terminology.findTerminology(softwareSystem)); assertEquals("Container", terminology.findTerminology(container)); diff --git a/structurizr-core/src/test/java/com/structurizr/view/VertexTests.java b/structurizr-core/src/test/java/com/structurizr/view/VertexTests.java new file mode 100644 index 000000000..f9377d309 --- /dev/null +++ b/structurizr-core/src/test/java/com/structurizr/view/VertexTests.java @@ -0,0 +1,25 @@ +package com.structurizr.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +public class VertexTests { + + @Test + void equals() { + Vertex vertex1 = new Vertex(123, 456); + Vertex vertex2 = new Vertex(123, 456); + Vertex vertex3 = new Vertex(456, 123); + + assertNotEquals(vertex1, null); + assertNotEquals(vertex1, "hello world"); + + assertEquals(vertex1, vertex1); + assertEquals(vertex1, vertex2); + assertEquals(vertex2, vertex1); + assertNotEquals(vertex2, vertex3); + } + +} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/ViewSetTests.java b/structurizr-core/src/test/java/com/structurizr/view/ViewSetTests.java similarity index 65% rename from structurizr-core/test/unit/com/structurizr/view/ViewSetTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ViewSetTests.java index 086d5b280..4bec2222f 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ViewSetTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ViewSetTests.java @@ -2,12 +2,12 @@ import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Collections; import java.util.HashSet; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ViewSetTests { @@ -28,27 +28,21 @@ private Workspace createWorkspace() { } @Test - public void test_createCustomView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - new Workspace("", "").getViews().createCustomView(null, "Title", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createCustomView_GeneratesAKey_WhenANullKeyIsSpecified() { + View view = new Workspace("", "").getViews().createCustomView(null, "Title", "Description"); + assertEquals("Custom-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createCustomView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - new Workspace("", "").getViews().createCustomView(" ", "Title", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createCustomView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + View view = new Workspace("", "").getViews().createCustomView(" ", "Title", "Description"); + assertEquals("Custom-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createCustomView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { + void createCustomView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); workspace.getViews().createCustomView("key", "Title", "Description"); @@ -60,14 +54,14 @@ public void test_createCustomView_ThrowsAnException_WhenADuplicateKeyIsSpecified } @Test - public void test_createCustomView_DoesNotThrowAnException_WhenUniqueKeysAreSpecified() { + void createCustomView_DoesNotThrowAnException_WhenUniqueKeysAreSpecified() { Workspace workspace = new Workspace("Name", "Description"); workspace.getViews().createCustomView("key1", "Title", "Description"); workspace.getViews().createCustomView("key2", "Title", "Description"); } @Test - public void test_createCustomView() { + void createCustomView() { Workspace workspace = new Workspace("Name", "Description"); CustomView customView = workspace.getViews().createCustomView("key", "Title", "Description"); assertEquals("key", customView.getKey()); @@ -78,27 +72,21 @@ public void test_createCustomView() { } @Test - public void test_createSystemLandscapeView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - new Workspace("", "").getViews().createSystemLandscapeView(null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createSystemLandscapeView_GeneratesAKey_WhenANullKeyIsSpecified() { + SystemLandscapeView view = new Workspace("", "").getViews().createSystemLandscapeView(null, "Description"); + assertEquals("SystemLandscape-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createSystemLandscapeView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - new Workspace("", "").getViews().createSystemLandscapeView(" ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createSystemLandscapeView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + SystemLandscapeView view = new Workspace("", "").getViews().createSystemLandscapeView(" ", "Description"); + assertEquals("SystemLandscape-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createSystemLandscapeView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { + void createSystemLandscapeView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); workspace.getViews().createSystemLandscapeView("key", "Description"); @@ -110,14 +98,14 @@ public void test_createSystemLandscapeView_ThrowsAnException_WhenADuplicateKeyIs } @Test - public void test_createSystemLandscapeView_DoesNotThrowAnException_WhenUniqueKeysAreSpecified() { + void createSystemLandscapeView_DoesNotThrowAnException_WhenUniqueKeysAreSpecified() { Workspace workspace = new Workspace("Name", "Description"); workspace.getViews().createSystemLandscapeView("key1", "Description"); workspace.getViews().createSystemLandscapeView("key2", "Description"); } @Test - public void test_createSystemLandscapeView() { + void createSystemLandscapeView() { Workspace workspace = new Workspace("Name", "Description"); SystemLandscapeView systemLandscapeView = workspace.getViews().createSystemLandscapeView("key", "Description"); assertEquals("key", systemLandscapeView.getKey()); @@ -125,7 +113,7 @@ public void test_createSystemLandscapeView() { } @Test - public void test_createSystemContextView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { + void createSystemContextView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { try { new Workspace("", "").getViews().createSystemContextView(null, null, "Description"); fail(); @@ -135,31 +123,25 @@ public void test_createSystemContextView_ThrowsAnException_WhenASoftwareSystemIs } @Test - public void test_createSystemContextView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createSystemContextView(softwareSystem, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createSystemContextView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, null, "Description"); + assertEquals("SystemContext-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createSystemContextView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createSystemContextView(softwareSystem, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createSystemContextView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + SystemContextView view = workspace.getViews().createSystemContextView(softwareSystem, " ", "Description"); + assertEquals("SystemContext-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createSystemContextView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { + void createSystemContextView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); @@ -172,7 +154,7 @@ public void test_createSystemContextView_ThrowsAnException_WhenADuplicateKeyIsSp } @Test - public void test_createSystemContextView() { + void createSystemContextView() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); SystemContextView systemContextView = workspace.getViews().createSystemContextView(softwareSystem, "key", "Description"); @@ -182,7 +164,7 @@ public void test_createSystemContextView() { } @Test - public void test_createContainerView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { + void createContainerView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { try { new Workspace("", "").getViews().createContainerView(null, null, "Description"); fail(); @@ -192,31 +174,25 @@ public void test_createContainerView_ThrowsAnException_WhenASoftwareSystemIsSpec } @Test - public void test_createContainerView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createContainerView(softwareSystem, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createContainerView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + ContainerView view = workspace.getViews().createContainerView(softwareSystem, null, "Description"); + assertEquals("Container-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createContainerView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createContainerView(softwareSystem, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createContainerView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + ContainerView view = workspace.getViews().createContainerView(softwareSystem, " ", "Description"); + assertEquals("Container-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createContainerView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { + void createContainerView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); @@ -229,7 +205,7 @@ public void test_createContainerView_ThrowsAnException_WhenADuplicateKeyIsSpecif } @Test - public void test_createContainerView() { + void createContainerView() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); ContainerView containerView = workspace.getViews().createContainerView(softwareSystem, "key", "Description"); @@ -239,7 +215,7 @@ public void test_createContainerView() { } @Test - public void test_createComponentView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { + void createComponentView_ThrowsAnException_WhenASoftwareSystemIsSpecified() { try { new Workspace("", "").getViews().createComponentView(null, null, "Description"); fail(); @@ -249,33 +225,27 @@ public void test_createComponentView_ThrowsAnException_WhenASoftwareSystemIsSpec } @Test - public void test_createComponentView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - Container container = softwareSystem.addContainer("Container", "Description", "Technology"); - workspace.getViews().createComponentView(container, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createComponentView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ComponentView view = workspace.getViews().createComponentView(container, null, "Description"); + assertEquals("Component-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createComponentView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - Container container = softwareSystem.addContainer("Container", "Description", "Technology"); - workspace.getViews().createComponentView(container, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createComponentView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ComponentView view = workspace.getViews().createComponentView(container, " ", "Description"); + assertEquals("Component-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createComponentView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { + void createComponentView_ThrowsAnException_WhenADuplicateKeyIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); @@ -289,7 +259,7 @@ public void test_createComponentView_ThrowsAnException_WhenADuplicateKeyIsSpecif } @Test - public void test_createComponentView() { + void createComponentView() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); @@ -299,31 +269,25 @@ public void test_createComponentView() { assertSame(softwareSystem, componentView.getSoftwareSystem()); assertSame(container, componentView.getContainer()); } - + @Test - public void test_createDynamicView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - workspace.getViews().createDynamicView(null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + DynamicView view = workspace.getViews().createDynamicView(null); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - workspace.getViews().createDynamicView(" ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + DynamicView view = workspace.getViews().createDynamicView(" ", "Description"); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicView() { + void createDynamicView() { Workspace workspace = new Workspace("Name", "Description"); DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); @@ -334,9 +298,9 @@ public void test_createDynamicView() { } @Test - public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { + void createDynamicViewForASoftwareSystem_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { try { - new Workspace("", "").getViews().createDynamicView((SoftwareSystem)null, "key", "Description"); + new Workspace("", "").getViews().createDynamicView((SoftwareSystem) null, "key", "Description"); fail(); } catch (IllegalArgumentException iae) { assertEquals("A software system must be specified.", iae.getMessage()); @@ -344,31 +308,25 @@ public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenANull } @Test - public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createDynamicView(softwareSystem, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicViewForASoftwareSystem_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + DynamicView view = workspace.getViews().createDynamicView(softwareSystem, null, "Description"); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createDynamicView(softwareSystem, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicViewForASoftwareSystem_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + DynamicView view = workspace.getViews().createDynamicView(softwareSystem, " ", "Description"); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenADuplicateKeyIsUsed() { + void createDynamicViewForASoftwareSystem_ThrowsAnException_WhenADuplicateKeyIsUsed() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); @@ -382,7 +340,7 @@ public void test_createDynamicViewForASoftwareSystem_ThrowsAnException_WhenADupl } @Test - public void test_createDynamicViewForSoftwareSystem() { + void createDynamicViewForSoftwareSystem() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); @@ -394,9 +352,9 @@ public void test_createDynamicViewForSoftwareSystem() { } @Test - public void test_createDynamicViewForAContainer_ThrowsAnException_WhenANullContainerIsSpecified() { + void createDynamicViewForAContainer_ThrowsAnException_WhenANullContainerIsSpecified() { try { - new Workspace("", "").getViews().createDynamicView((Container)null, "key", "Description"); + new Workspace("", "").getViews().createDynamicView((Container) null, "key", "Description"); fail(); } catch (IllegalArgumentException iae) { assertEquals("A container must be specified.", iae.getMessage()); @@ -404,33 +362,27 @@ public void test_createDynamicViewForAContainer_ThrowsAnException_WhenANullConta } @Test - public void test_createDynamicViewForAContainer_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - Container container = softwareSystem.addContainer("Container", "Description", "Technology"); - workspace.getViews().createDynamicView(container, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicViewForAContainer_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DynamicView view = workspace.getViews().createDynamicView(container, null, "Description"); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicViewForAContainer_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - Container container = softwareSystem.addContainer("Container", "Description", "Technology"); - workspace.getViews().createDynamicView(container, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDynamicViewForAContainer_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DynamicView view = workspace.getViews().createDynamicView(container, " ", "Description"); + assertEquals("Dynamic-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDynamicViewForContainer() { + void createDynamicViewForContainer() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); Container container = softwareSystem.addContainer("Container", "Description", "Technology"); @@ -443,29 +395,23 @@ public void test_createDynamicViewForContainer() { } @Test - public void test_createDeploymentView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - workspace.getViews().createDeploymentView(null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDeploymentView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentView view = workspace.getViews().createDeploymentView(null); + assertEquals("Deployment-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDeploymentView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - workspace.getViews().createDeploymentView(" ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDeploymentView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentView view = workspace.getViews().createDeploymentView(" ", "Description"); + assertEquals("Deployment-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDeploymentView_ThrowsAnException_WhenADuplicateKeyIsUsed() { + void createDeploymentView_ThrowsAnException_WhenADuplicateKeyIsUsed() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); @@ -479,7 +425,7 @@ public void test_createDeploymentView_ThrowsAnException_WhenADuplicateKeyIsUsed( } @Test - public void test_createDeploymentView() { + void createDeploymentView() { Workspace workspace = new Workspace("Name", "Description"); DeploymentView deploymentView = workspace.getViews().createDeploymentView("key", "Description"); @@ -489,9 +435,9 @@ public void test_createDeploymentView() { } @Test - public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { + void createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenANullSoftwareSystemIsSpecified() { try { - new Workspace("", "").getViews().createDeploymentView((SoftwareSystem)null, "key", "Description"); + new Workspace("", "").getViews().createDeploymentView((SoftwareSystem) null, "key", "Description"); fail(); } catch (IllegalArgumentException iae) { assertEquals("A software system must be specified.", iae.getMessage()); @@ -499,31 +445,25 @@ public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenAN } @Test - public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createDeploymentView(softwareSystem, null, "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDeploymentViewForASoftwareSystem_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + DeploymentView view = workspace.getViews().createDeploymentView(softwareSystem, null, "Description"); + assertEquals("Deployment-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); - workspace.getViews().createDeploymentView(softwareSystem, " ", "Description"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createDeploymentViewForASoftwareSystem_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", "Description"); + DeploymentView view = workspace.getViews().createDeploymentView(softwareSystem, " ", "Description"); + assertEquals("Deployment-001", view.getKey()); + assertTrue(view.isGeneratedKey()); } @Test - public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenADuplicateKeyIsUsed() { + void createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenADuplicateKeyIsUsed() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); @@ -537,7 +477,7 @@ public void test_createDeploymentViewForASoftwareSystem_ThrowsAnException_WhenAD } @Test - public void test_createDeploymentViewForSoftwareSystem() { + void createDeploymentViewForSoftwareSystem() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); @@ -548,10 +488,10 @@ public void test_createDeploymentViewForSoftwareSystem() { } @Test - public void test_createFilteredView_ThrowsAnException_WhenANullViewIsSpecified() { + void createFilteredView_ThrowsAnException_WhenANullViewIsSpecified() { try { Workspace workspace = new Workspace("Name", "Description"); - workspace.getViews().createFilteredView(null, "key", "Description", FilterMode.Include, "tag1", "tag2"); + workspace.getViews().createFilteredView((SystemLandscapeView)null, "key", "Description", FilterMode.Include, "tag1", "tag2"); fail(); } catch (IllegalArgumentException iae) { assertEquals("A view must be specified.", iae.getMessage()); @@ -559,31 +499,25 @@ public void test_createFilteredView_ThrowsAnException_WhenANullViewIsSpecified() } @Test - public void test_createFilteredView_ThrowsAnException_WhenANullKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); - workspace.getViews().createFilteredView(view, null, "Description", FilterMode.Include, "tag1", "tag2"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createFilteredView_GeneratesAKey_WhenANullKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); + FilteredView filteredView = workspace.getViews().createFilteredView(view, null, "Description", FilterMode.Include, "tag1", "tag2"); + assertEquals("Filtered-001", filteredView.getKey()); + assertTrue(filteredView.isGeneratedKey()); } @Test - public void test_createFilteredView_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - try { - Workspace workspace = new Workspace("Name", "Description"); - SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); - workspace.getViews().createFilteredView(view, " ", "Description", FilterMode.Include, "tag1", "tag2"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A key must be specified.", iae.getMessage()); - } + void createFilteredView_GeneratesAKey_WhenAnEmptyKeyIsSpecified() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); + FilteredView filteredView = workspace.getViews().createFilteredView(view, " ", "Description", FilterMode.Include, "tag1", "tag2"); + assertEquals("Filtered-001", filteredView.getKey()); + assertTrue(filteredView.isGeneratedKey()); } @Test - public void test_createFilteredView_ThrowsAnException_WhenADuplicateKeyIsUsed() { + void createFilteredView_ThrowsAnException_WhenADuplicateKeyIsUsed() { Workspace workspace = new Workspace("Name", "Description"); SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); workspace.getViews().createFilteredView(view, "filtered", "Description", FilterMode.Include, "tag1", "tag2"); @@ -596,7 +530,7 @@ public void test_createFilteredView_ThrowsAnException_WhenADuplicateKeyIsUsed() } @Test - public void test_createFilteredView() { + void createFilteredView_OnStaticView() { Workspace workspace = new Workspace("Name", "Description"); SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("systemLandscape", "Description"); FilteredView filteredView = workspace.getViews().createFilteredView(view, "key", "Description", FilterMode.Include, "tag1", "tag2"); @@ -610,7 +544,21 @@ public void test_createFilteredView() { } @Test - public void test_copyLayoutInformationFrom_WhenAViewKeyIsNotSetButTheViewTitlesMatch() { + void createFilteredView_OnDeploymentView() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentView view = workspace.getViews().createDeploymentView("deployment"); + FilteredView filteredView = workspace.getViews().createFilteredView(view, "key", "Description", FilterMode.Include, "tag1", "tag2"); + + assertEquals("key", filteredView.getKey()); + assertEquals("Description", filteredView.getDescription()); + assertEquals(FilterMode.Include, filteredView.getMode()); + assertEquals(2, filteredView.getTags().size()); + assertTrue(filteredView.getTags().contains("tag1")); + assertTrue(filteredView.getTags().contains("tag2")); + } + + @Test + void copyLayoutInformationFrom_WhenAViewKeyIsNotSetButTheViewTitlesMatch() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemContextView view1 = workspace1.getViews().createSystemContextView(softwareSystem1, "context", "Description"); @@ -631,7 +579,7 @@ public void test_copyLayoutInformationFrom_WhenAViewKeyIsNotSetButTheViewTitlesM } @Test - public void test_copyLayoutInformationFrom_WhenAViewKeyHasBeenIntroduced() { + void copyLayoutInformationFrom_WhenAViewKeyHasBeenIntroduced() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemContextView view1 = workspace1.getViews().createSystemContextView(softwareSystem1, "context", "Description"); @@ -651,7 +599,7 @@ public void test_copyLayoutInformationFrom_WhenAViewKeyHasBeenIntroduced() { } @Test - public void test_copyLayoutInformationFrom_IgnoresThePaperSize_WhenThePaperSizeIsSet() { + void copyLayoutInformationFrom_IgnoresThePaperSize_WhenThePaperSizeIsSet() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemContextView view1 = workspace1.getViews().createSystemContextView(softwareSystem1, "context", "Description"); @@ -667,7 +615,7 @@ public void test_copyLayoutInformationFrom_IgnoresThePaperSize_WhenThePaperSizeI } @Test - public void test_copyLayoutInformationFrom_CopiesThePaperSize_WhenThePaperSizeIsNotSet() { + void copyLayoutInformationFrom_CopiesThePaperSize_WhenThePaperSizeIsNotSet() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemContextView view1 = workspace1.getViews().createSystemContextView(softwareSystem1, "context", "Description"); @@ -682,7 +630,7 @@ public void test_copyLayoutInformationFrom_CopiesThePaperSize_WhenThePaperSizeIs } @Test - public void test_copyLayoutInformationFrom_WhenTheSystemLandscapeViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheSystemLandscapeViewKeysMatch() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemLandscapeView view1 = workspace1.getViews().createSystemLandscapeView("landscape", "Description"); @@ -701,7 +649,7 @@ public void test_copyLayoutInformationFrom_WhenTheSystemLandscapeViewKeysMatch() } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemLandscapeViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemLandscapeViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -715,7 +663,7 @@ public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemLan } @Test - public void test_copyLayoutInformationFrom_WhenTheSystemContextViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheSystemContextViewKeysMatch() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemContextView view1 = workspace1.getViews().createSystemContextView(softwareSystem1, "context", "Description"); @@ -734,7 +682,7 @@ public void test_copyLayoutInformationFrom_WhenTheSystemContextViewKeysMatch() { } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemContextViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemContextViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -748,7 +696,7 @@ public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoSystemCon } @Test - public void test_copyLayoutInformationFrom_WhenTheContainerViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheContainerViewKeysMatch() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); ContainerView view1 = workspace1.getViews().createContainerView(softwareSystem1, "containers", "Description"); @@ -767,7 +715,7 @@ public void test_copyLayoutInformationFrom_WhenTheContainerViewKeysMatch() { } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoContainerViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoContainerViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -781,7 +729,7 @@ public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoContainer } @Test - public void test_copyLayoutInformationFrom_WhenTheComponentViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheComponentViewKeysMatch() { Workspace workspace1 = createWorkspace(); Container container1 = workspace1.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("Container"); ComponentView view1 = workspace1.getViews().createComponentView(container1, "containers", "Description"); @@ -800,7 +748,7 @@ public void test_copyLayoutInformationFrom_WhenTheComponentViewKeysMatch() { } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoComponentViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoComponentViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -814,7 +762,7 @@ public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoComponent } @Test - public void test_copyLayoutInformationFrom_WhenTheDynamicViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheDynamicViewKeysMatch() { Workspace workspace1 = createWorkspace(); Person person1 = workspace1.getModel().getPersonWithName("Person"); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); @@ -835,7 +783,7 @@ public void test_copyLayoutInformationFrom_WhenTheDynamicViewKeysMatch() { } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoDynamicViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoDynamicViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -850,7 +798,7 @@ public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoDynamicVi } @Test - public void test_copyLayoutInformationFrom_WhenTheDeploymentViewKeysMatch() { + void copyLayoutInformationFrom_WhenTheDeploymentViewKeysMatch() { Workspace workspace1 = createWorkspace(); DeploymentNode deploymentNode1 = workspace1.getModel().getDeploymentNodeWithName("Deployment Node"); DeploymentView view1 = workspace1.getViews().createDeploymentView("key", "Description"); @@ -871,7 +819,7 @@ public void test_copyLayoutInformationFrom_WhenTheDeploymentViewKeysMatch() { } @Test - public void test_copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoDeploymentViewToCopyInformationFrom() { + void copyLayoutInformationFrom_DoesNotDoAnythingIfThereIsNoDeploymentViewToCopyInformationFrom() { Workspace workspace1 = createWorkspace(); Workspace workspace2 = createWorkspace(); @@ -910,7 +858,7 @@ private HashSet relationshipViewsFor(Relationship... relations } @Test - public void test_hydrate() { + void hydrate() { Workspace workspace = new Workspace("Name", "Description"); Model model = workspace.getModel(); ViewSet views = workspace.getViews(); @@ -1004,7 +952,7 @@ public void test_hydrate() { } @Test - public void test_setEnterpriseContextViews_IsSupportedForOlderWorkspaces() { + void setEnterpriseContextViews_IsSupportedForOlderWorkspaces() { ViewSet views = new Workspace("", "").getViews(); SystemLandscapeView systemLandscapeView = views.createSystemLandscapeView("key", "Description"); views.setEnterpriseContextViews(Collections.singleton(systemLandscapeView)); @@ -1013,7 +961,7 @@ public void test_setEnterpriseContextViews_IsSupportedForOlderWorkspaces() { } @Test - public void test_createDefaultViews() { + void createDefaultViews() { Workspace workspace = new Workspace("Name", "Description"); Model model = workspace.getModel(); ViewSet views = workspace.getViews(); @@ -1033,19 +981,19 @@ public void test_createDefaultViews() { views.createDefaultViews(); assertEquals(1, views.getSystemLandscapeViews().size()); - assertEquals("SystemLandscape", views.getSystemLandscapeViews().iterator().next().getKey()); + assertEquals("SystemLandscape-001", views.getSystemLandscapeViews().iterator().next().getKey()); assertEquals(2, views.getSystemContextViews().size()); - assertSame(ss1, views.getSystemContextViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-SystemContext")).findFirst().get().getSoftwareSystem()); - assertSame(ss2, views.getSystemContextViews().stream().filter(v -> v.getKey().equals("SoftwareSystem2-SystemContext")).findFirst().get().getSoftwareSystem()); + assertSame(ss1, views.getSystemContextViews().stream().filter(v -> v.getKey().equals("SystemContext-001")).findFirst().get().getSoftwareSystem()); + assertSame(ss2, views.getSystemContextViews().stream().filter(v -> v.getKey().equals("SystemContext-002")).findFirst().get().getSoftwareSystem()); assertEquals(2, views.getContainerViews().size()); - assertSame(ss1, views.getContainerViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Container")).findFirst().get().getSoftwareSystem()); - assertSame(ss2, views.getContainerViews().stream().filter(v -> v.getKey().equals("SoftwareSystem2-Container")).findFirst().get().getSoftwareSystem()); + assertSame(ss1, views.getContainerViews().stream().filter(v -> v.getKey().equals("Container-001")).findFirst().get().getSoftwareSystem()); + assertSame(ss2, views.getContainerViews().stream().filter(v -> v.getKey().equals("Container-002")).findFirst().get().getSoftwareSystem()); assertEquals(2, views.getComponentViews().size()); - assertSame(c1, views.getComponentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Container1-Component")).findFirst().get().getContainer()); - assertSame(c2, views.getComponentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem2-Container2-Component")).findFirst().get().getContainer()); + assertSame(c1, views.getComponentViews().stream().filter(v -> v.getKey().equals("Component-001")).findFirst().get().getContainer()); + assertSame(c2, views.getComponentViews().stream().filter(v -> v.getKey().equals("Component-002")).findFirst().get().getContainer()); assertEquals(0, views.getDynamicViews().size()); assertEquals(0, views.getDeploymentViews().size()); @@ -1056,7 +1004,7 @@ public void test_createDefaultViews() { views.createDefaultViews(); assertEquals(1, views.getDeploymentViews().size()); - assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Live-Deployment")).findFirst().get().getEnvironment()); + assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-001")).findFirst().get().getEnvironment()); dev.add(ss1); liveEc2.add(c1); @@ -1066,16 +1014,16 @@ public void test_createDefaultViews() { views.createDefaultViews(); assertEquals(3, views.getDeploymentViews().size()); - assertSame(ss1, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Development-Deployment")).findFirst().get().getSoftwareSystem()); - assertSame("Development", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Development-Deployment")).findFirst().get().getEnvironment()); - assertSame(ss1, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Live-Deployment")).findFirst().get().getSoftwareSystem()); - assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem1-Live-Deployment")).findFirst().get().getEnvironment()); - assertSame(ss2, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem2-Live-Deployment")).findFirst().get().getSoftwareSystem()); - assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("SoftwareSystem2-Live-Deployment")).findFirst().get().getEnvironment()); + assertSame(ss1, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-001")).findFirst().get().getSoftwareSystem()); + assertSame("Development", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-001")).findFirst().get().getEnvironment()); + assertSame(ss1, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-002")).findFirst().get().getSoftwareSystem()); + assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-002")).findFirst().get().getEnvironment()); + assertSame(ss2, views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-003")).findFirst().get().getSoftwareSystem()); + assertSame("Live", views.getDeploymentViews().stream().filter(v -> v.getKey().equals("Deployment-003")).findFirst().get().getEnvironment()); } @Test - public void test_copyLayoutInformationFrom_DoesNothing_WhenMergeFromRemoteIsSetToFalse() { + void copyLayoutInformationFrom_DoesNothing_WhenMergeFromRemoteIsSetToFalse() { Workspace workspace1 = createWorkspace(); SoftwareSystem softwareSystem1 = workspace1.getModel().getSoftwareSystemWithName("Software System"); SystemLandscapeView view1 = workspace1.getViews().createSystemLandscapeView("landscape", "Description"); @@ -1095,7 +1043,7 @@ public void test_copyLayoutInformationFrom_DoesNothing_WhenMergeFromRemoteIsSetT } @Test - public void test_view_ordering() { + void view_ordering() { Workspace workspace = new Workspace("Name", "Description"); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); Container container = softwareSystem.addContainer("Container"); @@ -1122,4 +1070,161 @@ public void test_view_ordering() { assertEquals(9, systemLandscapeView2.getOrder()); } + @Test + public void createDefaultViews_ForSoftwareSystemsWithNamesUsingUTF8Characters() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem ss1 = workspace.getModel().addSoftwareSystem("English is fine"); + SoftwareSystem ss2 = workspace.getModel().addSoftwareSystem("Ðо люди говорÑÑ‚ и на других Ñзыках"); + SoftwareSystem ss3 = workspace.getModel().addSoftwareSystem("英語ã ã‘ãŒè¨€èªžã§ã¯ãªã„"); + + workspace.getViews().createDefaultViews(); + + assertEquals(4, workspace.getViews().getViews().size()); + + assertEquals(1, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals("SystemLandscape-001", workspace.getViews().getSystemLandscapeViews().iterator().next().getKey()); + + assertEquals(3, workspace.getViews().getSystemContextViews().size()); + assertSame(ss1, workspace.getViews().getSystemContextViews().stream().filter(v -> v.getKey().equals("SystemContext-001")).findFirst().get().getSoftwareSystem()); + assertSame(ss2, workspace.getViews().getSystemContextViews().stream().filter(v -> v.getKey().equals("SystemContext-002")).findFirst().get().getSoftwareSystem()); + assertSame(ss3, workspace.getViews().getSystemContextViews().stream().filter(v -> v.getKey().equals("SystemContext-003")).findFirst().get().getSoftwareSystem()); + } + + @Test + void removeViewWithKey_ThrowsAndException_WhenNoKeyIsSpecified() { + try { + new Workspace("Name").getViews().removeViewWithKey(null); + fail(); + } catch (Exception e) { + assertEquals("A view key must be specified.", e.getMessage()); + } + + try { + new Workspace("Name").getViews().removeViewWithKey(""); + fail(); + } catch (Exception e) { + assertEquals("A view key must be specified.", e.getMessage()); + } + } + + @Test + void removeViewWithKey_ThrowsAndException_WhenNoViewExists() { + try { + new Workspace("Name").getViews().removeViewWithKey("key"); + fail(); + } catch (Exception e) { + assertEquals("A view with key \"key\" does not exist.", e.getMessage()); + } + } + + @Test + void removeViewWithKey_ThrowsAndException_WhenABaseViewExistsForTheSpecifiedFilteredView() { + Workspace workspace = new Workspace("Name"); + + try { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("landscape"); + workspace.getViews().createFilteredView(view, "filtered", FilterMode.Include, Tags.ELEMENT); + + workspace.getViews().removeViewWithKey("landscape"); + fail(); + } catch (Exception e) { + assertEquals("A filtered view based upon \"landscape\" exists - please remove this first.", e.getMessage()); + } + } + + @Test + void removeViewWithKey_CustomView() { + Workspace workspace = new Workspace("Name"); + workspace.getViews().createCustomView("key", "title"); + + assertFalse(workspace.getViews().getCustomViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getCustomViews().isEmpty()); + } + + @Test + void removeViewWithKey_SystemLandscapeView() { + Workspace workspace = new Workspace("Name"); + workspace.getViews().createSystemLandscapeView("key"); + + assertFalse(workspace.getViews().getSystemLandscapeViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getSystemLandscapeViews().isEmpty()); + } + + @Test + void removeViewWithKey_SystemContextView() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + workspace.getViews().createSystemContextView(softwareSystem, "key"); + + assertFalse(workspace.getViews().getSystemContextViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getSystemContextViews().isEmpty()); + } + + @Test + void removeViewWithKey_ContainerView() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + workspace.getViews().createContainerView(softwareSystem, "key"); + + assertFalse(workspace.getViews().getContainerViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getContainerViews().isEmpty()); + } + + @Test + void removeViewWithKey_ComponentView() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + Container container = softwareSystem.addContainer("Name"); + workspace.getViews().createComponentView(container, "key"); + + assertFalse(workspace.getViews().getComponentViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getComponentViews().isEmpty()); + } + + @Test + void removeViewWithKey_DynamicView() { + Workspace workspace = new Workspace("Name"); + workspace.getViews().createDynamicView("key"); + + assertFalse(workspace.getViews().getDynamicViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getDynamicViews().isEmpty()); + } + + @Test + void removeViewWithKey_DeploymentView() { + Workspace workspace = new Workspace("Name"); + workspace.getViews().createDeploymentView("key"); + + assertFalse(workspace.getViews().getDeploymentViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getDeploymentViews().isEmpty()); + } + + @Test + void removeViewWithKey_FilteredView() { + Workspace workspace = new Workspace("Name"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("landscape"); + workspace.getViews().createFilteredView(view, "key", FilterMode.Include, Tags.ELEMENT); + + assertFalse(workspace.getViews().getFilteredViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getFilteredViews().isEmpty()); + } + + @Test + void removeViewWithKey_ImageView() { + Workspace workspace = new Workspace("Name"); + workspace.getViews().createImageView("key"); + + assertFalse(workspace.getViews().getImageViews().isEmpty()); + workspace.getViews().removeViewWithKey("key"); + assertTrue(workspace.getViews().getImageViews().isEmpty()); + } + } \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/ViewTests.java b/structurizr-core/src/test/java/com/structurizr/view/ViewTests.java similarity index 79% rename from structurizr-core/test/unit/com/structurizr/view/ViewTests.java rename to structurizr-core/src/test/java/com/structurizr/view/ViewTests.java index e093e84ec..068c17991 100644 --- a/structurizr-core/test/unit/com/structurizr/view/ViewTests.java +++ b/structurizr-core/src/test/java/com/structurizr/view/ViewTests.java @@ -3,18 +3,18 @@ import com.structurizr.AbstractWorkspaceTestBase; import com.structurizr.Workspace; import com.structurizr.model.*; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.Iterator; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; public class ViewTests extends AbstractWorkspaceTestBase { @Test - public void test_construction() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void construction() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "key", "Description"); assertEquals("key", view.getKey()); assertEquals("Description", view.getDescription()); @@ -22,15 +22,15 @@ public void test_construction() { } @Test - public void test_construction_WhenTheViewKeyContainsAForwardSlashCharacter() { + void construction_WhenTheViewKeyContainsAForwardSlashCharacter() { SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); StaticView view = new SystemContextView(softwareSystem, "key/1", "Description"); assertEquals("key_1", view.getKey()); } @Test - public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); assertEquals(1, view.getElements().size()); view.addAllSoftwareSystems(); @@ -38,11 +38,11 @@ public void test_addAllSoftwareSystems_DoesNothing_WhenThereAreNoOtherSoftwareSy } @Test - public void test_addAllSoftwareSystems_DoesAddAllSoftwareSystems_WhenThereAreSoftwareSystemsInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.Unspecified, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.Unspecified, "System B", "Description"); - SoftwareSystem softwareSystemC = model.addSoftwareSystem(Location.Unspecified, "System C", "Description"); + void addAllSoftwareSystems_DoesAddAllSoftwareSystems_WhenThereAreSoftwareSystemsInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); + SoftwareSystem softwareSystemC = model.addSoftwareSystem("System C", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); view.addAllSoftwareSystems(); @@ -56,12 +56,12 @@ public void test_addAllSoftwareSystems_DoesAddAllSoftwareSystems_WhenThereAreSof } @Test - public void test_addSoftwareSystem_ThrowsAnException_WhenGivenNull() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void addSoftwareSystem_ThrowsAnException_WhenGivenNull() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); try { - view.add((SoftwareSystem)null); + view.add((SoftwareSystem) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -69,9 +69,9 @@ public void test_addSoftwareSystem_ThrowsAnException_WhenGivenNull() { } @Test - public void test_addSoftwareSystem_AddsTheSoftwareSystem_WhenTheSoftwareSystemIsInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.Unspecified, "System A", "Description"); + void addSoftwareSystem_AddsTheSoftwareSystem_WhenTheSoftwareSystemIsInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); view.add(softwareSystemA); @@ -82,8 +82,8 @@ public void test_addSoftwareSystem_AddsTheSoftwareSystem_WhenTheSoftwareSystemIs } @Test - public void test_addAllPeople_DoesNothing_WhenThereAreNoPeopleInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void addAllPeople_DoesNothing_WhenThereAreNoPeopleInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); assertEquals(1, view.getElements().size()); @@ -93,11 +93,11 @@ public void test_addAllPeople_DoesNothing_WhenThereAreNoPeopleInTheModel() { } @Test - public void test_addAllPeople_DoesAddAllPeople_WhenThereArePeopleInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); - Person person1 = model.addPerson(Location.Unspecified, "Person 1", "Description"); - Person person2 = model.addPerson(Location.Unspecified, "Person 2", "Description"); - Person person3 = model.addPerson(Location.Unspecified, "Person 3", "Description"); + void addAllPeople_DoesAddAllPeople_WhenThereArePeopleInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); + Person person1 = model.addPerson("Person 1", "Description"); + Person person2 = model.addPerson("Person 2", "Description"); + Person person3 = model.addPerson("Person 3", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); view.addAllPeople(); @@ -111,11 +111,11 @@ public void test_addAllPeople_DoesAddAllPeople_WhenThereArePeopleInTheModel() { } @Test - public void test_addPerson_ThrowsAnException_WhenGivenNull() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void addPerson_ThrowsAnException_WhenGivenNull() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); try { - view.add((Person)null); + view.add((Person) null); fail(); } catch (IllegalArgumentException iae) { assertEquals("An element must be specified.", iae.getMessage()); @@ -123,11 +123,11 @@ public void test_addPerson_ThrowsAnException_WhenGivenNull() { } @Test - public void test_addPerson_AddsThePerson_WhenThPersonIsInTheModel() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void addPerson_AddsThePerson_WhenThPersonIsInTheModel() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); - Person person1 = model.addPerson(Location.Unspecified, "Person 1", "Description"); + Person person1 = model.addPerson("Person 1", "Description"); view.add(person1); assertEquals(2, view.getElements().size()); @@ -137,9 +137,9 @@ public void test_addPerson_AddsThePerson_WhenThPersonIsInTheModel() { } @Test - public void test_removeElementsWithNoRelationships_RemovesAllElements_WhenTheViewHasNoRelationshipsBetweenElements() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "Software System", "Description"); - Person person = model.addPerson(Location.Unspecified, "Person", "Description"); + void removeElementsWithNoRelationships_RemovesAllElements_WhenTheViewHasNoRelationshipsBetweenElements() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Person person = model.addPerson("Person", "Description"); StaticView view = views.createSystemLandscapeView("context", "Description"); view.addAllSoftwareSystems(); @@ -150,12 +150,12 @@ public void test_removeElementsWithNoRelationships_RemovesAllElements_WhenTheVie } @Test - public void test_removeElementsWithNoRelationships_RemovesOnlyThoseElementsWithoutRelationships_WhenTheViewContainsSomeUnlinkedElements() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); - SoftwareSystem softwareSystemA = model.addSoftwareSystem(Location.Unspecified, "System A", "Description"); - SoftwareSystem softwareSystemB = model.addSoftwareSystem(Location.Unspecified, "System B", "Description"); - Person person1 = model.addPerson(Location.Unspecified, "Person 1", "Description"); - Person person2 = model.addPerson(Location.Unspecified, "Person 2", "Description"); + void removeElementsWithNoRelationships_RemovesOnlyThoseElementsWithoutRelationships_WhenTheViewContainsSomeUnlinkedElements() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); + SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", "Description"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", "Description"); + Person person1 = model.addPerson("Person 1", "Description"); + Person person2 = model.addPerson("Person 2", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); softwareSystem.uses(softwareSystemA, "uses"); @@ -170,7 +170,7 @@ public void test_removeElementsWithNoRelationships_RemovesOnlyThoseElementsWitho } @Test - public void test_copyLayoutInformationFrom() { + void copyLayoutInformationFrom() { Workspace workspace1 = new Workspace("", ""); Model model1 = workspace1.getModel(); SoftwareSystem softwareSystem1A = model1.addSoftwareSystem("System A", "Description"); @@ -256,21 +256,14 @@ public void test_copyLayoutInformationFrom() { } @Test - public void test_getName() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); - SystemContextView systemContextView = new SystemContextView(softwareSystem, "context", "Description"); - assertEquals("The System - System Context", systemContextView.getName()); - } - - @Test - public void test_removeElementsThatAreUnreachableFrom_DoesNothing_WhenANullElementIsSpecified() { - SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.Internal, "The System", "Description"); + void removeElementsThatAreUnreachableFrom_DoesNothing_WhenANullElementIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); StaticView view = new SystemContextView(softwareSystem, "context", "Description"); view.removeElementsThatAreUnreachableFrom(null); } @Test - public void test_removeElementsThatAreUnreachableFrom_DoesNothing_WhenAllElementsCanBeReached() { + void removeElementsThatAreUnreachableFrom_DoesNothing_WhenAllElementsCanBeReached() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", ""); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", ""); @@ -287,7 +280,7 @@ public void test_removeElementsThatAreUnreachableFrom_DoesNothing_WhenAllElement } @Test - public void test_removeElementsThatAreUnreachableFrom_RemovesOrphanedElements_WhenThereAreSomeOrphanedElements() { + void removeElementsThatAreUnreachableFrom_RemovesOrphanedElements_WhenThereAreSomeOrphanedElements() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", ""); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", ""); @@ -306,7 +299,7 @@ public void test_removeElementsThatAreUnreachableFrom_RemovesOrphanedElements_Wh } @Test - public void test_removeElementsThatAreUnreachableFrom_RemovesUnreachableElements_WhenThereAreSomeUnreachableElements() { + void removeElementsThatAreUnreachableFrom_RemovesUnreachableElements_WhenThereAreSomeUnreachableElements() { SoftwareSystem softwareSystem = model.addSoftwareSystem("The System", "Description"); SoftwareSystem softwareSystemA = model.addSoftwareSystem("System A", ""); SoftwareSystem softwareSystemB = model.addSoftwareSystem("System B", ""); @@ -324,7 +317,7 @@ public void test_removeElementsThatAreUnreachableFrom_RemovesUnreachableElements } @Test - public void test_removeElementsThatAreUnreachableFrom_DoesntIncludeAllElements_WhenThereIsACyclicGraph() { + void removeElementsThatAreUnreachableFrom_DoesntIncludeAllElements_WhenThereIsACyclicGraph() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); Person user = model.addPerson("User", ""); @@ -345,7 +338,7 @@ public void test_removeElementsThatAreUnreachableFrom_DoesntIncludeAllElements_W } @Test - public void test_removeRelationship_DoesNothing_WhenNullIsSpecified() { + void removeRelationship_DoesNothing_WhenNullIsSpecified() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); SoftwareSystem softwareSystem3 = model.addSoftwareSystem("Software System 3", "Description"); @@ -358,11 +351,11 @@ public void test_removeRelationship_DoesNothing_WhenNullIsSpecified() { view.addAllElements(); assertEquals(3, view.getRelationships().size()); - view.remove((Relationship)null); + view.remove((Relationship) null); } @Test - public void test_removeRelationship_RemovesARelationship_WhenAValidRelationshipIsSpecified() { + void removeRelationship_RemovesARelationship_WhenAValidRelationshipIsSpecified() { SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); SoftwareSystem softwareSystem3 = model.addSoftwareSystem("Software System 3", "Description"); @@ -382,20 +375,24 @@ public void test_removeRelationship_RemovesARelationship_WhenAValidRelationshipI assertTrue(view.getRelationships().contains(new RelationshipView(relationship23))); } - @Test(expected = IllegalArgumentException.class) - public void test_setKey_ThrowsAnException_WhenANullKeyIsSpecified() { - SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - new SystemContextView(softwareSystem, null, "Description"); + @Test + void setKey_ThrowsAnException_WhenANullKeyIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + new SystemContextView(softwareSystem, null, "Description"); + }); } - @Test(expected = IllegalArgumentException.class) - public void test_setKey_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { - SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); - new SystemContextView(softwareSystem, " ", "Description"); + @Test + void setKey_ThrowsAnException_WhenAnEmptyKeyIsSpecified() { + assertThrows(IllegalArgumentException.class, () -> { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + new SystemContextView(softwareSystem, " ", "Description"); + }); } @Test - public void test_addElement_ThrowsAnException_WhenTheSpecifiedElementDoesNotExistInTheModel() { + void addElement_ThrowsAnException_WhenTheSpecifiedElementDoesNotExistInTheModel() { try { Workspace workspace = new Workspace("1", ""); SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); @@ -409,7 +406,7 @@ public void test_addElement_ThrowsAnException_WhenTheSpecifiedElementDoesNotExis } @Test - public void test_enableAutomaticLayout_EnablesAutoLayoutWithSomeDefaultValues_WhenTrueIsSpecified() { + void enableAutomaticLayout_EnablesAutoLayoutWithSomeDefaultValues_WhenTrueIsSpecified() { SystemLandscapeView view = new Workspace("", "").getViews().createSystemLandscapeView("key", "Description"); view.enableAutomaticLayout(); @@ -422,7 +419,7 @@ public void test_enableAutomaticLayout_EnablesAutoLayoutWithSomeDefaultValues_Wh } @Test - public void test_enableAutomaticLayout_DisablesAutoLayout_WhenFalseIsSpecified() { + void enableAutomaticLayout_DisablesAutoLayout_WhenFalseIsSpecified() { SystemLandscapeView view = new Workspace("", "").getViews().createSystemLandscapeView("key", "Description"); view.enableAutomaticLayout(); assertNotNull(view.getAutomaticLayout()); @@ -432,7 +429,7 @@ public void test_enableAutomaticLayout_DisablesAutoLayout_WhenFalseIsSpecified() } @Test - public void test_enableAutomaticLayout() { + void enableAutomaticLayout() { SystemLandscapeView view = new Workspace("", "").getViews().createSystemLandscapeView("key", "Description"); view.enableAutomaticLayout(AutomaticLayout.RankDirection.LeftRight, 100, 200, 300, true); @@ -445,7 +442,7 @@ public void test_enableAutomaticLayout() { } @Test - public void test_addCustomElement_AddsTheCustomElementToTheView() { + void addCustomElement_AddsTheCustomElementToTheView() { Workspace workspace = new Workspace("", ""); SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); @@ -463,7 +460,7 @@ public void test_addCustomElement_AddsTheCustomElementToTheView() { } @Test - public void test_addCustomElementWithoutRelationships_AddsTheCustomElementToTheView() { + void addCustomElementWithoutRelationships_AddsTheCustomElementToTheView() { Workspace workspace = new Workspace("", ""); SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); @@ -481,7 +478,7 @@ public void test_addCustomElementWithoutRelationships_AddsTheCustomElementToTheV } @Test - public void test_removeCustomElement_RemovesTheCustomElementFromTheView() { + void removeCustomElement_RemovesTheCustomElementFromTheView() { Workspace workspace = new Workspace("", ""); SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); diff --git a/structurizr-core/src/test/resources/image.png b/structurizr-core/src/test/resources/image.png new file mode 100644 index 000000000..9ecfbfd32 Binary files /dev/null and b/structurizr-core/src/test/resources/image.png differ diff --git a/structurizr-core/src/test/resources/image.svg b/structurizr-core/src/test/resources/image.svg new file mode 100644 index 000000000..fe686329a --- /dev/null +++ b/structurizr-core/src/test/resources/image.svg @@ -0,0 +1 @@ +BobBobAliceAlicehello \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/WorkspaceTests.java b/structurizr-core/test/unit/com/structurizr/WorkspaceTests.java deleted file mode 100644 index e969b6495..000000000 --- a/structurizr-core/test/unit/com/structurizr/WorkspaceTests.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.structurizr; - -import com.structurizr.documentation.Decision; -import com.structurizr.documentation.Format; -import com.structurizr.model.Component; -import com.structurizr.model.Container; -import com.structurizr.model.SoftwareSystem; -import org.junit.Test; - -import java.io.File; - -import static org.junit.Assert.*; - -public class WorkspaceTests { - - private Workspace workspace = new Workspace("Name", "Description"); - - @Test - public void test_isEmpty_ReturnsTrue_WhenThereAreNoElementsViewsOrDocumentation() { - workspace = new Workspace("Name", "Description"); - assertTrue(workspace.isEmpty()); - } - - @Test - public void test_isEmpty_ReturnsFalse_WhenThereAreElements() { - workspace = new Workspace("Name", "Description"); - workspace.getModel().addPerson("Name", "Description"); - assertFalse(workspace.isEmpty()); - } - - @Test - public void test_isEmpty_ReturnsFalse_WhenThereAreViews() { - workspace = new Workspace("Name", "Description"); - workspace.getViews().createSystemLandscapeView("key", "Description"); - assertFalse(workspace.isEmpty()); - } - - @Test - public void test_isEmpty_ReturnsFalse_WhenThereIsDocumentation() throws Exception { - workspace = new Workspace("Name", "Description"); - Decision d = new Decision("1"); - d.setTitle("Title"); - d.setContent("Content"); - d.setStatus("Proposed"); - d.setFormat(Format.Markdown); - workspace.getDocumentation().addDecision(d); - assertFalse(workspace.isEmpty()); - } - - @Test - public void test_countAndLogWarnings() { - Workspace workspace = new Workspace("Name", "Description"); - SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1", null); - SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2", " "); - Container container1 = softwareSystem1.addContainer("Name", "Description", null); - Container container2 = softwareSystem2.addContainer("Name", "Description", " "); - container1.uses(container2, null, null); - container2.uses(container1, " ", " "); - - Component component1A = container1.addComponent("A", null, null); - Component component1B = container1.addComponent("B", "", ""); - component1A.uses(component1B, null); - component1B.uses(component1A, ""); - - assertEquals(10, workspace.countAndLogWarnings()); - } - - @Test - public void test_hydrate_DoesNotCrash() { - Workspace workspace = new Workspace("Name", "Description"); - assertNotNull(workspace.getViews()); - assertNotNull(workspace.getDocumentation()); - - // check that the hydrate method doesn't crash (it includes some method calls via reflection) - workspace.hydrate(); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/configuration/WorkspaceConfigurationTests.java b/structurizr-core/test/unit/com/structurizr/configuration/WorkspaceConfigurationTests.java deleted file mode 100644 index 0658abd18..000000000 --- a/structurizr-core/test/unit/com/structurizr/configuration/WorkspaceConfigurationTests.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.structurizr.configuration; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -public class WorkspaceConfigurationTests { - - @Test - public void test_addUser_ThrowsAnException_WhenANullUsernameIsSpecified() { - try { - WorkspaceConfiguration configuration = new WorkspaceConfiguration(); - configuration.addUser(null, Role.ReadWrite); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A username must be specified.", iae.getMessage()); - } - } - - @Test - public void test_addUser_ThrowsAnException_WhenAnEmptyUsernameIsSpecified() { - try { - WorkspaceConfiguration configuration = new WorkspaceConfiguration(); - configuration.addUser(" ", Role.ReadWrite); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A username must be specified.", iae.getMessage()); - } - } - - @Test - public void test_addUser_ThrowsAnException_WhenANullRoleIsSpecified() { - try { - WorkspaceConfiguration configuration = new WorkspaceConfiguration(); - configuration.addUser("user@domain.com", null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A role must be specified.", iae.getMessage()); - } - } - - @Test - public void test_addUser_AddsAUser() { - WorkspaceConfiguration configuration = new WorkspaceConfiguration(); - configuration.addUser("user@domain.com", Role.ReadOnly); - - assertEquals(1, configuration.getUsers().size()); - assertEquals("user@domain.com", configuration.getUsers().iterator().next().getUsername()); - assertEquals(Role.ReadOnly, configuration.getUsers().iterator().next().getRole()); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/documentation/SectionTests.java b/structurizr-core/test/unit/com/structurizr/documentation/SectionTests.java deleted file mode 100644 index bddc2cf47..000000000 --- a/structurizr-core/test/unit/com/structurizr/documentation/SectionTests.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.structurizr.documentation; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class SectionTests { - - @Test - public void test_construction() { - Section section = new Section("Title", Format.Markdown, "Content"); - - assertEquals("Title", section.getTitle()); - assertEquals(Format.Markdown, section.getFormat()); - assertEquals("Content", section.getContent()); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/CodeElementTests.java b/structurizr-core/test/unit/com/structurizr/model/CodeElementTests.java deleted file mode 100644 index 581b14516..000000000 --- a/structurizr-core/test/unit/com/structurizr/model/CodeElementTests.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.structurizr.model; - -import org.junit.Test; - -import static junit.framework.TestCase.assertNull; -import static org.junit.Assert.*; - -public class CodeElementTests { - - @Test - public void test_construction_WhenAFullyQualifiedNameIsSpecified() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertEquals("SomeComponent", codeElement.getName()); - assertEquals("com.structurizr.component.SomeComponent", codeElement.getType()); - } - - @Test - public void test_construction_WhenAFullyQualifiedNameIsSpecifiedInTheDefaultPackage() { - CodeElement codeElement = new CodeElement("SomeComponent"); - assertEquals("SomeComponent", codeElement.getName()); - assertEquals("SomeComponent", codeElement.getType()); - } - - @Test - public void test_descriptionProperty() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertNull(codeElement.getDescription()); - - codeElement.setDescription("Description"); - assertEquals("Description", codeElement.getDescription()); - } - - @Test - public void test_sizeProperty() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertEquals(0, codeElement.getSize()); - - codeElement.setSize(123456); - assertEquals(123456, codeElement.getSize()); - } - - @Test - public void test_languageProperty() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertEquals("Java", codeElement.getLanguage()); - - codeElement.setLanguage("Scala"); - assertEquals("Scala", codeElement.getLanguage()); - } - - @Test - public void test_categoryProperty() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertNull(codeElement.getCategory()); - - codeElement.setCategory("class"); - assertEquals("class", codeElement.getCategory()); - } - - @Test - public void test_visibilityProperty() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertNull(codeElement.getVisibility()); - - codeElement.setVisibility("package"); - assertEquals("package", codeElement.getVisibility()); - } - - @Test - public void test_setUrl() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - codeElement.setUrl("https://structurizr.com"); - assertEquals("https://structurizr.com", codeElement.getUrl()); - } - - @Test(expected = IllegalArgumentException.class) - public void test_setUrl_ThrowsAnIllegalArgumentException_WhenAnInvalidUrlIsSpecified() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - codeElement.setUrl("htt://blah"); - } - - @Test - public void test_setUrl_DoesNothing_WhenANullUrlIsSpecified() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - codeElement.setUrl(null); - assertNull(codeElement.getUrl()); - } - - @Test - public void test_setUrl_DoesNothing_WhenAnEmptyUrlIsSpecified() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - codeElement.setUrl(" "); - assertNull(codeElement.getUrl()); - } - - @Test(expected = IllegalArgumentException.class) - public void test_construction_ThrowsAnIllegalArgumentException_WhenANullFullyQualifiedNameIsSpecified() { - new CodeElement(null); - } - - @Test(expected = IllegalArgumentException.class) - public void test_construction_ThrowsAnIllegalArgumentException_WhenAnEmptyFullyQualifiedNameIsSpecified() { - new CodeElement(" "); - } - - @Test - public void test_equals_ReturnsFalse_WhenComparedToNull() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertFalse(codeElement.equals(null)); - } - - @Test - public void test_equals_ReturnsFalse_WhenComparedToDifferentTypeOfObject() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertFalse(codeElement.equals("hello")); - } - - @Test - public void test_equals_ReturnsFalse_WhenComparedToAnotherCodeElementWithADifferentType() { - CodeElement codeElement1 = new CodeElement("com.structurizr.component.SomeComponent1"); - CodeElement codeElement2 = new CodeElement("com.structurizr.component.SomeComponent2"); - assertFalse(codeElement1.equals(codeElement2)); - } - - @Test - public void test_equals_ReturnsFalse_WhenComparedToAnotherCodeElementWithTheSameType() { - CodeElement codeElement1 = new CodeElement("com.structurizr.component.SomeComponent1"); - CodeElement codeElement2 = new CodeElement("com.structurizr.component.SomeComponent1"); - assertTrue(codeElement1.equals(codeElement2)); - } - - @Test - public void test_getPackage_ReturnsThePackageName() { - CodeElement codeElement = new CodeElement("com.structurizr.component.SomeComponent"); - assertEquals("com.structurizr.component", codeElement.getPackage()); - } - -} diff --git a/structurizr-core/test/unit/com/structurizr/model/ComponentTests.java b/structurizr-core/test/unit/com/structurizr/model/ComponentTests.java deleted file mode 100644 index 5d1bbefc8..000000000 --- a/structurizr-core/test/unit/com/structurizr/model/ComponentTests.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.structurizr.model; - -import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; - -import java.util.Set; - -import static junit.framework.TestCase.assertNull; -import static org.junit.Assert.*; - -public class ComponentTests extends AbstractWorkspaceTestBase { - - private SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System", "Description"); - private Container container = softwareSystem.addContainer("Container", "Description", "Some technology"); - - @Test - public void test_getName_ReturnsTheGivenName_WhenANameIsGiven() { - Component component = new Component(); - component.setName("Some name"); - assertEquals("Some name", component.getName()); - } - - @Test - public void test_getCanonicalName() { - Component component = container.addComponent("Component", "Description"); - assertEquals("Component://System.Container.Component", component.getCanonicalName()); - } - - @Test - public void test_getCanonicalName_WhenNameContainsSlashAndDotCharacters() { - Component component = container.addComponent("Name1/.Name2", "Description"); - assertEquals("Component://System.Container.Name1Name2", component.getCanonicalName()); - } - - @Test - public void test_getParent_ReturnsTheParentContainer() { - Component component = container.addComponent("Component", "Description"); - assertEquals(container, component.getParent()); - } - - @Test - public void test_getContainer_ReturnsTheParentContainer() { - Component component = container.addComponent("Name", "Description"); - assertEquals(container, component.getContainer()); - } - - @Test - public void test_removeTags_DoesNotRemoveRequiredTags() { - Component component = new Component(); - assertTrue(component.getTags().contains(Tags.ELEMENT)); - assertTrue(component.getTags().contains(Tags.COMPONENT)); - - component.removeTag(Tags.COMPONENT); - component.removeTag(Tags.ELEMENT); - - assertTrue(component.getTags().contains(Tags.ELEMENT)); - assertTrue(component.getTags().contains(Tags.COMPONENT)); - } - - @Test - public void test_technologyProperty() { - Component component = new Component(); - assertNull(component.getTechnology()); - - component.setTechnology("Spring Bean"); - assertEquals("Spring Bean", component.getTechnology()); - } - - @Test - public void test_sizeProperty() { - Component component = new Component(); - assertEquals(0, component.getSize()); - - component.setSize(123456); - assertEquals(123456, component.getSize()); - } - - @Test - public void test_setType_ThrowsAnExceptionWhenPassedNull() { - Component component = new Component(); - try { - component.setType(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A fully qualified name must be provided.", iae.getMessage()); - } - } - - @Test - public void test_setType_AddsAPrimaryCodeElement_WhenPassedAFullyQualifiedTypeName() { - Component component = new Component(); - component.setType("com.structurizr.web.HomePageController"); - - Set codeElements = component.getCode(); - assertEquals(1, codeElements.size()); - CodeElement codeElement = codeElements.iterator().next(); - assertEquals("HomePageController", codeElement.getName()); - assertEquals("com.structurizr.web.HomePageController", codeElement.getType()); - assertEquals(CodeElementRole.Primary, codeElement.getRole()); - } - - @Test - public void test_setType_OverwritesThePrimaryCodeElement_WhenCalledMoreThanOnce() { - Component component = new Component(); - component.setType("com.structurizr.web.HomePageController"); - component.setType("com.structurizr.web.SomeOtherController"); - - Set codeElements = component.getCode(); - assertEquals(1, codeElements.size()); - CodeElement codeElement = codeElements.iterator().next(); - assertEquals("SomeOtherController", codeElement.getName()); - assertEquals("com.structurizr.web.SomeOtherController", codeElement.getType()); - assertEquals(CodeElementRole.Primary, codeElement.getRole()); - - } - - @Test - public void test_addSupportingType_ThrowsAnExceptionWhenPassedNull() { - Component component = new Component(); - try { - component.addSupportingType(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A fully qualified name must be provided.", iae.getMessage()); - } - } - - @Test - public void test_addSupportingType_AddsASupportingCodeElement_WhenPassedAFullyQualifiedTypeName() { - Component component = new Component(); - component.addSupportingType("com.structurizr.web.HomePageViewModel"); - - Set codeElements = component.getCode(); - assertEquals(1, codeElements.size()); - CodeElement codeElement = codeElements.iterator().next(); - assertEquals("HomePageViewModel", codeElement.getName()); - assertEquals("com.structurizr.web.HomePageViewModel", codeElement.getType()); - assertEquals(CodeElementRole.Supporting, codeElement.getRole()); - } - - @Test - public void test_getType_ReturnsNull_WhenThereAreNoCodeElements() { - Component component = new Component(); - assertNull(component.getType()); - } - - @Test - public void test_getType_ReturnsNull_WhenThereAreNoPrimaryCodeElements() { - Component component = new Component(); - component.addSupportingType("com.structurizr.SomeType"); - assertNull(component.getType()); - } - - @Test - public void test_getType_ReturnsThePrimaryCodeElement_WhenThereIsAPrimaryCodeElement() { - Component component = new Component(); - component.setType("com.structurizr.SomeType"); - CodeElement codeElement = component.getType(); - assertEquals(CodeElementRole.Primary, codeElement.getRole()); - assertEquals("com.structurizr.SomeType", codeElement.getType()); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/model/ContainerTests.java b/structurizr-core/test/unit/com/structurizr/model/ContainerTests.java deleted file mode 100644 index 07813325b..000000000 --- a/structurizr-core/test/unit/com/structurizr/model/ContainerTests.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.structurizr.model; - -import com.structurizr.AbstractWorkspaceTestBase; -import org.junit.Test; - -import static org.junit.Assert.*; - -public class ContainerTests extends AbstractWorkspaceTestBase { - - private SoftwareSystem softwareSystem = model.addSoftwareSystem(Location.External, "System", "Description"); - private Container container = softwareSystem.addContainer("Container", "Description", "Some technology"); - - @Test - public void test_technologyProperty() { - assertEquals("Some technology", container.getTechnology()); - - container.setTechnology("Some other technology"); - assertEquals("Some other technology", container.getTechnology()); - } - - @Test - public void test_getCanonicalName() { - assertEquals("Container://System.Container", container.getCanonicalName()); - } - - @Test - public void test_getCanonicalName_WhenNameContainsSlashAndDotCharacters() { - container = softwareSystem.addContainer("Name1/.Name2", "Description", "Some technology"); - - assertEquals("Container://System.Name1Name2", container.getCanonicalName()); - } - - @Test - public void test_getParent_ReturnsTheParentSoftwareSystem() { - assertEquals(softwareSystem, container.getParent()); - } - - @Test - public void test_getSoftwareSystem_ReturnsTheParentSoftwareSystem() { - assertEquals(softwareSystem, container.getSoftwareSystem()); - } - - @Test - public void test_removeTags_DoesNotRemoveRequiredTags() { - assertTrue(container.getTags().contains(Tags.ELEMENT)); - assertTrue(container.getTags().contains(Tags.CONTAINER)); - - container.removeTag(Tags.CONTAINER); - container.removeTag(Tags.ELEMENT); - - assertTrue(container.getTags().contains(Tags.ELEMENT)); - assertTrue(container.getTags().contains(Tags.CONTAINER)); - } - - @Test(expected = IllegalArgumentException.class) - public void test_addComponent_ThrowsAnException_WhenANullNameIsSpecified() { - container.addComponent(null, ""); - } - - @Test(expected = IllegalArgumentException.class) - public void test_addComponent_ThrowsAnException_WhenAnEmptyNameIsSpecified() { - container.addComponent(" ", ""); - } - - @Test - public void test_addComponent_ThrowsAnException_WhenAComponentWithTheSameNameAlreadyExists() { - container.addComponent("Component 1", ""); - try { - container.addComponent("Component 1", ""); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A component named 'Component 1' already exists for this container.", iae.getMessage()); - } - } - - @Test - public void test_addComponent_AddsAComponentWithTheSpecifiedNameAndDescription() { - Component component = container.addComponent("Name", "Description"); - assertTrue(container.getComponents().contains(component)); - assertEquals("Name", component.getName()); - assertEquals("Description", component.getDescription()); - assertNull(component.getTechnology()); - assertNull(component.getType()); - assertEquals(0, component.getCode().size()); - assertSame(container, component.getParent()); - } - - @Test - public void test_addComponent_AddsAComponentWithTheSpecifiedNameAndDescriptionAndTechnology() { - Component component = container.addComponent("Name", "Description", "Technology"); - assertTrue(container.getComponents().contains(component)); - assertEquals("Name", component.getName()); - assertEquals("Description", component.getDescription()); - assertEquals("Technology", component.getTechnology()); - assertNull(component.getType()); - assertEquals(0, component.getCode().size()); - assertSame(container, component.getParent()); - } - - @Test - public void test_addComponent_AddsAComponentWithTheSpecifiedNameAndDescriptionAndTechnologyAndStringType() { - Component component = container.addComponent("Name", "SomeType", "Description", "Technology"); - assertTrue(container.getComponents().contains(component)); - assertEquals("Name", component.getName()); - assertEquals("Description", component.getDescription()); - assertEquals("Technology", component.getTechnology()); - assertEquals("SomeType", component.getType().getType()); - assertEquals(1, component.getCode().size()); - assertSame(container, component.getParent()); - } - - @Test - public void test_addComponent_AddsAComponentWithTheSpecifiedNameAndDescriptionAndTechnologyAndClassType() { - Component component = container.addComponent("Name", this.getClass(), "Description", "Technology"); - assertTrue(container.getComponents().contains(component)); - assertEquals("Name", component.getName()); - assertEquals("Description", component.getDescription()); - assertEquals("Technology", component.getTechnology()); - assertEquals("com.structurizr.model.ContainerTests", component.getType().getType()); - assertEquals(1, component.getCode().size()); - assertSame(container, component.getParent()); - } - - @Test - public void test_getComponentWithName_ThrowsAnException_WhenANullNameIsSpecified() { - try { - container.getComponentWithName(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A component name must be provided.", iae.getMessage()); - } - } - - @Test - public void test_getComponentWithName_ThrowsAnException_WhenAnEmptyNameIsSpecified() { - try { - container.getComponentWithName(" "); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A component name must be provided.", iae.getMessage()); - } - } - - @Test - public void test_getComponentOfType_ThrowsAnException_WhenANullTypeIsSpecified() { - try { - container.getComponentOfType(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A component type must be provided.", iae.getMessage()); - } - } - - @Test - public void test_getComponentOfType_ThrowsAnException_WhenAnEmptyTypeIsSpecified() { - try { - container.getComponentOfType(" "); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A component type must be provided.", iae.getMessage()); - } - } - - @Test - public void test_getComponentOfType_ReturnsNull_WhenNoComponentWithTheSpecifiedTypeExists() { - assertNull(container.getComponentOfType("SomeType")); - } - - @Test - public void test_getComponentOfType_ReturnsAComponent_WhenAComponentWithTheSpecifiedTypeExists() { - container.addComponent("Name", "SomeType", "Description", "Technology"); - Component component = container.getComponentOfType("SomeType"); - - assertNotNull(component); - assertEquals("SomeType", component.getType().getType()); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/util/ImageUtilsTests.java b/structurizr-core/test/unit/com/structurizr/util/ImageUtilsTests.java deleted file mode 100644 index 0d5ee691b..000000000 --- a/structurizr-core/test/unit/com/structurizr/util/ImageUtilsTests.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.structurizr.util; - -import org.junit.Test; - -import java.io.File; - -import static org.junit.Assert.*; - -public class ImageUtilsTests { - - @Test - public void test_getContentType_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { - try { - ImageUtils.getContentType(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A file must be specified.", iae.getMessage()); - } - } - - @Test - public void test_getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { - try { - ImageUtils.getContentType(new File("../structurizr-core")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); - } - } - - @Test - public void test_getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { - try { - ImageUtils.getContentType(new File("../build.gradle")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); - } - } - - @Test - public void test_getContentType_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { - try { - ImageUtils.getContentType(new File("./foo.xml")); - fail(); - } catch (IllegalArgumentException iae) { - assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); - } - } - - @Test - public void test_getContentType_ReturnsTheContentType_WhenAFileIsSpecified() throws Exception { - String contentType = ImageUtils.getContentType(new File("../structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png")); - assertEquals("image/png", contentType); - } - - @Test - public void test_getImageAsBase64_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { - try { - ImageUtils.getImageAsBase64(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A file must be specified.", iae.getMessage()); - } - } - - @Test - public void test_getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { - try { - ImageUtils.getImageAsBase64(new File("../structurizr-core")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); - } - } - - @Test - public void test_getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { - try { - ImageUtils.getImageAsBase64(new File("../build.gradle")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); - } - } - - @Test - public void test_getImageAsBase64_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { - try { - ImageUtils.getImageAsBase64(new File("./foo.xml")); - fail(); - } catch (IllegalArgumentException iae) { - assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); - } - } - - @Test - public void test_getImageAsBase64_ReturnsTheImageAsABase64EncodedString_WhenAFileIsSpecified() throws Exception { - String imageAsBase64 = ImageUtils.getImageAsBase64(new File("../structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png")); - assertTrue(imageAsBase64.startsWith("iVBORw0KGgoAAAANSUhEUgAAAMQAAADECAYAAADApo5rAAA")); // the actual base64 encoded string varies between Java 8 and 9 - } - - @Test - public void test_getImageAsDataUri_ThrowsAnException_WhenANullFileIsSpecified() throws Exception { - try { - ImageUtils.getImageAsDataUri(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("A file must be specified.", iae.getMessage()); - } - } - - @Test - public void test_getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAFile() throws Exception { - try { - ImageUtils.getImageAsDataUri(new File("../structurizr-core")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("structurizr-core is not a file.")); - } - } - - @Test - public void test_getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItIsNotAnImage() throws Exception { - try { - ImageUtils.getImageAsDataUri(new File("../build.gradle")); - fail(); - } catch (IllegalArgumentException iae) { - iae.printStackTrace(); - assertTrue(iae.getMessage().endsWith("build.gradle is not a supported image file.")); - } - } - - @Test - public void test_getImageAsDataUri_ThrowsAnException_WhenAFileIsSpecifiedButItDoesNotExist() throws Exception { - try { - ImageUtils.getImageAsDataUri(new File("./foo.xml")); - fail(); - } catch (IllegalArgumentException iae) { - assertTrue(iae.getMessage().endsWith("foo.xml does not exist.")); - } - } - - @Test - public void test_getImageAsDataUri_ReturnsTheImageAsADataUri_WhenAFileIsSpecified() throws Exception { - String imageAsDataUri = ImageUtils.getImageAsDataUri(new File("../structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png")); - System.out.println(imageAsDataUri); - assertTrue(imageAsDataUri.startsWith("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMQAAADECAYAAADA")); // the actual base64 encoded string varies between Java 8 and 9 - } - - @Test - public void test_validateImage() { - // allowed - ImageUtils.validateImage("https://structurizr.com/image.png"); - ImageUtils.validateImage("data:image/png;base64,iVBORw0KGg"); - ImageUtils.validateImage("data:image/jpeg;base64,iVBORw0KGg"); - - //disallowed - try { - ImageUtils.validateImage("data:image/svg+xml;base64,iVBORw0KGg"); - fail(); - } catch (Exception e) { - assertEquals("Only PNG and JPG data URIs are supported: data:image/svg+xml;base64,iVBORw0KGg", e.getMessage()); - } - } - - @Test - public void test_isSupportedDataUri() { - assertTrue(ImageUtils.isSupportedDataUri("data:image/png;base64,iVBORw0KGg")); - assertTrue(ImageUtils.isSupportedDataUri("data:image/jpeg;base64,iVBORw0KGg")); - assertFalse(ImageUtils.isSupportedDataUri("data:image/svg+xml;base64,iVBORw0KGg")); - assertFalse(ImageUtils.isSupportedDataUri("hello world")); - } - -} diff --git a/structurizr-core/test/unit/com/structurizr/util/UrlTests.java b/structurizr-core/test/unit/com/structurizr/util/UrlTests.java deleted file mode 100644 index 6669463ba..000000000 --- a/structurizr-core/test/unit/com/structurizr/util/UrlTests.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.structurizr.util; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class UrlTests { - - @Test - public void test_isUrl_ReturnsFalse_WhenPassedNull() { - assertFalse(Url.isUrl(null)); - } - - @Test - public void test_isUrl_ReturnsFalse_WhenPassedAnEmptyString() { - assertFalse(Url.isUrl("")); - assertFalse(Url.isUrl(" ")); - } - - @Test - public void test_isUrl_ReturnsFalse_WhenPassedAnInvalidUrl() { - assertFalse(Url.isUrl("www.google.com")); - } - - @Test - public void test_isUrl_ReturnsTrue_WhenPassedAValidUrl() { - assertTrue(Url.isUrl("https://www.google.com")); - } - -} diff --git a/structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png b/structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png deleted file mode 100644 index 9324ae8dc..000000000 Binary files a/structurizr-core/test/unit/com/structurizr/util/structurizr-logo.png and /dev/null differ diff --git a/structurizr-core/test/unit/com/structurizr/view/ColorPairTests.java b/structurizr-core/test/unit/com/structurizr/view/ColorPairTests.java deleted file mode 100644 index efc760b82..000000000 --- a/structurizr-core/test/unit/com/structurizr/view/ColorPairTests.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.structurizr.view; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -public class ColorPairTests { - - @Test - public void test_construction() { - ColorPair colorPair = new ColorPair("#ffffff", "#000000"); - assertEquals("#ffffff", colorPair.getBackground()); - assertEquals("#000000", colorPair.getForeground()); - } - - @Test - public void test_setBackground_WithAValidHtmlColorCode() { - ColorPair colorPair = new ColorPair(); - colorPair.setBackground("#ffffff"); - assertEquals("#ffffff", colorPair.getBackground()); - } - - @Test - public void test_setBackground_ThrowsAnException_WhenANullHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setBackground(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'null' is not a valid hex color code.", iae.getMessage()); - } - } - - @Test - public void test_setBackground_ThrowsAnException_WhenAnEmptyHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setBackground(""); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'' is not a valid hex color code.", iae.getMessage()); - } - } - - @Test - public void test_setBackground_ThrowsAnException_WhenAnInvalidHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setBackground("ffffff"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'ffffff' is not a valid hex color code.", iae.getMessage()); - } - } - - @Test - public void test_setForeground_WithAValidHtmlColorCode() { - ColorPair colorPair = new ColorPair(); - colorPair.setForeground("#000000"); - assertEquals("#000000", colorPair.getForeground()); - } - - @Test - public void test_setForeground_ThrowsAnException_WhenANullHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setForeground(null); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'null' is not a valid hex color code.", iae.getMessage()); - } - } - - @Test - public void test_setForeground_ThrowsAnException_WhenAnEmptyHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setForeground(""); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'' is not a valid hex color code.", iae.getMessage()); - } - } - - @Test - public void test_setForeground_ThrowsAnException_WhenAnInvalidHtmlColorCodeIsSpecified() { - try { - ColorPair colorPair = new ColorPair(); - colorPair.setForeground("000000"); - fail(); - } catch (IllegalArgumentException iae) { - assertEquals("'000000' is not a valid hex color code.", iae.getMessage()); - } - } - -} diff --git a/structurizr-core/test/unit/com/structurizr/view/ColorTests.java b/structurizr-core/test/unit/com/structurizr/view/ColorTests.java deleted file mode 100644 index 074f8170d..000000000 --- a/structurizr-core/test/unit/com/structurizr/view/ColorTests.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.structurizr.view; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class ColorTests { - - @Test - public void test_isHexColorCode_ReturnsFalse_WhenPassedNull() { - assertFalse(Color.isHexColorCode(null)); - } - - @Test - public void test_isHexColorCode_ReturnsFalse_WhenPassedAnEmptyString() { - assertFalse(Color.isHexColorCode("")); - } - - @Test - public void test_isHexColorCode_ReturnsFalse_WhenPassedAnInvalidString() { - assertFalse(Color.isHexColorCode("ffffff")); - assertFalse(Color.isHexColorCode("#fffff")); - assertFalse(Color.isHexColorCode("#gggggg")); - } - - @Test - public void test_isHexColorCode_ReturnsTrue_WhenPassedAnValidString() { - assertTrue(Color.isHexColorCode("#abcdef")); - assertTrue(Color.isHexColorCode("#ABCDEF")); - assertTrue(Color.isHexColorCode("#123456")); - } - -} \ No newline at end of file diff --git a/structurizr-core/test/unit/com/structurizr/view/SequenceNumberTests.java b/structurizr-core/test/unit/com/structurizr/view/SequenceNumberTests.java deleted file mode 100644 index 5984b6f39..000000000 --- a/structurizr-core/test/unit/com/structurizr/view/SequenceNumberTests.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.structurizr.view; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -public class SequenceNumberTests { - - @Test - public void test_increment() { - SequenceNumber sequenceNumber = new SequenceNumber(); - assertEquals("1", sequenceNumber.getNext()); - assertEquals("2", sequenceNumber.getNext()); - } - - @Test - public void test_parallelSequences() { - SequenceNumber sequenceNumber = new SequenceNumber(); - assertEquals("1", sequenceNumber.getNext()); - - sequenceNumber.startParallelSequence(); - assertEquals("2", sequenceNumber.getNext()); - sequenceNumber.endParallelSequence(false); - - sequenceNumber.startParallelSequence(); - assertEquals("2", sequenceNumber.getNext()); - sequenceNumber.endParallelSequence(true); - - assertEquals("3", sequenceNumber.getNext()); - } - -} diff --git a/structurizr-core/test/unit/com/structurizr/view/VertexTests.java b/structurizr-core/test/unit/com/structurizr/view/VertexTests.java deleted file mode 100644 index a7f3be379..000000000 --- a/structurizr-core/test/unit/com/structurizr/view/VertexTests.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.structurizr.view; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class VertexTests { - - @Test - public void test_equals() { - Vertex vertex1 = new Vertex(123, 456); - Vertex vertex2 = new Vertex(123, 456); - Vertex vertex3 = new Vertex(456, 123); - - assertFalse(vertex1.equals(null)); - assertFalse(vertex1.equals("hello world")); - - assertTrue(vertex1.equals(vertex1)); - assertTrue(vertex1.equals(vertex2)); - assertTrue(vertex2.equals(vertex1)); - assertFalse(vertex2.equals(vertex3)); - } - -} \ No newline at end of file diff --git a/structurizr-dsl/README.md b/structurizr-dsl/README.md new file mode 100644 index 000000000..2108a6503 --- /dev/null +++ b/structurizr-dsl/README.md @@ -0,0 +1,11 @@ +# Structurizr DSL + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-dsl.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-dsl) + +This library is the implementation of the Structurizr DSL - a way to create Structurizr software +architecture models based upon the [C4 model](https://c4model.com) using a textual domain specific language (DSL). +The Structurizr DSL has appeared on the +[ThoughtWorks Tech Radar - Techniques - Diagrams as code](https://www.thoughtworks.com/radar/techniques/diagrams-as-code) +and is text-based wrapper around the [Structurizr for Java library](https://github.com/structurizr/java). + +- [Documentation](https://docs.structurizr.com/dsl) diff --git a/structurizr-dsl/build.gradle b/structurizr-dsl/build.gradle new file mode 100644 index 000000000..11cd9dd1a --- /dev/null +++ b/structurizr-dsl/build.gradle @@ -0,0 +1,14 @@ +dependencies { + + api project(':structurizr-client') + api project(':structurizr-import') + api project(':structurizr-export') + api project(':structurizr-component') + + testImplementation 'org.codehaus.groovy:groovy-jsr223:3.0.25' + testImplementation 'org.jetbrains.kotlin:kotlin-scripting-jsr223:1.9.25' + testImplementation 'org.jruby:jruby-core:9.4.12.0' + +} + +description = 'Structurizr DSL' \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractParser.java new file mode 100644 index 000000000..d45b3a2f6 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractParser.java @@ -0,0 +1,4 @@ +package com.structurizr.dsl; + +abstract class AbstractParser { +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractRelationshipParser.java new file mode 100644 index 000000000..556ac6a7e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractRelationshipParser.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +abstract class AbstractRelationshipParser extends AbstractParser { + + protected Relationship createRelationship(Element sourceElement, String description, String technology, String[] tags, Element destinationElement) { + Relationship relationship = null; + + if (sourceElement instanceof CustomElement) { + relationship = ((CustomElement)sourceElement).uses(destinationElement, description, technology, null, tags); + } else if (destinationElement instanceof CustomElement) { + relationship = sourceElement.uses((CustomElement)destinationElement, description, technology, null, tags); + } else if (sourceElement instanceof StaticStructureElement && destinationElement instanceof StaticStructureElement) { + relationship = ((StaticStructureElement)sourceElement).uses((StaticStructureElement)destinationElement, description, technology, null, tags); + } else if (sourceElement instanceof DeploymentNode && destinationElement instanceof DeploymentNode) { + relationship = ((DeploymentNode)sourceElement).uses((DeploymentNode)destinationElement, description, technology, null, tags); + } else if (sourceElement instanceof DeploymentNode && destinationElement instanceof InfrastructureNode) { + relationship = ((DeploymentNode)sourceElement).uses((InfrastructureNode) destinationElement, description, technology, null, tags); + } else if (sourceElement instanceof InfrastructureNode && destinationElement instanceof DeploymentElement) { + relationship = ((InfrastructureNode)sourceElement).uses((DeploymentElement)destinationElement, description, technology, null, tags); + } else if (sourceElement instanceof StaticStructureElementInstance && destinationElement instanceof InfrastructureNode) { + relationship = ((StaticStructureElementInstance)sourceElement).uses((InfrastructureNode)destinationElement, description, technology, null, tags); + } else { + throw new RuntimeException("A relationship between \"" + sourceElement.getCanonicalName() + "\" and \"" + destinationElement.getCanonicalName() + "\" is not permitted"); + } + + if (relationship == null) { + if (sourceElement.hasEfferentRelationshipWith(destinationElement, description) || sourceElement.hasEfferentRelationshipWith(destinationElement)) { + throw new RuntimeException("A relationship between \"" + sourceElement.getCanonicalName() + "\" and \"" + destinationElement.getCanonicalName() + "\" already exists"); + } + } + + return relationship; + } + + protected Set findSoftwareSystemInstances(SoftwareSystem softwareSystem, String deploymentEnvironment) { + return softwareSystem.getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).map(e -> (SoftwareSystemInstance) e).filter(ssi -> ssi.getSoftwareSystem().equals(softwareSystem) && ssi.getEnvironment().equals(deploymentEnvironment)).collect(Collectors.toSet()); + } + + protected Set findContainerInstances(Container container, String deploymentEnvironment) { + return container.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance) e).filter(ci -> ci.getContainer().equals(container) && ci.getEnvironment().equals(deploymentEnvironment)).collect(Collectors.toSet()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractViewParser.java new file mode 100644 index 000000000..c47bfabe8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/AbstractViewParser.java @@ -0,0 +1,16 @@ +package com.structurizr.dsl; + +import java.util.regex.Pattern; + +abstract class AbstractViewParser extends AbstractParser { + + private static final String PERMITTED_CHARACTERS_IN_VIEW_KEY = "a-zA-Z0-9_-"; + private static final Pattern VIEW_KEY_PATTERN = Pattern.compile("[" + PERMITTED_CHARACTERS_IN_VIEW_KEY + "]+"); + + void validateViewKey(String key) { + if (!VIEW_KEY_PATTERN.matcher(key).matches()) { + throw new RuntimeException("View keys can only contain the following characters: a-zA-Z0-9_-"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/Archetype.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/Archetype.java new file mode 100644 index 000000000..71a92fa0a --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/Archetype.java @@ -0,0 +1,155 @@ +package com.structurizr.dsl; + +import com.structurizr.PerspectivesHolder; +import com.structurizr.PropertyHolder; +import com.structurizr.model.Perspective; +import com.structurizr.util.StringUtils; + +import java.util.*; + +final class Archetype implements PropertyHolder, PerspectivesHolder { + + private final String name; + private final String type; + private String metadata = ""; + private String description = ""; + private String technology = ""; + private final Set tags = new LinkedHashSet<>(); + + private final Map properties = new HashMap<>(); + private final Set perspectives = new TreeSet<>(); + + Archetype(String name, String type) { + if (StringUtils.isNullOrEmpty(name)) { + name = type; + } + + this.name = name.toLowerCase(); + this.type = type; + } + + String getName() { + return name; + } + + String getType() { + return type; + } + + String getMetadata() { + return metadata; + } + + void setMetadata(String metadata) { + this.metadata = metadata; + } + + String getDescription() { + return description; + } + + void setDescription(String description) { + this.description = description; + } + + String getTechnology() { + return technology; + } + + void setTechnology(String technology) { + this.technology = technology; + } + + void addTags(String... tags) { + if (tags == null) { + return; + } + + for (String tag : tags) { + if (tag != null) { + this.tags.add(tag.trim()); + } + } + } + + Set getTags() { + return new LinkedHashSet<>(tags); + } + + /** + * Gets the collection of name-value property pairs associated with this archetype, as a Map. + * + * @return a Map (String, String) (empty if there are no properties) + */ + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Adds a name-value pair property to this archetype. + * + * @param name the name of the property + * @param value the value of the property + */ + public void addProperty(String name, String value) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A property name must be specified."); + } + + if (StringUtils.isNullOrEmpty(value)) { + throw new IllegalArgumentException("A property value must be specified."); + } + + properties.put(name, value); + } + + /** + * Gets the set of perspectives associated with this archetype. + * + * @return a Set of Perspective objects (empty if there are none) + */ + public Set getPerspectives() { + return new TreeSet<>(perspectives); + } + + /** + * Adds a perspective to this archetype. + * + * @param name the name of the perspective (e.g. "Security", must be unique) + * @param description the description of the perspective + * @return a Perspective object + * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already + */ + public Perspective addPerspective(String name, String description) { + return addPerspective(name, description, ""); + } + + /** + * Adds a perspective to this archetype. + * + * @param name the name of the perspective (e.g. "Technical Debt", must be unique) + * @param description the description of the perspective (e.g. "High") + * @param value the value of the perspective + * @return a Perspective object + * @throws IllegalArgumentException if perspective details are not specified, or the named perspective exists already + */ + public Perspective addPerspective(String name, String description, String value) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A name must be specified."); + } + + if (StringUtils.isNullOrEmpty(description)) { + throw new IllegalArgumentException("A description must be specified."); + } + + if (perspectives.stream().anyMatch(p -> p.getName().equals(name))) { + throw new IllegalArgumentException("A perspective named \"" + name + "\" already exists."); + } + + Perspective perspective = new Perspective(name, description, value); + perspectives.add(perspective); + + return perspective; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeDslContext.java new file mode 100644 index 000000000..0f1dbb71f --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeDslContext.java @@ -0,0 +1,15 @@ +package com.structurizr.dsl; + +abstract class ArchetypeDslContext extends DslContext { + + private final Archetype archetype; + + ArchetypeDslContext(Archetype archetype) { + this.archetype = archetype; + } + + Archetype getArchetype() { + return archetype; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeParser.java new file mode 100644 index 000000000..1b5c00668 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypeParser.java @@ -0,0 +1,76 @@ +package com.structurizr.dsl; + +final class ArchetypeParser extends AbstractParser { + + private final static int NAME_INDEX = 1; + private final static int VALUE_INDEX = 1; + + void parseTag(ArchetypeDslContext context, Tokens tokens) { + // tag + if (tokens.hasMoreThan(VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: tag "); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: tag "); + } + + String tag = tokens.get(VALUE_INDEX); + context.getArchetype().addTags(tag); + } + + void parseTags(ArchetypeDslContext context, Tokens tokens) { + // tags [tags] + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: tags [tags]"); + } + + for (int i = NAME_INDEX; i < tokens.size(); i++) { + String tags = tokens.get(i); + context.getArchetype().addTags(tags.split(",")); + } + } + + void parseMetadata(ArchetypeDslContext context, Tokens tokens) { + // metadata + if (tokens.hasMoreThan(VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: metadata "); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: metadata "); + } + + String metadata = tokens.get(VALUE_INDEX); + context.getArchetype().setMetadata(metadata); + } + + void parseDescription(ArchetypeDslContext context, Tokens tokens) { + // description + if (tokens.hasMoreThan(VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: description "); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: description "); + } + + String description = tokens.get(VALUE_INDEX); + context.getArchetype().setDescription(description); + } + + void parseTechnology(ArchetypeDslContext context, Tokens tokens) { + // technology + if (tokens.hasMoreThan(VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(VALUE_INDEX); + context.getArchetype().setTechnology(technology); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypesDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypesDslContext.java new file mode 100644 index 000000000..d457913f7 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ArchetypesDslContext.java @@ -0,0 +1,14 @@ +package com.structurizr.dsl; + +final class ArchetypesDslContext extends DslContext { + + ArchetypesDslContext() { + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/AutoLayoutParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/AutoLayoutParser.java new file mode 100644 index 000000000..7a8be9e4d --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/AutoLayoutParser.java @@ -0,0 +1,70 @@ +package com.structurizr.dsl; + +import com.structurizr.view.AutomaticLayout; +import com.structurizr.view.ModelView; +import com.structurizr.view.View; + +import java.util.HashMap; +import java.util.Map; + +final class AutoLayoutParser extends AbstractParser { + + private static final int DEFAULT_RANK_SEPARATION = 300; + private static final int DEFAULT_NODE_SEPARATION = 300; + + private static final int RANK_DIRECTION_INDEX = 1; + private static final int RANK_SEPARATION_INDEX = 2; + private static final int NODE_SEPARATION_INDEX = 3; + + private static Map RANK_DIRECTIONS = new HashMap<>(); + + static { + RANK_DIRECTIONS.put("tb", AutomaticLayout.RankDirection.TopBottom); + RANK_DIRECTIONS.put("bt", AutomaticLayout.RankDirection.BottomTop); + RANK_DIRECTIONS.put("lr", AutomaticLayout.RankDirection.LeftRight); + RANK_DIRECTIONS.put("rl", AutomaticLayout.RankDirection.RightLeft); + } + + void parse(ModelViewDslContext context, Tokens tokens) { + // autoLayout [rankDirection] [rankSeparation] [nodeSeparation] + ModelView view = context.getView(); + if (view != null) { + AutomaticLayout.RankDirection rankDirection = AutomaticLayout.RankDirection.TopBottom; + int rankSeparation = DEFAULT_RANK_SEPARATION; + int nodeSeparation = DEFAULT_NODE_SEPARATION; + + if (tokens.includes(RANK_DIRECTION_INDEX)) { + String rankDirectionAsString = tokens.get(RANK_DIRECTION_INDEX); + + if (RANK_DIRECTIONS.containsKey(rankDirectionAsString)) { + rankDirection = RANK_DIRECTIONS.get(rankDirectionAsString); + } else { + throw new RuntimeException("Valid rank directions are: tb|bt|lr|rl"); + } + } + + if (tokens.includes(RANK_SEPARATION_INDEX)) { + String rankSeparationAsString = tokens.get(RANK_SEPARATION_INDEX); + + try { + rankSeparation = Integer.parseInt(rankSeparationAsString); + } catch (NumberFormatException e) { + throw new RuntimeException("Rank separation must be positive integer in pixels"); + } + } + + if (tokens.includes(NODE_SEPARATION_INDEX)) { + String nodeSeparationAsString = tokens.get(NODE_SEPARATION_INDEX); + + try { + nodeSeparation = Integer.parseInt(nodeSeparationAsString); + } catch (NumberFormatException e) { + throw new RuntimeException("Node separation must be positive integer in pixels"); + } + } + + view.enableAutomaticLayout(rankDirection, rankSeparation, nodeSeparation); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingDslContext.java new file mode 100644 index 000000000..e2b730cd4 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingDslContext.java @@ -0,0 +1,25 @@ +package com.structurizr.dsl; + +import java.io.File; + +final class BrandingDslContext extends DslContext { + + private File file; + + BrandingDslContext(File file) { + this.file = file; + } + + File getFile() { + return file; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.BRANDING_LOGO_TOKEN, + StructurizrDslTokens.BRANDING_FONT_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingParser.java new file mode 100644 index 000000000..add3a4712 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/BrandingParser.java @@ -0,0 +1,87 @@ +package com.structurizr.dsl; + +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.Url; +import com.structurizr.view.Font; + +import java.io.File; + +final class BrandingParser extends AbstractParser { + + private static final String LOGO_GRAMMAR = "logo "; + private static final String FONT_GRAMMAR = "font [url]"; + + private static final int LOGO_FILE_INDEX = 1; + + private static final int FONT_NAME_INDEX = 1; + private static final int FONT_URL_INDEX = 2; + + void parseLogo(BrandingDslContext context, Tokens tokens) { + // logo + + if (tokens.hasMoreThan(LOGO_FILE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + LOGO_GRAMMAR); + } else if (tokens.includes(LOGO_FILE_INDEX)) { + String path = tokens.get(1); + + if (path.startsWith("data:image/")) { + ImageUtils.validateImage(path); + context.getWorkspace().getViews().getConfiguration().getBranding().setLogo(path); + } else if (Url.isHttpsUrl(path)) { + if (context.getFeatures().isEnabled(Features.HTTPS)) { + ImageUtils.validateImage(path); + context.getWorkspace().getViews().getConfiguration().getBranding().setLogo(path); + } else { + throw new FeatureNotEnabledException(Features.HTTPS, "Icons via HTTPS are not permitted"); + } + } else if (Url.isHttpUrl(path)) { + if (context.getFeatures().isEnabled(Features.HTTP)) { + ImageUtils.validateImage(path); + context.getWorkspace().getViews().getConfiguration().getBranding().setLogo(path); + } else { + throw new FeatureNotEnabledException(Features.HTTP, "Icons via HTTP are not permitted"); + } + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(context.getFile().getParent(), path); + if (file.exists() && !file.isDirectory()) { + context.setDslPortable(false); + try { + String dataUri = ImageUtils.getImageAsDataUri(file); + context.getWorkspace().getViews().getConfiguration().getBranding().setLogo(dataUri); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else { + throw new RuntimeException(path + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "!branding is not permitted"); + } + } + } else { + throw new RuntimeException("Expected: " + LOGO_GRAMMAR); + } + } + + void parseFont(BrandingDslContext context, Tokens tokens) { + // font [url] + + if (tokens.hasMoreThan(FONT_URL_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + FONT_GRAMMAR); + } else if (tokens.includes(FONT_URL_INDEX)) { + String name = tokens.get(FONT_NAME_INDEX); + String url = tokens.get(FONT_URL_INDEX); + + context.getWorkspace().getViews().getConfiguration().getBranding().setFont(new Font(name, url)); + } else if (tokens.includes(FONT_NAME_INDEX)) { + String name = tokens.get(FONT_NAME_INDEX); + + context.getWorkspace().getViews().getConfiguration().getBranding().setFont(new Font(name)); + } else { + throw new RuntimeException("Expected: " + FONT_GRAMMAR); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CommentDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CommentDslContext.java new file mode 100644 index 000000000..24f802627 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CommentDslContext.java @@ -0,0 +1,10 @@ +package com.structurizr.dsl; + +final class CommentDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentArchetypeDslContext.java new file mode 100644 index 000000000..f81a925cb --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class ComponentArchetypeDslContext extends ElementArchetypeDslContext { + + ComponentArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentDslContext.java new file mode 100644 index 000000000..f207e3ebe --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentDslContext.java @@ -0,0 +1,45 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; + +final class ComponentDslContext extends GroupableElementDslContext { + + private Component component; + + ComponentDslContext(Component component) { + this.component = component; + } + + Component getComponent() { + return component; + } + + @Override + ModelItem getModelItem() { + return getComponent(); + } + + @Override + GroupableElement getElement() { + return component; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DOCS_TOKEN, + StructurizrDslTokens.DECISIONS_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderDslContext.java new file mode 100644 index 000000000..60eaf4a32 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderDslContext.java @@ -0,0 +1,47 @@ +package com.structurizr.dsl; + +import com.structurizr.component.ComponentFinderBuilder; +import com.structurizr.model.Component; + +import java.util.Set; + +final class ComponentFinderDslContext extends DslContext { + + private final ComponentFinderBuilder componentFinderBuilder = new ComponentFinderBuilder(); + + private final StructurizrDslParser dslParser; + private final ContainerDslContext containerDslContext; + + ComponentFinderDslContext(StructurizrDslParser dslParser, ContainerDslContext containerDslContext) { + this.dslParser = dslParser; + this.containerDslContext = containerDslContext; + componentFinderBuilder.forContainer(containerDslContext.getContainer()); + setDslPortable(false); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.COMPONENT_FINDER_CLASSES_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_SOURCE_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_TOKEN + }; + } + + ContainerDslContext getContainerDslContext() { + return containerDslContext; + } + + ComponentFinderBuilder getComponentFinderBuilder() { + return this.componentFinderBuilder; + } + + @Override + void end() { + Set components = componentFinderBuilder.build().run(); + for (Component component : components) { + dslParser.registerIdentifier(IdentifiersRegister.toIdentifier(component.getName()), component); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderParser.java new file mode 100644 index 000000000..f0931bc5a --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderParser.java @@ -0,0 +1,66 @@ +package com.structurizr.dsl; + +import com.structurizr.component.filter.ExcludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.filter.IncludeFullyQualifiedNameRegexFilter; + +final class ComponentFinderParser extends AbstractParser { + + private static final String CLASSES_GRAMMAR = "classes "; + private static final String SOURCE_GRAMMAR = "source "; + + private static final String FILTER_INCLUDE = "include"; + private static final String FILTER_EXCLUDE = "exclude"; + private static final String FILTER_FQN_REGEX = "fqn-regex"; + private static final String FILTER_GRAMMAR = "filter <" + FILTER_INCLUDE + "|" + FILTER_EXCLUDE + "> <" + FILTER_FQN_REGEX + "> [parameters]"; + + void parseClasses(ComponentFinderDslContext context, Tokens tokens) { + // classes + + if (tokens.hasMoreThan(1)) { + throw new RuntimeException("Too many tokens, expected: " + CLASSES_GRAMMAR); + } + + context.getComponentFinderBuilder().fromClasses(tokens.get(1)); + } + + void parseSource(ComponentFinderDslContext context, Tokens tokens) { + // source + + if (tokens.hasMoreThan(1)) { + throw new RuntimeException("Too many tokens, expected: " + SOURCE_GRAMMAR); + } + + context.getComponentFinderBuilder().fromSource(tokens.get(1)); + } + + void parseFilter(ComponentFinderDslContext context, Tokens tokens) { + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + FILTER_GRAMMAR); + } + + String includeOrExclude = tokens.get(1).toLowerCase(); + if (!"include".equalsIgnoreCase(includeOrExclude) && !"exclude".equalsIgnoreCase(includeOrExclude)) { + throw new RuntimeException("Filter mode should be \"" + FILTER_INCLUDE + "\" or \"" + FILTER_EXCLUDE + "\": " + FILTER_GRAMMAR); + } + + String type = tokens.get(2).toLowerCase(); + switch (type) { + case FILTER_FQN_REGEX: + if (tokens.size() == 4) { + String regex = tokens.get(3); + + if (FILTER_INCLUDE.equalsIgnoreCase(includeOrExclude)) { + context.getComponentFinderBuilder().filteredBy(new IncludeFullyQualifiedNameRegexFilter(regex)); + } else { + context.getComponentFinderBuilder().filteredBy(new ExcludeFullyQualifiedNameRegexFilter(regex)); + } + } else { + throw new RuntimeException("Expected: " + FILTER_GRAMMAR); + } + break; + default: + throw new IllegalArgumentException("Unknown filter: " + type); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyDslContext.java new file mode 100644 index 000000000..805de90b3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyDslContext.java @@ -0,0 +1,39 @@ +package com.structurizr.dsl; + +import com.structurizr.component.ComponentFinderStrategyBuilder; + +final class ComponentFinderStrategyDslContext extends DslContext { + + private final ComponentFinderDslContext componentFinderDslContext; + private final ComponentFinderStrategyBuilder componentFinderStrategyBuilder = new ComponentFinderStrategyBuilder(); + + ComponentFinderStrategyDslContext(ComponentFinderDslContext componentFinderDslContext) { + this.componentFinderDslContext = componentFinderDslContext; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_TECHNOLOGY_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_MATCHER_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_FILTER_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_SUPPORTING_TYPES_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_NAME_TOKEN, + StructurizrDslTokens.COMPONENT_FINDER_STRATEGY_FOREACH_TOKEN + }; + } + + ComponentFinderStrategyBuilder getComponentFinderStrategyBuilder() { + return this.componentFinderStrategyBuilder; + } + + ComponentFinderDslContext getComponentFinderDslContext() { + return this.componentFinderDslContext; + } + + @Override + void end() { + componentFinderDslContext.getComponentFinderBuilder().withStrategy(componentFinderStrategyBuilder.build()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyForEachDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyForEachDslContext.java new file mode 100644 index 000000000..fe765bf44 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyForEachDslContext.java @@ -0,0 +1,35 @@ +package com.structurizr.dsl; + +import java.util.ArrayList; +import java.util.List; + +final class ComponentFinderStrategyForEachDslContext extends DslContext { + + private final List dslLines = new ArrayList<>(); + + ComponentFinderStrategyForEachDslContext(ComponentFinderStrategyDslContext dslContext, StructurizrDslParser dslParser) { + dslContext.getComponentFinderStrategyBuilder().forEach(component -> { + try { + ContainerDslContext containerDslContext = dslContext.getComponentFinderDslContext().getContainerDslContext(); + if (containerDslContext.hasGroup()) { + component.setGroup(containerDslContext.getGroup().getName()); + containerDslContext.getGroup().addElement(component); + } + + dslParser.parse(dslLines, new ComponentDslContext(component)); + } catch (StructurizrDslParserException e) { + throw new RuntimeException(e); + } + }); + } + + void addLine(String line) { + this.dslLines.add(line); + } + + @Override + protected String[] getPermittedTokens() { + return new ComponentDslContext(null).getPermittedTokens(); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyParser.java new file mode 100644 index 000000000..9c73a68b9 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentFinderStrategyParser.java @@ -0,0 +1,284 @@ +package com.structurizr.dsl; + +import com.structurizr.component.description.FirstSentenceDescriptionStrategy; +import com.structurizr.component.description.TruncatedDescriptionStrategy; +import com.structurizr.component.filter.ExcludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.filter.IncludeFullyQualifiedNameRegexFilter; +import com.structurizr.component.matcher.*; +import com.structurizr.component.naming.DefaultPackageNamingStrategy; +import com.structurizr.component.naming.TypeNamingStrategy; +import com.structurizr.component.naming.FullyQualifiedNamingStrategy; +import com.structurizr.component.supporting.*; +import com.structurizr.component.url.PrefixSourceUrlStrategy; + +import java.io.File; +import java.util.List; + +final class ComponentFinderStrategyParser extends AbstractParser { + + private static final String TECHNOLOGY_GRAMMAR = "technology "; + + private static final String MATCHER_ANNOTATION = "annotation"; + private static final String MATCHER_EXTENDS = "extends"; + private static final String MATCHER_IMPLEMENTS = "implements"; + private static final String MATCHER_NAME_SUFFIX = "name-suffix"; + private static final String MATCHER_FQN_REGEX = "fqn-regex"; + private static final String MATCHER_GRAMMAR = "matcher <" + String.join("|", List.of(MATCHER_ANNOTATION, MATCHER_EXTENDS, MATCHER_IMPLEMENTS, MATCHER_NAME_SUFFIX, MATCHER_FQN_REGEX)) + "> [parameters]"; + private static final String MATCHER_ANNOTATION_GRAMMAR = "matcher annotation "; + private static final String MATCHER_EXTENDS_GRAMMAR = "matcher extends "; + private static final String MATCHER_IMPLEMENTS_GRAMMAR = "matcher implements "; + private static final String MATCHER_NAMESUFFIX_GRAMMAR = "matcher name-suffix "; + private static final String MATCHER_REGEX_GRAMMAR = "matcher fqn-regex "; + + private static final String FILTER_INCLUDE = "include"; + private static final String FILTER_EXCLUDE = "exclude"; + private static final String FILTER_FQN_REGEX = "fqn-regex"; + private static final String FILTER_GRAMMAR = "filter <" + FILTER_INCLUDE + "|" + FILTER_EXCLUDE + "> <" + FILTER_FQN_REGEX + "> [parameters]"; + + private static final String SUPPORTING_TYPES_ALL_REFERENCED = "all-referenced"; + private static final String SUPPORTING_TYPES_REFERENCED_IN_PACKAGE = "referenced-in-package"; + private static final String SUPPORTING_TYPES_IN_PACKAGE = "in-package"; + private static final String SUPPORTING_TYPES_UNDER_PACKAGE = "under-package"; + private static final String SUPPORTING_TYPES_IMPLEMENTATION_WITH_PREFIX = "implementation-prefix"; + private static final String SUPPORTING_TYPES_IMPLEMENTATION_WITH_SUFFIX = "implementation-suffix"; + private static final String SUPPORTING_TYPES_NONE = "none"; + private static final String SUPPORTING_TYPES_GRAMMAR = "supportingTypes <" + String.join("|", List.of(SUPPORTING_TYPES_ALL_REFERENCED, SUPPORTING_TYPES_REFERENCED_IN_PACKAGE, SUPPORTING_TYPES_IN_PACKAGE, SUPPORTING_TYPES_UNDER_PACKAGE, SUPPORTING_TYPES_IMPLEMENTATION_WITH_PREFIX, SUPPORTING_TYPES_IMPLEMENTATION_WITH_SUFFIX, SUPPORTING_TYPES_NONE)) + "> [parameters]"; + private static final String SUPPORTING_TYPES_IMPLEMENTATION_WITH_PREFIX_GRAMMAR = "supportingTypes implementation-prefix "; + private static final String SUPPORTING_TYPES_IMPLEMENTATION_WITH_SUFFIX_GRAMMAR = "supportingTypes implementation-suffix "; + + private static final String NAME_TYPE_NAME = "type-name"; + private static final String NAME_FQN = "fqn"; + private static final String NAME_PACKAGE = "package"; + private static final String NAME_GRAMMAR = "name <" + String.join("|", List.of(NAME_TYPE_NAME, NAME_FQN, NAME_PACKAGE)) + ">"; + + private static final String DESCRIPTION_TRUNCATED = "truncated"; + private static final String DESCRIPTION_FIRST_SENTENCE = "first-sentence"; + private static final String DESCRIPTION_GRAMMAR = "description <" + String.join("|", List.of(DESCRIPTION_FIRST_SENTENCE, DESCRIPTION_TRUNCATED)) + ">"; + private static final String DESCRIPTION_TRUNCATED_GRAMMAR = "description truncated "; + + private static final String URL_PREFIX_SRC = "prefix-src"; + private static final String URL_GRAMMAR = "url <" + String.join("|", List.of(URL_PREFIX_SRC)) + ">"; + private static final String URL_PREFIX_SRC_GRAMMAR = "url prefix-src "; + + void parseTechnology(ComponentFinderStrategyDslContext context, Tokens tokens) { + if (tokens.size() != 2) { + throw new RuntimeException("Expected: " + TECHNOLOGY_GRAMMAR); + } + + String name = tokens.get(1); + context.getComponentFinderStrategyBuilder().withTechnology(name); + } + + void parseMatcher(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 2) { + throw new RuntimeException("Too few tokens, expected: " + MATCHER_GRAMMAR); + } + + String type = tokens.get(1); + switch (type.toLowerCase()) { + case MATCHER_ANNOTATION: + if (tokens.size() == 3) { + String name = tokens.get(2); + + context.getComponentFinderStrategyBuilder().matchedBy(new AnnotationTypeMatcher(name)); + } else { + throw new RuntimeException("Expected: " + MATCHER_ANNOTATION_GRAMMAR); + } + break; + case MATCHER_EXTENDS: + if (tokens.size() == 3) { + String name = tokens.get(2); + + context.getComponentFinderStrategyBuilder().matchedBy(new ExtendsTypeMatcher(name)); + } else { + throw new RuntimeException("Expected: " + MATCHER_EXTENDS_GRAMMAR); + } + break; + case MATCHER_IMPLEMENTS: + if (tokens.size() == 3) { + String name = tokens.get(2); + + context.getComponentFinderStrategyBuilder().matchedBy(new ImplementsTypeMatcher(name)); + } else { + throw new RuntimeException("Expected: " + MATCHER_IMPLEMENTS_GRAMMAR); + } + break; + case MATCHER_NAME_SUFFIX: + if (tokens.size() == 3) { + String suffix = tokens.get(2); + + context.getComponentFinderStrategyBuilder().matchedBy(new NameSuffixTypeMatcher(suffix)); + } else { + throw new RuntimeException("Expected: " + MATCHER_NAMESUFFIX_GRAMMAR); + } + break; + case MATCHER_FQN_REGEX: + if (tokens.size() == 3) { + String regex = tokens.get(2); + + context.getComponentFinderStrategyBuilder().matchedBy(new RegexTypeMatcher(regex)); + } else { + throw new RuntimeException("Expected: " + MATCHER_REGEX_GRAMMAR); + } + break; + default: + try { + Class typeMatcherClass = context.loadClass(type, dslFile); + + TypeMatcher typeMatcher; + if (tokens.size() == 3) { + String parameter = tokens.get(2); + typeMatcher = typeMatcherClass.getDeclaredConstructor(String.class).newInstance(parameter); + } else { + typeMatcher = typeMatcherClass.getDeclaredConstructor().newInstance(); + } + + context.getComponentFinderStrategyBuilder().matchedBy(typeMatcher); + } catch (Exception e) { + throw new RuntimeException("Type matcher \"" + type + "\" could not be loaded - " + e.getClass() + ": " + e.getMessage()); + } + } + } + + void parseFilter(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + FILTER_GRAMMAR); + } + + String includeOrExclude = tokens.get(1).toLowerCase(); + if (!"include".equalsIgnoreCase(includeOrExclude) && !"exclude".equalsIgnoreCase(includeOrExclude)) { + throw new RuntimeException("Filter mode should be \"" + FILTER_INCLUDE + "\" or \"" + FILTER_EXCLUDE + "\": " + FILTER_GRAMMAR); + } + + String type = tokens.get(2).toLowerCase(); + switch (type) { + case FILTER_FQN_REGEX: + if (tokens.size() == 4) { + String regex = tokens.get(3); + + if (FILTER_INCLUDE.equalsIgnoreCase(includeOrExclude)) { + context.getComponentFinderStrategyBuilder().filteredBy(new IncludeFullyQualifiedNameRegexFilter(regex)); + } else { + context.getComponentFinderStrategyBuilder().filteredBy(new ExcludeFullyQualifiedNameRegexFilter(regex)); + } + } else { + throw new RuntimeException("Expected: " + FILTER_GRAMMAR); + } + break; + default: + throw new IllegalArgumentException("Unknown filter: " + type); + } + } + + void parseSupportingTypes(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 2) { + throw new RuntimeException("Too few tokens, expected: " + SUPPORTING_TYPES_GRAMMAR); + } + + String type = tokens.get(1).toLowerCase(); + switch (type) { + case SUPPORTING_TYPES_ALL_REFERENCED: + context.getComponentFinderStrategyBuilder().supportedBy(new AllReferencedTypesSupportingTypesStrategy()); + break; + case SUPPORTING_TYPES_REFERENCED_IN_PACKAGE: + context.getComponentFinderStrategyBuilder().supportedBy(new AllReferencedTypesInPackageSupportingTypesStrategy()); + break; + case SUPPORTING_TYPES_IN_PACKAGE: + context.getComponentFinderStrategyBuilder().supportedBy(new AllTypesInPackageSupportingTypesStrategy()); + break; + case SUPPORTING_TYPES_UNDER_PACKAGE: + context.getComponentFinderStrategyBuilder().supportedBy(new AllTypesUnderPackageSupportingTypesStrategy()); + break; + case SUPPORTING_TYPES_IMPLEMENTATION_WITH_PREFIX: + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + SUPPORTING_TYPES_IMPLEMENTATION_WITH_PREFIX_GRAMMAR); + } + + String prefix = tokens.get(2); + context.getComponentFinderStrategyBuilder().supportedBy(new ImplementationWithPrefixSupportingTypesStrategy(prefix)); + break; + case SUPPORTING_TYPES_IMPLEMENTATION_WITH_SUFFIX: + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + SUPPORTING_TYPES_IMPLEMENTATION_WITH_SUFFIX_GRAMMAR); + } + + String suffix = tokens.get(2); + context.getComponentFinderStrategyBuilder().supportedBy(new ImplementationWithSuffixSupportingTypesStrategy(suffix)); + break; + case SUPPORTING_TYPES_NONE: + context.getComponentFinderStrategyBuilder().supportedBy(new DefaultSupportingTypesStrategy()); + break; + default: + throw new IllegalArgumentException("Unknown supporting types strategy: " + type); + } + } + + void parseName(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 2) { + throw new RuntimeException("Too few tokens, expected: " + NAME_GRAMMAR); + } + + String type = tokens.get(1).toLowerCase(); + switch (type) { + case NAME_TYPE_NAME: + context.getComponentFinderStrategyBuilder().withName(new TypeNamingStrategy()); + break; + case NAME_FQN: + context.getComponentFinderStrategyBuilder().withName(new FullyQualifiedNamingStrategy()); + break; + case NAME_PACKAGE: + context.getComponentFinderStrategyBuilder().withName(new DefaultPackageNamingStrategy()); + break; + default: + throw new IllegalArgumentException("Unknown name strategy: " + type); + } + } + + void parseDescription(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 2) { + throw new RuntimeException("Too few tokens, expected: " + DESCRIPTION_GRAMMAR); + } + + String type = tokens.get(1).toLowerCase(); + switch (type) { + case DESCRIPTION_FIRST_SENTENCE: + context.getComponentFinderStrategyBuilder().withDescription(new FirstSentenceDescriptionStrategy()); + break; + case DESCRIPTION_TRUNCATED: + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + DESCRIPTION_TRUNCATED_GRAMMAR); + } + + try { + int maxLength = Integer.parseInt(tokens.get(2)); + context.getComponentFinderStrategyBuilder().withDescription(new TruncatedDescriptionStrategy(maxLength)); + } catch (NumberFormatException e) { + throw new RuntimeException("Max length must be an integer"); + } + break; + default: + throw new IllegalArgumentException("Unknown description strategy: " + type); + } + } + + void parseUrl(ComponentFinderStrategyDslContext context, Tokens tokens, File dslFile) { + if (tokens.size() < 2) { + throw new RuntimeException("Too few tokens, expected: " + URL_GRAMMAR); + } + + String type = tokens.get(1).toLowerCase(); + switch (type) { + case URL_PREFIX_SRC: + if (tokens.size() < 3) { + throw new RuntimeException("Too few tokens, expected: " + URL_PREFIX_SRC_GRAMMAR); + } + + String prefix = tokens.get(2); + context.getComponentFinderStrategyBuilder().withUrl(new PrefixSourceUrlStrategy(prefix)); + break; + default: + throw new IllegalArgumentException("Unknown URL strategy: " + type); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentParser.java new file mode 100644 index 000000000..cb3f88098 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentParser.java @@ -0,0 +1,87 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.model.Container; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class ComponentParser extends AbstractParser { + + private static final String GRAMMAR = "component [description] [technology] [tags]"; + + private final static int NAME_INDEX = 1; + private final static int DESCRIPTION_INDEX = 2; + private final static int TECHNOLOGY_INDEX = 3; + private final static int TAGS_INDEX = 4; + + Component parse(ContainerDslContext context, Tokens tokens, Archetype archetype) { + // component [description] [technology] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Container container = context.getContainer(); + Component component = null; + String name = tokens.get(NAME_INDEX); + + if (context.isExtendingWorkspace()) { + component = container.getComponentWithName(name); + } + + if (component == null) { + component = container.addComponent(name); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + component.setDescription(description); + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + component.setTechnology(technology); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + component.addTags(tags.toArray(new String[0])); + + component.addProperties(archetype.getProperties()); + component.addPerspectives(archetype.getPerspectives()); + + if (context.hasGroup()) { + component.setGroup(context.getGroup().getName()); + context.getGroup().addElement(component); + } + + return component; + } + + void parseTechnology(ComponentDslContext context, Tokens tokens) { + int index = 1; + + // technology + if (tokens.hasMoreThan(index)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(index)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(index); + context.getComponent().setTechnology(technology); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewDslContext.java new file mode 100644 index 000000000..31b482510 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewDslContext.java @@ -0,0 +1,11 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ComponentView; + +final class ComponentViewDslContext extends StaticViewDslContext { + + ComponentViewDslContext(ComponentView view) { + super(view); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewParser.java new file mode 100644 index 000000000..ef90c6963 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ComponentViewParser.java @@ -0,0 +1,57 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import com.structurizr.view.ComponentView; + +final class ComponentViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "component [key] [description] {"; + + private static final int CONTAINER_IDENTIFIER_INDEX = 1; + private static final int KEY_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + + ComponentView parse(DslContext context, Tokens tokens) { + // component [key] [description] { + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(CONTAINER_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + Container container; + String key = ""; + String description = ""; + + String containerIdentifier = tokens.get(CONTAINER_IDENTIFIER_INDEX); + Element element = context.getElement(containerIdentifier); + if (element == null) { + throw new RuntimeException("The container \"" + containerIdentifier + "\" does not exist"); + } + if (element instanceof Container) { + container = (Container)element; + } else { + throw new RuntimeException("The element \"" + containerIdentifier + "\" is not a container"); + } + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + ComponentView view = workspace.getViews().createComponentView(container, key, description); + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationDslContext.java new file mode 100644 index 000000000..18b211f57 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationDslContext.java @@ -0,0 +1,15 @@ +package com.structurizr.dsl; + +final class ConfigurationDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.SCOPE_TOKEN, + StructurizrDslTokens.VISIBILITY_TOKEN, + StructurizrDslTokens.USERS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationParser.java new file mode 100644 index 000000000..ddd24799e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ConfigurationParser.java @@ -0,0 +1,61 @@ +package com.structurizr.dsl; + +import com.structurizr.configuration.Visibility; +import com.structurizr.configuration.WorkspaceScope; + +final class ConfigurationParser extends AbstractParser { + + private static final String SCOPE_GRAMMAR = "scope "; + private static final String SCOPE_LANDSCAPE = "landscape"; + private static final String SCOPE_SOFTWARE_SYSTEM = "softwaresystem"; + private static final String SCOPE_NONE = "none"; + + private static final String VISIBILITY_GRAMMAR = "visibility "; + private static final String VISIBILITY_PRIVATE = "private"; + private static final String VISIBILITY_PUBLIC = "public"; + + private static final int FIRST_PROPERTY_INDEX = 1; + + void parseScope(DslContext context, Tokens tokens) { + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + SCOPE_GRAMMAR); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String scope = tokens.get(1).toLowerCase(); + + if (scope.equalsIgnoreCase(SCOPE_LANDSCAPE)) { + context.getWorkspace().getConfiguration().setScope(WorkspaceScope.Landscape); + } else if (scope.equalsIgnoreCase(SCOPE_SOFTWARE_SYSTEM)) { + context.getWorkspace().getConfiguration().setScope(WorkspaceScope.SoftwareSystem); + } else if (scope.equalsIgnoreCase(SCOPE_NONE)) { + context.getWorkspace().getConfiguration().setScope(null); + } else { + throw new RuntimeException("The scope \"" + scope + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: " + SCOPE_GRAMMAR); + } + } + + void parseVisibility(DslContext context, Tokens tokens) { + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + VISIBILITY_GRAMMAR); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String visibility = tokens.get(1).toLowerCase(); + + if (visibility.equalsIgnoreCase(VISIBILITY_PRIVATE)) { + context.getWorkspace().getConfiguration().setVisibility(Visibility.Private); + } else if (visibility.equalsIgnoreCase(VISIBILITY_PUBLIC)) { + context.getWorkspace().getConfiguration().setVisibility(Visibility.Public); + } else { + throw new RuntimeException("The visibility \"" + visibility + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: " + VISIBILITY_GRAMMAR); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerArchetypeDslContext.java new file mode 100644 index 000000000..676f1d548 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class ContainerArchetypeDslContext extends ElementArchetypeDslContext { + + ContainerArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerDslContext.java new file mode 100644 index 000000000..6a30bf792 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerDslContext.java @@ -0,0 +1,52 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; + +final class ContainerDslContext extends GroupableElementDslContext { + + private Container container; + + ContainerDslContext(Container container) { + this(container, null); + } + + ContainerDslContext(Container container, ElementGroup group) { + super(group); + + this.container = container; + } + + Container getContainer() { + return container; + } + + @Override + ModelItem getModelItem() { + return getContainer(); + } + + @Override + GroupableElement getElement() { + return container; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DOCS_TOKEN, + StructurizrDslTokens.DECISIONS_TOKEN, + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.COMPONENT_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceDslContext.java new file mode 100644 index 000000000..a8f1bc8bd --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceDslContext.java @@ -0,0 +1,48 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ContainerInstance; +import com.structurizr.model.Element; +import com.structurizr.model.ModelItem; +import com.structurizr.model.StaticStructureElementInstance; + +final class ContainerInstanceDslContext extends StaticStructureElementInstanceDslContext { + + private final ContainerInstance containerInstance; + + ContainerInstanceDslContext(ContainerInstance containerInstance) { + this.containerInstance = containerInstance; + } + + ContainerInstance getContainerInstance() { + return containerInstance; + } + + @Override + ModelItem getModelItem() { + return getContainerInstance(); + } + + @Override + Element getElement() { + return getContainerInstance(); + } + + @Override + StaticStructureElementInstance getElementInstance() { + return getContainerInstance(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.HEALTH_CHECK_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceParser.java new file mode 100644 index 000000000..04e52c35e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerInstanceParser.java @@ -0,0 +1,59 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.ContainerInstance; +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; + +import java.util.HashSet; +import java.util.Set; + +final class ContainerInstanceParser extends StaticStructureInstanceParser { + + private static final String GRAMMAR = "containerInstance [deploymentGroups] [tags]"; + + private static final int IDENTIFIER_INDEX = 1; + private static final int DEPLOYMENT_GROUPS_TOKEN = 2; + private static final int TAGS_INDEX = 3; + + ContainerInstance parse(DeploymentNodeDslContext context, Tokens tokens) { + // containerInstance [deploymentGroups] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String containerIdentifier = tokens.get(IDENTIFIER_INDEX); + + Element element = context.getElement(containerIdentifier, Container.class); + if (element == null) { + throw new RuntimeException("The container \"" + containerIdentifier + "\" does not exist"); + } + + DeploymentNode deploymentNode = context.getDeploymentNode(); + + Set deploymentGroups = new HashSet<>(); + if (tokens.includes(DEPLOYMENT_GROUPS_TOKEN)) { + deploymentGroups = getDeploymentGroups(context, tokens.get(DEPLOYMENT_GROUPS_TOKEN)); + } + + ContainerInstance containerInstance = deploymentNode.add((Container)element, deploymentGroups.toArray(new String[]{})); + + if (tokens.includes(TAGS_INDEX)) { + String tags = tokens.get(TAGS_INDEX); + containerInstance.addTags(tags.split(",")); + } + + if (context.hasGroup()) { + containerInstance.setGroup(context.getGroup().getName()); + context.getGroup().addElement(containerInstance); + } + + return containerInstance; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerParser.java new file mode 100644 index 000000000..33e81e553 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerParser.java @@ -0,0 +1,87 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class ContainerParser extends AbstractParser { + + private static final String GRAMMAR = "container [description] [technology] [tags]"; + + private final static int NAME_INDEX = 1; + private final static int DESCRIPTION_INDEX = 2; + private final static int TECHNOLOGY_INDEX = 3; + private final static int TAGS_INDEX = 4; + + Container parse(SoftwareSystemDslContext context, Tokens tokens, Archetype archetype) { + // container [description] [technology] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + SoftwareSystem softwareSystem = context.getSoftwareSystem(); + Container container = null; + String name = tokens.get(NAME_INDEX); + + if (context.isExtendingWorkspace()) { + container = softwareSystem.getContainerWithName(name); + } + + if (container == null) { + container = softwareSystem.addContainer(name); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + container.setDescription(description); + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + container.setTechnology(technology); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + container.addTags(tags.toArray(new String[0])); + + container.addProperties(archetype.getProperties()); + container.addPerspectives(archetype.getPerspectives()); + + if (context.hasGroup()) { + container.setGroup(context.getGroup().getName()); + context.getGroup().addElement(container); + } + + return container; + } + + void parseTechnology(ContainerDslContext context, Tokens tokens) { + int index = 1; + + // technology + if (tokens.hasMoreThan(index)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(index)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(index); + context.getContainer().setTechnology(technology); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewDslContext.java new file mode 100644 index 000000000..c304fd247 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewDslContext.java @@ -0,0 +1,11 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ContainerView; + +final class ContainerViewDslContext extends StaticViewDslContext { + + ContainerViewDslContext(ContainerView view) { + super(view); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewParser.java new file mode 100644 index 000000000..48de8e65c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ContainerViewParser.java @@ -0,0 +1,57 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.ContainerView; + +final class ContainerViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "container [key] [description] {"; + + private static final int SOFTWARE_SYSTEM_IDENTIFIER_INDEX = 1; + private static final int KEY_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + + ContainerView parse(DslContext context, Tokens tokens) { + // container [key] [description] { + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(SOFTWARE_SYSTEM_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + SoftwareSystem softwareSystem; + String key = ""; + String description = ""; + + String softwareSystemIdentifier = tokens.get(SOFTWARE_SYSTEM_IDENTIFIER_INDEX); + Element element = context.getElement(softwareSystemIdentifier); + if (element == null) { + throw new RuntimeException("The software system \"" + softwareSystemIdentifier + "\" does not exist"); + } + if (element instanceof SoftwareSystem) { + softwareSystem = (SoftwareSystem)element; + } else { + throw new RuntimeException("The element \"" + softwareSystemIdentifier + "\" is not a software system"); + } + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + ContainerView view = workspace.getViews().createContainerView(softwareSystem, key, description); + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementArchetypeDslContext.java new file mode 100644 index 000000000..4bad680b8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class CustomElementArchetypeDslContext extends ElementArchetypeDslContext { + + CustomElementArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.METADATA_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementDslContext.java new file mode 100644 index 000000000..78daacea1 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementDslContext.java @@ -0,0 +1,41 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; + +final class CustomElementDslContext extends GroupableElementDslContext { + + private CustomElement customElement; + + CustomElementDslContext(CustomElement customElement) { + this.customElement = customElement; + } + + CustomElement getCustomElement() { + return customElement; + } + + @Override + ModelItem getModelItem() { + return getCustomElement(); + } + + @Override + GroupableElement getElement() { + return customElement; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementParser.java new file mode 100644 index 000000000..06d9e6e65 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomElementParser.java @@ -0,0 +1,58 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import com.structurizr.model.Location; +import com.structurizr.model.Person; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class CustomElementParser extends AbstractParser { + + private static final String GRAMMAR = "element [metadata] [description] [tags]"; + + private final static int NAME_INDEX = 1; + private final static int METADATA_INDEX = 2; + private final static int DESCRIPTION_INDEX = 3; + private final static int TAGS_INDEX = 4; + + CustomElement parse(ModelDslContext context, Tokens tokens, Archetype archetype) { + // element [metadata] [description] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String name = tokens.get(NAME_INDEX); + + String metadata = archetype.getMetadata(); + if (tokens.includes(METADATA_INDEX)) { + metadata = tokens.get(METADATA_INDEX); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + CustomElement customElement = context.getWorkspace().getModel().addCustomElement(name, metadata, description); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + customElement.addTags(tags.toArray(new String[0])); + + if (context.hasGroup()) { + customElement.setGroup(context.getGroup().getName()); + } + + return customElement; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationDslContext.java new file mode 100644 index 000000000..df19917a8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationDslContext.java @@ -0,0 +1,26 @@ +package com.structurizr.dsl; + +import com.structurizr.view.CustomView; + +class CustomViewAnimationDslContext extends DslContext { + + private CustomView view; + + CustomViewAnimationDslContext(CustomView view) { + super(); + + this.view = view; + } + + CustomView getView() { + return view; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ANIMATION_STEP_IN_VIEW_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationStepParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationStepParser.java new file mode 100644 index 000000000..e0e32202b --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewAnimationStepParser.java @@ -0,0 +1,70 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.CustomView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +final class CustomViewAnimationStepParser extends AbstractParser { + + private static final String GRAMMAR = " [identifier|element expression...]"; + + void parse(CustomViewDslContext context, Tokens tokens) { + // animationStep [identifier|element expression...] + + if (!tokens.includes(1)) { + throw new RuntimeException("Expected: animationStep " + GRAMMAR); + } + + parse(context, context.getCustomView(), tokens, 1); + } + + void parse(CustomViewAnimationDslContext context, Tokens tokens) { + // [identifier|element expression...] + + if (!tokens.includes(0)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + parse(context, context.getView(), tokens, 0); + } + + void parse(DslContext context, CustomView view, Tokens tokens, int startIndex) { + List customElements = new ArrayList<>(); + + for (int i = startIndex; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (ExpressionParser.isExpression(token.toLowerCase())) { + Set elements = new CustomViewExpressionParser().parseExpression(token, context); + + for (ModelItem element : elements) { + if (element instanceof CustomElement) { + customElements.add((CustomElement)element); + } + } + } else { + Set elements = new CustomViewExpressionParser().parseIdentifier(token, context); + + if (elements.isEmpty()) { + throw new RuntimeException("The element \"" + token + "\" does not exist"); + } + + for (ModelItem element : elements) { + if (element instanceof CustomElement) { + customElements.add((CustomElement)element); + } + } + } + } + + if (!customElements.isEmpty()) { + view.addAnimation(customElements.toArray(new CustomElement[0])); + } else { + throw new RuntimeException("No custom elements were found"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewContentParser.java new file mode 100644 index 000000000..47e97a6cc --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewContentParser.java @@ -0,0 +1,97 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import com.structurizr.model.Element; +import com.structurizr.model.ModelItem; +import com.structurizr.model.Relationship; +import com.structurizr.util.StringUtils; +import com.structurizr.view.CustomView; +import com.structurizr.view.ElementNotPermittedInViewException; + +final class CustomViewContentParser extends ModelViewContentParser { + + private static final int FIRST_IDENTIFIER_INDEX = 1; + + void parseInclude(CustomViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: include <*|identifier> [*|identifier...]"); + } + + CustomView view = context.getCustomView(); + + // include [identifier...] + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (token.equals(WILDCARD) || token.equals(ELEMENT_WILDCARD)) { + // include * or include element==* + view.addDefaultElements(); + } else if (isExpression(token)) { + new CustomViewExpressionParser().parseExpression(token, context).forEach(mi -> addModelItemToView(mi, view, null)); + } else { + new CustomViewExpressionParser().parseIdentifier(token, context).forEach(mi -> addModelItemToView(mi, view, token)); + } + } + } + + void parseExclude(CustomViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: exclude [identifier...]"); + } + + CustomView view = context.getCustomView(); + + // exclude [identifier...] + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + if (isExpression(token)) { + new CustomViewExpressionParser().parseExpression(token, context).forEach(mi -> removeModelItemFromView(mi, view)); + } else { + new CustomViewExpressionParser().parseIdentifier(token, context).forEach(mi -> removeModelItemFromView(mi, view)); + } + } + } + + private void addModelItemToView(ModelItem modelItem, CustomView view, String identifier) { + if (modelItem instanceof Element) { + addElementToView((Element)modelItem, view, identifier); + } else { + addRelationshipToView((Relationship)modelItem, view); + } + } + + private void addElementToView(Element element, CustomView view, String identifier) { + try { + if (element instanceof CustomElement) { + view.add((CustomElement) element); + } else { + if (!StringUtils.isNullOrEmpty(identifier)) { + throw new RuntimeException("The element \"" + identifier + "\" can not be added to this type of view"); + } + } + } catch (ElementNotPermittedInViewException e) { + // ignore + } + } + + private void removeModelItemFromView(ModelItem modelItem, CustomView view) { + if (modelItem instanceof Element) { + removeElementFromView((Element)modelItem, view); + } else { + removeRelationshipFromView((Relationship)modelItem, view); + } + } + + private void removeElementFromView(Element element, CustomView view) { + if (element instanceof CustomElement) { + view.remove((CustomElement) element); + } + } + + private void addRelationshipToView(Relationship relationship, CustomView view) { + if (view.isElementInView(relationship.getSource()) && view.isElementInView(relationship.getDestination())) { + view.add(relationship); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewDslContext.java new file mode 100644 index 000000000..e434f9bed --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.view.CustomView; + +final class CustomViewDslContext extends ModelViewDslContext { + + CustomViewDslContext(CustomView view) { + super(view); + } + + public CustomView getCustomView() { + return (CustomView)super.getView(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.INCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.EXCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.AUTOLAYOUT_VIEW_TOKEN, + StructurizrDslTokens.DEFAULT_VIEW_TOKEN, + StructurizrDslTokens.ANIMATION_IN_VIEW_TOKEN, + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewExpressionParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewExpressionParser.java new file mode 100644 index 000000000..16e5be56c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewExpressionParser.java @@ -0,0 +1,45 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import com.structurizr.model.Element; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static com.structurizr.dsl.StructurizrDslExpressions.ELEMENT_TYPE_EQUALS_EXPRESSION; + +final class CustomViewExpressionParser extends ExpressionParser { + + @Override + protected Set evaluateElementTypeExpression(String expr, DslContext context) { + Set elements = new LinkedHashSet<>(); + + String type = expr.substring(ELEMENT_TYPE_EQUALS_EXPRESSION.length()); + switch (type.toLowerCase()) { + case "custom": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof CustomElement).forEach(elements::add); + break; + default: + throw new RuntimeException("The element type of \"" + type + "\" is not valid for this view"); + } + + return elements; + } + + protected Set findAfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findAfferentCouplings(element, CustomElement.class)); + + return elements; + } + + protected Set findEfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findEfferentCouplings(element, CustomElement.class)); + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewParser.java new file mode 100644 index 000000000..bd7ffdcb3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/CustomViewParser.java @@ -0,0 +1,44 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.view.CustomView; + +final class CustomViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "custom [key] [title] [description] {"; + + private static final int KEY_INDEX = 1; + private static final int TITLE_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + + CustomView parse(DslContext context, Tokens tokens) { + // custom [key] [title] [description] + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + String key = ""; + String title = ""; + String description = ""; + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (tokens.includes(TITLE_INDEX)) { + title = tokens.get(TITLE_INDEX); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + CustomView view = workspace.getViews().createCustomView(key, title, description); + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DecisionsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DecisionsParser.java new file mode 100644 index 000000000..cf380e068 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DecisionsParser.java @@ -0,0 +1,95 @@ +package com.structurizr.dsl; + +import com.structurizr.documentation.Documentable; +import com.structurizr.importer.documentation.DefaultImageImporter; +import com.structurizr.importer.documentation.DocumentationImporter; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.util.HashMap; +import java.util.Map; + +final class DecisionsParser extends AbstractParser { + + private static final Map DECISION_IMPORTERS = new HashMap<>(); + + private static final String ADRTOOLS_DECISION_IMPORTER = "adrtools"; + private static final String LOG4BRAINS_DECISION_IMPORTER = "log4brains"; + private static final String MADR_DECISION_IMPORTER = "madr"; + + static { + DECISION_IMPORTERS.put(ADRTOOLS_DECISION_IMPORTER, "com.structurizr.importer.documentation.AdrToolsDecisionImporter"); + DECISION_IMPORTERS.put(MADR_DECISION_IMPORTER, "com.structurizr.importer.documentation.MadrDecisionImporter"); + DECISION_IMPORTERS.put(LOG4BRAINS_DECISION_IMPORTER, "com.structurizr.importer.documentation.Log4brainsDecisionImporter"); + } + + private static final String GRAMMAR = "!decisions "; + + private static final int PATH_INDEX = 1; + private static final int TYPE_OR_FQN_INDEX = 2; + + void parse(WorkspaceDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getWorkspace(), dslFile, tokens); + } + + void parse(SoftwareSystemDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getSoftwareSystem(), dslFile, tokens); + } + + void parse(ContainerDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getContainer(), dslFile, tokens); + } + + void parse(ComponentDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getComponent(), dslFile, tokens); + } + + private void parse(DslContext context, Documentable documentable, File dslFile, Tokens tokens) { + // !adrs + + context.setDslPortable(false); + + if (tokens.hasMoreThan(TYPE_OR_FQN_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(PATH_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String fullyQualifiedClassName = DECISION_IMPORTERS.get(ADRTOOLS_DECISION_IMPORTER); + if (tokens.includes(TYPE_OR_FQN_INDEX)) { + String typeOrFullyQualifiedName = tokens.get(TYPE_OR_FQN_INDEX); + fullyQualifiedClassName = DECISION_IMPORTERS.getOrDefault(typeOrFullyQualifiedName, typeOrFullyQualifiedName); + } + + if (dslFile != null) { + File path = new File(dslFile.getParentFile(), tokens.get(PATH_INDEX)); + + if (!path.exists()) { + throw new RuntimeException("Documentation path " + path + " does not exist"); + } + + if (!path.isDirectory()) { + throw new RuntimeException("Documentation path " + path + " is not a directory"); + } + + try { + Class decisionImporterClass = context.loadClass(fullyQualifiedClassName, dslFile); + Constructor constructor = decisionImporterClass.getDeclaredConstructor(); + DocumentationImporter decisionImporter = (DocumentationImporter)constructor.newInstance(); + decisionImporter.importDocumentation(documentable, path); + + if (!tokens.includes(TYPE_OR_FQN_INDEX)) { + DefaultImageImporter imageImporter = new DefaultImageImporter(); + imageImporter.importDocumentation(documentable, path); + } + } catch (ClassNotFoundException cnfe) { + throw new RuntimeException("Error importing decisions from " + path.getAbsolutePath() + ": " + fullyQualifiedClassName + " was not found"); + } catch (Exception e) { + throw new RuntimeException("Error importing decisions from " + path.getAbsolutePath() + ": " + e.getMessage()); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DefaultViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DefaultViewParser.java new file mode 100644 index 000000000..919e2ec57 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DefaultViewParser.java @@ -0,0 +1,12 @@ +package com.structurizr.dsl; + +import com.structurizr.view.View; + +final class DefaultViewParser extends AbstractParser { + + void parse(ViewDslContext context) { + View view = context.getView(); + context.getWorkspace().getViews().getConfiguration().setDefaultView(view); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironment.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironment.java new file mode 100644 index 000000000..2b7f60b6c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironment.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; + +import java.util.Objects; +import java.util.Set; + +class DeploymentEnvironment extends Element { + + private String name; + + DeploymentEnvironment(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getCanonicalName() { + return name; + } + + @Override + public Element getParent() { + return null; + } + + @Override + public Set getDefaultTags() { + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DeploymentEnvironment that = (DeploymentEnvironment) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentDslContext.java new file mode 100644 index 000000000..ff54ccfe8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentDslContext.java @@ -0,0 +1,42 @@ +package com.structurizr.dsl; + +class DeploymentEnvironmentDslContext extends DslContext implements GroupableDslContext { + + private final DeploymentEnvironment environment; + private final ElementGroup group; + + DeploymentEnvironmentDslContext(String environment) { + this.environment = new DeploymentEnvironment(environment); + this.group = null; + } + + DeploymentEnvironmentDslContext(String environment, ElementGroup group) { + this.environment = new DeploymentEnvironment(environment); + this.group = group; + } + + DeploymentEnvironment getEnvironment() { + return environment; + } + + @Override + public boolean hasGroup() { + return group != null; + } + + @Override + public ElementGroup getGroup() { + return group; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.DEPLOYMENT_GROUP_TOKEN, + StructurizrDslTokens.DEPLOYMENT_NODE_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentParser.java new file mode 100644 index 000000000..d206f5f60 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentEnvironmentParser.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class DeploymentEnvironmentParser extends AbstractParser { + + private static final String GRAMMAR = "deploymentEnvironment {"; + + private static final int DEPLOYMENT_ENVIRONMENT_NAME_INDEX = 1; + + String parse(Tokens tokens) { + // deploymentEnvironment + + if (tokens.hasMoreThan(DEPLOYMENT_ENVIRONMENT_NAME_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } else if (tokens.size() != DEPLOYMENT_ENVIRONMENT_NAME_INDEX + 1) { + throw new RuntimeException("Expected: " + GRAMMAR); + } else { + return tokens.get(DEPLOYMENT_ENVIRONMENT_NAME_INDEX); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroup.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroup.java new file mode 100644 index 000000000..a89e9fb49 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroup.java @@ -0,0 +1,37 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; + +import java.util.Set; + +class DeploymentGroup extends Element { + + private final Element parent; + private final String name; + + DeploymentGroup(DeploymentEnvironment deploymentEnvironment, String name) { + this.parent = deploymentEnvironment; + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getCanonicalName() { + return name; + } + + @Override + public Element getParent() { + return parent; + } + + @Override + public Set getDefaultTags() { + return null; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroupParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroupParser.java new file mode 100644 index 000000000..ff0b79fc4 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentGroupParser.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class DeploymentGroupParser extends AbstractParser { + + private static final String GRAMMAR = "deploymentGroup "; + + private static final int DEPLOYMENT_GROUP_NAME_INDEX = 1; + + String parse(Tokens tokens) { + // deploymentGroup + + if (tokens.hasMoreThan(DEPLOYMENT_GROUP_NAME_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } else if (tokens.size() != DEPLOYMENT_GROUP_NAME_INDEX + 1) { + throw new RuntimeException("Expected: " + GRAMMAR); + } else { + return tokens.get(DEPLOYMENT_GROUP_NAME_INDEX); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeArchetypeDslContext.java new file mode 100644 index 000000000..0a5b3dc4a --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class DeploymentNodeArchetypeDslContext extends ElementArchetypeDslContext { + + DeploymentNodeArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeDslContext.java new file mode 100644 index 000000000..a347f75c7 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeDslContext.java @@ -0,0 +1,55 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; + +final class DeploymentNodeDslContext extends GroupableElementDslContext { + + private final DeploymentNode deploymentNode; + + DeploymentNodeDslContext(DeploymentNode deploymentNode) { + this(deploymentNode, null); + } + + public DeploymentNodeDslContext(DeploymentNode deploymentNode, ElementGroup group) { + super(group); + + this.deploymentNode = deploymentNode; + } + + DeploymentNode getDeploymentNode() { + return deploymentNode; + } + + @Override + ModelItem getModelItem() { + return getDeploymentNode(); + } + + @Override + GroupableElement getElement() { + return deploymentNode; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.DEPLOYMENT_NODE_TOKEN, + StructurizrDslTokens.INFRASTRUCTURE_NODE_TOKEN, + StructurizrDslTokens.INSTANCE_OF_TOKEN, + StructurizrDslTokens.SOFTWARE_SYSTEM_INSTANCE_TOKEN, + StructurizrDslTokens.CONTAINER_INSTANCE_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.INSTANCES_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeParser.java new file mode 100644 index 000000000..a6aa7d859 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentNodeParser.java @@ -0,0 +1,119 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class DeploymentNodeParser extends AbstractParser { + + private static final String GRAMMAR = "deploymentNode [description] [technology] [tags] [instances] {"; + + private static final int NAME_INDEX = 1; + private static final int DESCRIPTION_INDEX = 2; + private static final int TECHNOLOGY_INDEX = 3; + private static final int TAGS_INDEX = 4; + private static final int INSTANCES_INDEX = 5; + + DeploymentNode parse(DeploymentEnvironmentDslContext context, Tokens tokens, Archetype archetype) { + return parse(context, null, tokens, archetype); + } + + DeploymentNode parse(DeploymentNodeDslContext context, Tokens tokens, Archetype archetype) { + return parse(null, context, tokens, archetype); + } + + DeploymentNode parse(DeploymentEnvironmentDslContext deploymentEnvironmentDslContext, DeploymentNodeDslContext deploymentNodeDslContext, Tokens tokens, Archetype archetype) { + // deploymentNode [description] [technology] [tags] [instances] + + if (tokens.hasMoreThan(INSTANCES_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + DeploymentNode deploymentNode = null; + String name = tokens.get(NAME_INDEX); + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + if (deploymentEnvironmentDslContext != null) { + // add a root deployment node + deploymentNode = deploymentEnvironmentDslContext.getWorkspace().getModel().addDeploymentNode(deploymentEnvironmentDslContext.getEnvironment().getName(), name, description, technology); + + if (deploymentEnvironmentDslContext.hasGroup()) { + deploymentNode.setGroup(deploymentEnvironmentDslContext.getGroup().getName()); + deploymentEnvironmentDslContext.getGroup().addElement(deploymentNode); + } + } else { + deploymentNode = deploymentNodeDslContext.getDeploymentNode().addDeploymentNode(name, description, technology); + + if (deploymentNodeDslContext.hasGroup()) { + deploymentNode.setGroup(deploymentNodeDslContext.getGroup().getName()); + deploymentNodeDslContext.getGroup().addElement(deploymentNode); + } + } + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + deploymentNode.addTags(tags.toArray(new String[0])); + + deploymentNode.addProperties(archetype.getProperties()); + deploymentNode.addPerspectives(archetype.getPerspectives()); + + String instances = "1"; + if (tokens.includes(INSTANCES_INDEX)) { + instances = tokens.get(INSTANCES_INDEX); + deploymentNode.setInstances(instances); + } + + return deploymentNode; + } + + void parseTechnology(DeploymentNodeDslContext context, Tokens tokens) { + int index = 1; + + // technology + if (tokens.hasMoreThan(index)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(index)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(index); + context.getDeploymentNode().setTechnology(technology); + } + + void parseInstances(DeploymentNodeDslContext context, Tokens tokens) { + int index = 1; + + // instances + if (tokens.hasMoreThan(index)) { + throw new RuntimeException("Too many tokens, expected: instances "); + } + + if (!tokens.includes(index)) { + throw new RuntimeException("Expected: instances "); + } + + String instances = tokens.get(index); + context.getDeploymentNode().setInstances(instances); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationDslContext.java new file mode 100644 index 000000000..b08ad1465 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationDslContext.java @@ -0,0 +1,26 @@ +package com.structurizr.dsl; + +import com.structurizr.view.DeploymentView; + +class DeploymentViewAnimationDslContext extends DslContext { + + private DeploymentView view; + + DeploymentViewAnimationDslContext(DeploymentView view) { + super(); + + this.view = view; + } + + DeploymentView getView() { + return view; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ANIMATION_STEP_IN_VIEW_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationStepParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationStepParser.java new file mode 100644 index 000000000..c85e23c89 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewAnimationStepParser.java @@ -0,0 +1,79 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.DeploymentView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +final class DeploymentViewAnimationStepParser extends AbstractParser { + + private static final String GRAMMAR = " [identifier|element expression...]"; + + void parse(DeploymentViewDslContext context, Tokens tokens) { + // animationStep [identifier|element expression...] + + if (!tokens.includes(1)) { + throw new RuntimeException("Expected: animationStep " + GRAMMAR); + } + + parse(context, context.getView(), tokens, 1); + } + + void parse(DeploymentViewAnimationDslContext context, Tokens tokens) { + // [identifier|element expression...] + + if (!tokens.includes(0)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + parse(context, context.getView(), tokens, 0); + } + + private void parse(DslContext context, DeploymentView view, Tokens tokens, int startIndex) { + List staticStructureElementInstances = new ArrayList<>(); + List infrastructureNodes = new ArrayList<>(); + + for (int i = startIndex; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (ExpressionParser.isExpression(token.toLowerCase())) { + Set elements = new DeploymentViewExpressionParser().parseExpression(token, context); + + for (ModelItem element : elements) { + if (element instanceof StaticStructureElementInstance) { + staticStructureElementInstances.add((StaticStructureElementInstance)element); + } + + if (element instanceof InfrastructureNode) { + infrastructureNodes.add((InfrastructureNode)element); + } + } + } else { + Set elements = new DeploymentViewExpressionParser().parseIdentifier(token, context); + + if (elements.isEmpty()) { + throw new RuntimeException("The element \"" + token + "\" does not exist"); + } + + for (ModelItem element : elements) { + if (element instanceof StaticStructureElementInstance) { + staticStructureElementInstances.add((StaticStructureElementInstance)element); + } + + if (element instanceof InfrastructureNode) { + infrastructureNodes.add((InfrastructureNode)element); + } + } + } + } + + if (!(staticStructureElementInstances.isEmpty() && infrastructureNodes.isEmpty())) { + view.addAnimation(staticStructureElementInstances.toArray(new StaticStructureElementInstance[0]), infrastructureNodes.toArray(new InfrastructureNode[0])); + } else { + throw new RuntimeException("No software system instances, container instances, or infrastructure nodes were found"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewContentParser.java new file mode 100644 index 000000000..755f50e14 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewContentParser.java @@ -0,0 +1,153 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +final class DeploymentViewContentParser extends ModelViewContentParser { + + private static final int FIRST_IDENTIFIER_INDEX = 1; + + void parseInclude(DeploymentViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: include <*|identifier|expression> [*|identifier|expression...]"); + } + + DeploymentView view = context.getView(); + + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (token.equals(WILDCARD) || token.equals(ELEMENT_WILDCARD)) { + // include * or include element==* + view.addDefaultElements(); + } else if (isExpression(token)) { + new DeploymentViewExpressionParser().parseExpression(token, context).forEach(mi -> addModelItemToView(mi, view, null)); + } else { + new DeploymentViewExpressionParser().parseIdentifier(token, context).forEach(mi -> addModelItemToView(mi, view, token)); + } + } + } + + void parseExclude(DeploymentViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: exclude [identifier|expression...]"); + } + + DeploymentView view = context.getView(); + + + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (isExpression(token)) { + new DeploymentViewExpressionParser().parseExpression(token, context).forEach(e -> removeModelItemFromView(e, view)); + } else { + new DeploymentViewExpressionParser().parseIdentifier(token, context).forEach(mi -> removeModelItemFromView(mi, view)); + } + } + } + + private void addModelItemToView(ModelItem modelItem, DeploymentView view, String identifier) { + if (modelItem instanceof Element) { + addElementToView((Element)modelItem, view, identifier); + } else { + addRelationshipToView((Relationship)modelItem, view); + } + } + + private void addElementToView(Element element, DeploymentView view, String identifier) { + try { + if (element instanceof CustomElement) { + view.add((CustomElement) element); + } else if (element instanceof DeploymentNode) { + view.add((DeploymentNode) element); + } else if (element instanceof InfrastructureNode) { + view.add((InfrastructureNode) element); + } else if (element instanceof SoftwareSystem) { + // find instances of this software system + view.getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).map(e -> (SoftwareSystemInstance)e).filter(ssi -> ssi.getSoftwareSystem().equals(element) && ssi.getEnvironment().equals(view.getEnvironment())).forEach(view::add); + } else if (element instanceof SoftwareSystemInstance) { + view.add((SoftwareSystemInstance) element); + } else if (element instanceof Container) { + // find instances of this container + view.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getContainer().equals(element) && ci.getEnvironment().equals(view.getEnvironment())).forEach(view::add); + } else if (element instanceof ContainerInstance) { + view.add((ContainerInstance) element); + } else { + if (!StringUtils.isNullOrEmpty(identifier)) { + throw new RuntimeException("The element \"" + identifier + "\" can not be added to this type of view"); + } + } + } catch (ElementNotPermittedInViewException e) { + // ignore + } + } + + private void removeModelItemFromView(ModelItem modelItem, DeploymentView view) { + if (modelItem instanceof Element) { + removeElementFromView((Element)modelItem, view); + } else { + removeRelationshipFromView((Relationship)modelItem, view); + } + } + + private void removeElementFromView(Element element, DeploymentView view) { + if (element instanceof CustomElement) { + view.remove((CustomElement)element); + } else if (element instanceof DeploymentNode) { + view.remove((DeploymentNode)element); + } else if (element instanceof InfrastructureNode) { + view.remove((InfrastructureNode)element); + } else if (element instanceof SoftwareSystem) { + // find instances of this software system + view.getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).map(e -> (SoftwareSystemInstance)e).filter(ssi -> ssi.getSoftwareSystem().equals(element) && ssi.getEnvironment().equals(view.getEnvironment())).forEach(view::remove); + } else if (element instanceof SoftwareSystemInstance) { + view.remove((SoftwareSystemInstance)element); + } else if (element instanceof Container) { + // find instances of this container + view.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getContainer().equals(element) && ci.getEnvironment().equals(view.getEnvironment())).forEach(view::remove); + } else if (element instanceof ContainerInstance) { + view.remove((ContainerInstance)element); + } + } + + private void addRelationshipToView(Relationship relationship, DeploymentView view) { + if (view.isElementInView(relationship.getSource()) && view.isElementInView(relationship.getDestination())) { + view.add(relationship); + } else { + // we have a relationship, but the source and/or destination elements are not present in the view + // if both sides are static structure elements, then perhaps there's a replicated version of the relationship that should be added instead + Element sourceElement = relationship.getSource(); + Element destinationElement = relationship.getDestination(); + + if ((sourceElement instanceof SoftwareSystem || sourceElement instanceof Container) && (destinationElement instanceof SoftwareSystem || destinationElement instanceof Container)) { + String relationshipId = relationship.getId(); + + Set replicatedRelationships = view.getModel().getRelationships().stream().filter(r -> relationshipId.equals(r.getLinkedRelationshipId())).collect(Collectors.toSet()); + for (Relationship replicatedRelationship : replicatedRelationships) { + if (view.isElementInView(replicatedRelationship.getSource()) && view.isElementInView(replicatedRelationship.getDestination())) { + view.add(replicatedRelationship); + } + } + } + } + } + + @Override + protected void removeRelationshipFromView(Relationship relationship, ModelView view) { + // remove the specified relationship + view.remove(relationship); + + // and also remove any replicated versions of the specified relationship + Collection replicatedRelationshipsInView = view.getRelationships().stream().map(RelationshipView::getRelationship).filter(r -> r.getLinkedRelationshipId() != null && r.getLinkedRelationshipId().equals(relationship.getId())).collect(Collectors.toSet()); + for (Relationship r : replicatedRelationshipsInView) { + view.remove(r); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewDslContext.java new file mode 100644 index 000000000..7aca919f2 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.view.DeploymentView; + +final class DeploymentViewDslContext extends ModelViewDslContext { + + DeploymentViewDslContext(DeploymentView view) { + super(view); + } + + DeploymentView getView() { + return (DeploymentView)super.getView(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.INCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.EXCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.AUTOLAYOUT_VIEW_TOKEN, + StructurizrDslTokens.DEFAULT_VIEW_TOKEN, + StructurizrDslTokens.ANIMATION_IN_VIEW_TOKEN, + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewExpressionParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewExpressionParser.java new file mode 100644 index 000000000..ea6266d86 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewExpressionParser.java @@ -0,0 +1,89 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.structurizr.dsl.StructurizrDslExpressions.ELEMENT_TYPE_EQUALS_EXPRESSION; + +final class DeploymentViewExpressionParser extends ExpressionParser { + + @Override + protected Set evaluateElementTypeExpression(String expr, DslContext context) { + Set elements = new LinkedHashSet<>(); + + String type = expr.substring(ELEMENT_TYPE_EQUALS_EXPRESSION.length()); + switch (type.toLowerCase()) { + case "custom": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof CustomElement).forEach(elements::add); + break; + case "deploymentnode": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).forEach(elements::add); + break; + case "infrastructurenode": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).forEach(elements::add); + break; + case "softwaresystem": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystem).forEach(elements::add); + break; + case "softwaresysteminstance": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).forEach(elements::add); + break; + case "container": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Container).forEach(elements::add); + break; + case "containerinstance": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).forEach(elements::add); + break; + default: + throw new RuntimeException("The element type of \"" + type + "\" is not valid for this view"); + } + + return elements; + } + + @Override + protected Set getElements(String identifier, DslContext context) { + Set elements = new LinkedHashSet<>(); + for (Element element : super.getElements(identifier, context)) { + if (element instanceof SoftwareSystem) { + Set elementInstances = context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).map(e -> (SoftwareSystemInstance) e).filter(ssi -> ssi.getSoftwareSystem().equals(element)).collect(Collectors.toSet()); + elements.addAll(elementInstances); + } else if (element instanceof Container) { + Set elementInstances = context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getContainer().equals(element)).collect(Collectors.toSet()); + elements.addAll(elementInstances); + } else { + elements.add(element); + } + } + + return elements; + } + + protected Set findAfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findAfferentCouplings(element, CustomElement.class)); + elements.addAll(findAfferentCouplings(element, DeploymentNode.class)); + elements.addAll(findAfferentCouplings(element, InfrastructureNode.class)); + elements.addAll(findAfferentCouplings(element, SoftwareSystemInstance.class)); + elements.addAll(findAfferentCouplings(element, ContainerInstance.class)); + + return elements; + } + + protected Set findEfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findEfferentCouplings(element, CustomElement.class)); + elements.addAll(findEfferentCouplings(element, DeploymentNode.class)); + elements.addAll(findEfferentCouplings(element, InfrastructureNode.class)); + elements.addAll(findEfferentCouplings(element, SoftwareSystemInstance.class)); + elements.addAll(findEfferentCouplings(element, ContainerInstance.class)); + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewParser.java new file mode 100644 index 000000000..32e493a71 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DeploymentViewParser.java @@ -0,0 +1,79 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DeploymentView; + +final class DeploymentViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "deployment <*|software system identifier> [key] [description] {"; + + private static final int SCOPE_IDENTIFIER_INDEX = 1; + private static final int ENVIRONMENT_INDEX = 2; + private static final int KEY_INDEX = 3; + private static final int DESCRIPTION_INDEX = 4; + + DeploymentView parse(DslContext context, Tokens tokens) { + // deployment <*|software system identifier> [key] [description] { + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(ENVIRONMENT_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + String key = ""; + String scopeIdentifier = tokens.get(SCOPE_IDENTIFIER_INDEX); + String environment = tokens.get(ENVIRONMENT_INDEX); + if (context.getElement(environment) != null && context.getElement(environment) instanceof DeploymentEnvironment) { + environment = context.getElement(environment).getName(); + } + + // check that the deployment environment exists in the model + final String env = environment; + if (context.getWorkspace().getModel().getDeploymentNodes().stream().noneMatch(dn -> dn.getEnvironment().equals(env))) { + throw new RuntimeException("The environment \"" + environment + "\" does not exist"); + } + + String description = ""; + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + DeploymentView view; + + if ("*".equals(scopeIdentifier)) { + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + view = workspace.getViews().createDeploymentView(key, description); + } else { + Element element = context.getElement(scopeIdentifier); + if (element == null) { + throw new RuntimeException("The software system \"" + scopeIdentifier + "\" does not exist"); + } + + if (element instanceof SoftwareSystem) { + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + view = workspace.getViews().createDeploymentView((SoftwareSystem)element, key, description); + } else { + throw new RuntimeException("The element \"" + scopeIdentifier + "\" is not a software system"); + } + } + + view.setEnvironment(environment); + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DocsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DocsParser.java new file mode 100644 index 000000000..8cb4e6d51 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DocsParser.java @@ -0,0 +1,78 @@ +package com.structurizr.dsl; + +import com.structurizr.documentation.Documentable; +import com.structurizr.importer.documentation.DefaultImageImporter; +import com.structurizr.importer.documentation.DocumentationImporter; + +import java.io.File; +import java.lang.reflect.Constructor; + +final class DocsParser extends AbstractParser { + + private static final String DEFAULT_DOCUMENT_IMPORTER = "com.structurizr.importer.documentation.DefaultDocumentationImporter"; + + private static final String GRAMMAR = "!docs "; + + private static final int PATH_INDEX = 1; + private static final int FQN_INDEX = 2; + + void parse(WorkspaceDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getWorkspace(), dslFile, tokens); + } + + void parse(SoftwareSystemDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getSoftwareSystem(), dslFile, tokens); + } + + void parse(ContainerDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getContainer(), dslFile, tokens); + } + + void parse(ComponentDslContext context, File dslFile, Tokens tokens) { + parse(context, context.getComponent(), dslFile, tokens); + } + + private void parse(DslContext context, Documentable documentable, File dslFile, Tokens tokens) { + // !docs + + context.setDslPortable(false); + + if (tokens.hasMoreThan(FQN_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(PATH_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String fullyQualifiedClassName = DEFAULT_DOCUMENT_IMPORTER; + if (tokens.includes(FQN_INDEX)) { + fullyQualifiedClassName = tokens.get(FQN_INDEX); + } + + if (dslFile != null) { + File path = new File(dslFile.getParentFile(), tokens.get(PATH_INDEX)); + + if (!path.exists()) { + throw new RuntimeException("Documentation path " + path + " does not exist"); + } + + try { + Class documentationImporterClass = context.loadClass(fullyQualifiedClassName, dslFile); + Constructor constructor = documentationImporterClass.getDeclaredConstructor(); + DocumentationImporter documentationImporter = (DocumentationImporter)constructor.newInstance(); + documentationImporter.importDocumentation(documentable, path); + + if (!tokens.includes(FQN_INDEX) && path.isDirectory()) { + DefaultImageImporter imageImporter = new DefaultImageImporter(); + imageImporter.importDocumentation(documentable, path); + } + } catch (ClassNotFoundException cnfe) { + throw new RuntimeException("Error importing documentation from " + path.getAbsolutePath() + ": " + fullyQualifiedClassName + " was not found"); + } catch (Exception e) { + throw new RuntimeException("Error importing documentation from " + path.getAbsolutePath() + ": " + e.getMessage()); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslContext.java new file mode 100644 index 000000000..3c31f3e76 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslContext.java @@ -0,0 +1,166 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; + +abstract class DslContext { + + private static final String PLUGINS_DIRECTORY_NAME = "plugins"; + + static final String CONTEXT_START_TOKEN = "{"; + static final String CONTEXT_END_TOKEN = "}"; + + private Workspace workspace; + private boolean extendingWorkspace; + private boolean dslPortable = true; + + protected IdentifiersRegister identifiersRegister = new IdentifiersRegister(); + + private Features features = new Features(); + private HttpClient httpClient = new HttpClient(); + + Workspace getWorkspace() { + return workspace; + } + + void setWorkspace(Workspace workspace) { + this.workspace = workspace; + } + + boolean isExtendingWorkspace() { + return extendingWorkspace; + } + + void setExtendingWorkspace(boolean extendingWorkspace) { + this.extendingWorkspace = extendingWorkspace; + } + + boolean isDslPortable() { + return dslPortable; + } + + void setDslPortable(boolean bool) { + this.dslPortable = bool; + } + + void setIdentifierRegister(IdentifiersRegister identifersRegister) { + this.identifiersRegister = identifersRegister; + } + + String findIdentifier(Element element) { + return identifiersRegister.findIdentifier(element); + } + + Element getElement(String identifier) { + return getElement(identifier, null); + } + + Element getElement(String identifier, Class type) { + Element element = null; + identifier = identifier.toLowerCase(); + + if (identifiersRegister.getIdentifierScope() == IdentifierScope.Hierarchical) { + ElementDslContext elementDslContext = null; + if (this instanceof ElementDslContext) { + elementDslContext = (ElementDslContext)this; + } else if (this instanceof ElementsDslContext) { + ElementsDslContext elementsDslContext = (ElementsDslContext)this; + if (elementsDslContext.getParentDslContext() instanceof ElementDslContext) { + elementDslContext = (ElementDslContext)elementsDslContext.getParentDslContext(); + } + } + + if (elementDslContext != null) { + Element parent = elementDslContext.getElement(); + while (parent != null && element == null) { + String parentIdentifier = identifiersRegister.findIdentifier(parent); + + element = identifiersRegister.getElement(parentIdentifier + "." + identifier); + parent = parent.getParent(); + + element = checkElementType(element, type); + } + } else if (this instanceof DeploymentEnvironmentDslContext) { + DeploymentEnvironmentDslContext deploymentEnvironmentDslContext = (DeploymentEnvironmentDslContext)this; + DeploymentEnvironment deploymentEnvironment = deploymentEnvironmentDslContext.getEnvironment(); + String parentIdentifier = identifiersRegister.findIdentifier(deploymentEnvironment); + + element = identifiersRegister.getElement(parentIdentifier + "." + identifier); + } + + if (element == null) { + // default to finding a top-level element + element = identifiersRegister.getElement(identifier); + } + } else { + element = identifiersRegister.getElement(identifier); + } + + element = checkElementType(element, type); + return element; + } + + Element checkElementType(Element element, Class type) { + if (element != null && type != null) { + if (!element.getClass().isAssignableFrom(type)) { + element = null; + } + } + + return element; + } + + Relationship getRelationship(String identifier) { + return identifiersRegister.getRelationship(identifier.toLowerCase()); + } + + Features getFeatures() { + return features; + } + + void setFeatures(Features features) { + this.features = features; + } + + HttpClient getHttpClient() { + return httpClient; + } + + void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + protected Class loadClass(String fqn, File dslFile) throws Exception { + File pluginsDirectory = new File(dslFile.getParent(), PLUGINS_DIRECTORY_NAME); + URL[] urls = new URL[0]; + + if (pluginsDirectory.exists()) { + File[] jarFiles = pluginsDirectory.listFiles((dir, name) -> name.endsWith(".jar")); + if (jarFiles != null) { + urls = new URL[jarFiles.length]; + for (int i = 0; i < jarFiles.length; i++) { + try { + urls[i] = jarFiles[i].toURI().toURL(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + + URLClassLoader childClassLoader = new URLClassLoader(urls, getClass().getClassLoader()); + return (Class) childClassLoader.loadClass(fqn); + } + + void end() { + } + + protected abstract String[] getPermittedTokens(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DslLine.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslLine.java new file mode 100644 index 000000000..b39952fa5 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslLine.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +/** + * Represents a line of DSL, and its line number from the source file. + */ +class DslLine { + + private final String source; + private final int lineNumber; + + DslLine(String processedSource, int lineNumber) { + this.source = processedSource; + this.lineNumber = lineNumber; + } + + String getSource() { + return source; + } + + int getLineNumber() { + return lineNumber; + } + + @Override + public String toString() { + return source; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DslParserContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslParserContext.java new file mode 100644 index 000000000..8713345de --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslParserContext.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +import java.io.File; + +final class DslParserContext extends DslContext { + + private final StructurizrDslParser parser; + private final File file; + + DslParserContext(StructurizrDslParser parser, File file) { + this.parser = parser; + this.file = file; + } + + StructurizrDslParser getParser() { + return parser; + } + + File getFile() { + return file; + } + + void copyFrom(IdentifiersRegister identifersRegister) { + for (String identifier : identifersRegister.getElementIdentifiers()) { + this.identifiersRegister.register(identifier, identifersRegister.getElement(identifier)); + } + + for (String identifier : identifersRegister.getRelationshipIdentifiers()) { + this.identifiersRegister.register(identifier, identifersRegister.getRelationship(identifier)); + } + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DslUtils.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslUtils.java new file mode 100644 index 000000000..33662ac87 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DslUtils.java @@ -0,0 +1,54 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Utility methods to get/set DSL on a workspace. + */ +public class DslUtils { + + static final String STRUCTURIZR_DSL_PROPERTY_NAME = "structurizr.dsl"; + static final String STRUCTURIZR_DSL_RETAIN_SOURCE_PROPERTY_NAME = "structurizr.dsl.source"; + + /** + * Gets the DSL associated with a workspace. + * + * @param workspace a Workspace object + * @return a DSL string + */ + public static String getDsl(Workspace workspace) { + String base64 = workspace.getProperties().get(STRUCTURIZR_DSL_PROPERTY_NAME); + if (!StringUtils.isNullOrEmpty(base64)) { + return new String(Base64.getDecoder().decode(base64)); + } + + return ""; + } + + /** + * Sets the DSL associated with a workspace. + * + * @param workspace a Workspace object + * @param dsl the DSL string + */ + public static void setDsl(Workspace workspace, String dsl) { + if (!StringUtils.isNullOrEmpty(dsl)) { + String base64 = Base64.getEncoder().encodeToString(dsl.getBytes(StandardCharsets.UTF_8)); + workspace.addProperty(STRUCTURIZR_DSL_PROPERTY_NAME, base64); + } + } + + /** + * Clears the DSL associated with a workspace. + * + * @param workspace a Workspace object + */ + public static void clearDsl(Workspace workspace) { + workspace.removeProperty(STRUCTURIZR_DSL_PROPERTY_NAME); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewContentParser.java new file mode 100644 index 000000000..1843c148e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewContentParser.java @@ -0,0 +1,120 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.model.StaticStructureElement; +import com.structurizr.util.StringUtils; +import com.structurizr.view.DynamicView; +import com.structurizr.view.RelationshipView; + +final class DynamicViewContentParser extends AbstractParser { + + private static final String GRAMMAR_1 = "[order:] -> [description] [technology]"; + private static final String GRAMMAR_2 = "[order:] [description]"; + + private static final String ORDER_DELIMITER = ":"; + + private static final int SOURCE_IDENTIFIER_INDEX = 0; + private static final int RELATIONSHIP_TOKEN_INDEX = 1; + private static final int DESTINATION_IDENTIFIER_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + private static final int TECHNOLOGY_INDEX = 4; + + private static final int RELATIONSHIP_IDENTIFIER_INDEX = 0; + + RelationshipView parseRelationship(DynamicViewDslContext context, Tokens tokens) { + DynamicView view = context.getView(); + RelationshipView relationshipView = null; + String order = null; + + if (tokens.size() > 0 && tokens.get(0).endsWith(ORDER_DELIMITER)) { + // the optional [order:] token + order = tokens.get(0); + order = order.substring(0, order.length()-ORDER_DELIMITER.length()); + tokens.remove(0); + } + + if (tokens.size() > 1 && StructurizrDslTokens.RELATIONSHIP_TOKEN.equals(tokens.get(RELATIONSHIP_TOKEN_INDEX))) { + // -> [description] [technology] + if (tokens.hasMoreThan(TECHNOLOGY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR_1); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR_1); + } + + String sourceId = tokens.get(SOURCE_IDENTIFIER_INDEX); + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + + Element sourceElement = context.getElement(sourceId); + if (sourceElement == null) { + throw new RuntimeException("The source element \"" + sourceId + "\" does not exist"); + } + + if (!(sourceElement instanceof StaticStructureElement || sourceElement instanceof CustomElement)) { + throw new RuntimeException("The source element \"" + sourceId + "\" should be a static structure or custom element"); + } + + Element destinationElement = context.getElement(destinationId); + if (destinationElement == null) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } + + if (!(destinationElement instanceof StaticStructureElement || destinationElement instanceof CustomElement)) { + throw new RuntimeException("The destination element \"" + destinationId + "\" should be a static structure or custom element"); + } + + String description = ""; + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = ""; + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + if (sourceElement instanceof StaticStructureElement && destinationElement instanceof StaticStructureElement) { + relationshipView = view.add((StaticStructureElement) sourceElement, description, technology, (StaticStructureElement) destinationElement); + } else if (sourceElement instanceof StaticStructureElement && destinationElement instanceof CustomElement) { + relationshipView = view.add((StaticStructureElement) sourceElement, description, technology, (CustomElement) destinationElement); + } else if (sourceElement instanceof CustomElement && destinationElement instanceof StaticStructureElement) { + relationshipView = view.add((CustomElement) sourceElement, description, technology, (StaticStructureElement) destinationElement); + } else if (sourceElement instanceof CustomElement && destinationElement instanceof CustomElement) { + relationshipView = view.add((CustomElement) sourceElement, description, technology, (CustomElement) destinationElement); + } + } else { + // [order] [description] [technology] + String relationshipId = tokens.get(RELATIONSHIP_IDENTIFIER_INDEX); + Relationship relationship = context.getRelationship(relationshipId); + + if (tokens.hasMoreThan(RELATIONSHIP_IDENTIFIER_INDEX+1)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR_2); + } + + if (relationship == null) { + throw new RuntimeException("The relationship \"" + relationshipId + "\" does not exist"); + } + + String description = ""; + if (tokens.includes(RELATIONSHIP_IDENTIFIER_INDEX+1)) { + description = tokens.get(RELATIONSHIP_IDENTIFIER_INDEX+1); + } + + relationshipView = view.add(relationship, description); + } + + if (relationshipView != null) { + if (!StringUtils.isNullOrEmpty(order)) { + relationshipView.setOrder(order); + } + + return relationshipView; + } + + throw new RuntimeException("The specified relationship could not be added"); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewDslContext.java new file mode 100644 index 000000000..6ec05d24e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewDslContext.java @@ -0,0 +1,27 @@ +package com.structurizr.dsl; + +import com.structurizr.view.DynamicView; + +class DynamicViewDslContext extends ModelViewDslContext { + + DynamicViewDslContext(DynamicView view) { + super(view); + } + + DynamicView getView() { + return (DynamicView)super.getView(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.AUTOLAYOUT_VIEW_TOKEN, + StructurizrDslTokens.DEFAULT_VIEW_TOKEN, + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParallelSequenceDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParallelSequenceDslContext.java new file mode 100644 index 000000000..316bf07a4 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParallelSequenceDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class DynamicViewParallelSequenceDslContext extends DynamicViewDslContext { + + private boolean relationships = false; + + DynamicViewParallelSequenceDslContext(DynamicViewDslContext context) { + super(context.getView()); + getView().startParallelSequence(); + } + + void hasRelationships(boolean definesRelationships) { + this.relationships = definesRelationships; + } + + @Override + void end() { + getView().endParallelSequence(!relationships); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParser.java new file mode 100644 index 000000000..16ec26fcf --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewParser.java @@ -0,0 +1,76 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DynamicView; + +class DynamicViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "dynamic <*|software system identifier|container identifier> [key] [description] {"; + + private static final int SCOPE_IDENTIFIER_INDEX = 1; + private static final int KEY_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + + private static final String WILDCARD = "*"; + + DynamicView parse(DslContext context, Tokens tokens) { + // dynamic <*|software system identifier|container identifier> [key] [description] { + + Workspace workspace = context.getWorkspace(); + String key = ""; + String description = ""; + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(SCOPE_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + DynamicView view; + + String scopeIdentifier = tokens.get(SCOPE_IDENTIFIER_INDEX); + if (WILDCARD.equals(scopeIdentifier)) { + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + view = workspace.getViews().createDynamicView(key, description); + } else { + Element element = context.getElement(scopeIdentifier); + if (element == null) { + throw new RuntimeException("The software system or container \"" + scopeIdentifier + "\" does not exist"); + } + + if (element instanceof SoftwareSystem) { + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + view = workspace.getViews().createDynamicView((SoftwareSystem)element, key, description); + } else if (element instanceof Container) { + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + view = workspace.getViews().createDynamicView((Container)element, key, description); + } else { + throw new RuntimeException("The element \"" + scopeIdentifier + "\" is not a software system or container"); + } + } + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipContext.java new file mode 100644 index 000000000..910ef6199 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipContext.java @@ -0,0 +1,26 @@ +package com.structurizr.dsl; + +import com.structurizr.view.RelationshipView; + +class DynamicViewRelationshipContext extends DslContext { + + private final RelationshipView relationshipView; + + DynamicViewRelationshipContext(RelationshipView relationshipView) { + super(); + + this.relationshipView = relationshipView; + } + + RelationshipView getRelationshipView() { + return relationshipView; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.URL_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipParser.java new file mode 100644 index 000000000..5562e4d39 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/DynamicViewRelationshipParser.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class DynamicViewRelationshipParser extends AbstractParser { + + private final static int URL_INDEX = 1; + + void parseUrl(DynamicViewRelationshipContext context, Tokens tokens) { + // url + if (tokens.hasMoreThan(URL_INDEX)) { + throw new RuntimeException("Too many tokens, expected: url "); + } + + if (!tokens.includes(URL_INDEX)) { + throw new RuntimeException("Expected: url "); + } + + String url = tokens.get(URL_INDEX); + context.getRelationshipView().setUrl(url); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementArchetypeDslContext.java new file mode 100644 index 000000000..40a53783c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementArchetypeDslContext.java @@ -0,0 +1,9 @@ +package com.structurizr.dsl; + +abstract class ElementArchetypeDslContext extends ArchetypeDslContext { + + ElementArchetypeDslContext(Archetype archetype) { + super(archetype); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementDslContext.java new file mode 100644 index 000000000..11a0594f9 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementDslContext.java @@ -0,0 +1,9 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; + +abstract class ElementDslContext extends ModelItemDslContext { + + abstract Element getElement(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementGroup.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementGroup.java new file mode 100644 index 000000000..7c5895cef --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementGroup.java @@ -0,0 +1,64 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.Model; +import com.structurizr.util.StringUtils; + +import java.util.HashSet; +import java.util.Set; + +class ElementGroup extends Element { + + private Element parent; + private final ElementGroup parentGroup; + private final String name; + + private final Set elements = new HashSet<>(); + + ElementGroup(String name) { + this.name = name; + this.parentGroup = null; + } + + ElementGroup(String name, String groupSeparator, ElementGroup parentGroup) { + this.name = parentGroup.getName() + groupSeparator + name; + this.parentGroup = parentGroup; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getCanonicalName() { + return name; + } + + void setParent(Element parent) { + this.parent = parent; + } + + @Override + public Element getParent() { + return parent; + } + + @Override + public Set getDefaultTags() { + return null; + } + + void addElement(Element element) { + elements.add(element); + + if (parentGroup != null) { + parentGroup.addElement(element); + } + } + + Set getElements() { + return new HashSet<>(elements); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleDslContext.java new file mode 100644 index 000000000..f99834d07 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleDslContext.java @@ -0,0 +1,47 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ElementStyle; + +import java.io.File; + +final class ElementStyleDslContext extends DslContext { + + private File file; + private ElementStyle style; + + ElementStyleDslContext(ElementStyle style, File file) { + this.style = style; + this.file = file; + } + + File getFile() { + return file; + } + + ElementStyle getStyle() { + return style; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ELEMENT_STYLE_SHAPE_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_ICON_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_ICON_POSITION_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_WIDTH_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_HEIGHT_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_BACKGROUND_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_COLOR_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_COLOUR_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_STROKE_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_STROKE_WIDTH_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_FONT_SIZE_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_BORDER_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_OPACITY_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_METADATA_TOKEN, + StructurizrDslTokens.ELEMENT_STYLE_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleParser.java new file mode 100644 index 000000000..4fe23bde3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementStyleParser.java @@ -0,0 +1,367 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.util.Url; +import com.structurizr.view.*; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +final class ElementStyleParser extends AbstractParser { + + private static final int FIRST_PROPERTY_INDEX = 1; + + ElementStyle parseElementStyle(StylesDslContext context, Tokens tokens) { + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: element {"); + } else if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String tag = tokens.get(1); + + if (StringUtils.isNullOrEmpty(tag)) { + throw new RuntimeException("A tag must be specified"); + } + + Workspace workspace = context.getWorkspace(); + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().getElementStyle(tag, context.getColorScheme()); + if (elementStyle == null) { + elementStyle = workspace.getViews().getConfiguration().getStyles().addElementStyle(tag, context.getColorScheme()); + } + + return elementStyle; + } else { + throw new RuntimeException("Expected: element {"); + } + } + + void parseShape(ElementStyleDslContext context, Tokens tokens) { + Map shapes = new HashMap<>(); + String shapesAsString = ""; + for (Shape shape : Shape.values()) { + shapes.put(shape.toString().toLowerCase(), shape); + shapesAsString += shape; + shapesAsString += "|"; + } + shapesAsString = shapesAsString.substring(0, shapesAsString.length()-1); + + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: shape <" + shapesAsString + ">"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String shape = tokens.get(1).toLowerCase(); + + if (shapes.containsKey(shape)) { + style.setShape(shapes.get(shape)); + } else { + throw new RuntimeException("The shape \"" + shape + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: shape <" + shapesAsString + ">"); + } + } + + void parseBackground(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: background <#rrggbb|color name>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String colour = tokens.get(1); + style.setBackground(colour); + } else { + throw new RuntimeException("Expected: background <#rrggbb|color name>"); + } + } + + void parseStroke(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: stroke <#rrggbb|color name>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String colour = tokens.get(1); + style.setStroke(colour); + } else { + throw new RuntimeException("Expected: stroke <#rrggbb|color name>"); + } + } + + void parseStrokeWidth(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: strokeWidth <1-10>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String strokeWidthAsString = tokens.get(1); + + try { + int strokeWidth = Integer.parseInt(strokeWidthAsString); + style.setStrokeWidth(strokeWidth); + } catch (NumberFormatException e) { + throw new RuntimeException("Stroke width must be an integer between 1 and 10"); + } + } else { + throw new RuntimeException("Expected: strokeWidth <1-10>"); + } + } + + void parseColour(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: colour <#rrggbb|color name>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String colour = tokens.get(1); + style.setColor(colour); + } else { + throw new RuntimeException("Expected: colour <#rrggbb|color name>"); + } + } + + void parseBorder(ElementStyleDslContext context, Tokens tokens) { + Map borders = new HashMap<>(); + for (Border border : Border.values()) { + borders.put(border.toString().toLowerCase(), border); + } + + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: border "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String border = tokens.get(1).toLowerCase(); + + if (borders.containsKey(border)) { + style.setBorder(borders.get(border)); + } else { + throw new RuntimeException("The border \"" + border + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: border "); + } + } + + void parseOpacity(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: opacity <0-100>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String opacityAsString = tokens.get(1); + + try { + int opacity = Integer.parseInt(opacityAsString); + style.setOpacity(opacity); + } catch (NumberFormatException e) { + throw new RuntimeException("Opacity must be an integer between 0 and 100"); + } + } else { + throw new RuntimeException("Expected: opacity <0-100>"); + } + } + + void parseWidth(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: width "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String widthAsString = tokens.get(1); + + try { + int width = Integer.parseInt(widthAsString); + style.setWidth(width); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Width must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: width "); + } + } + + void parseHeight(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: height "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String heightAsString = tokens.get(1); + + try { + int height = Integer.parseInt(heightAsString); + style.setHeight(height); + } catch (NumberFormatException e) { + throw new RuntimeException("Height must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: height "); + } + } + + void parseFontSize(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: fontSize "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String fontSizeAsString = tokens.get(1); + + try { + int fontSize = Integer.parseInt(fontSizeAsString); + style.setFontSize(fontSize); + } catch (NumberFormatException e) { + throw new RuntimeException("Font size must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: fontSize "); + } + } + + void parseMetadata(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: metadata "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String metadata = tokens.get(1); + + if ("true".equalsIgnoreCase(metadata)) { + style.setMetadata(true); + } else if ("false".equalsIgnoreCase(metadata)) { + style.setMetadata(false); + } else { + throw new RuntimeException("Metadata must be true or false"); + } + } else { + throw new RuntimeException("Expected: metadata "); + } + } + + void parseDescription(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: description "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String description = tokens.get(1); + + if ("true".equalsIgnoreCase(description)) { + style.setDescription(true); + } else if ("false".equalsIgnoreCase(description)) { + style.setDescription(false); + } else { + throw new RuntimeException("Description must be true or false"); + } + } else { + throw new RuntimeException("Expected: description "); + } + } + + void parseIcon(ElementStyleDslContext context, Tokens tokens) { + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: icon "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String path = tokens.get(1); + + if (path.startsWith("data:image/")) { + ImageUtils.validateImage(path); + style.setIcon(path); + } else if (Url.isHttpsUrl(path)) { + if (context.getFeatures().isEnabled(Features.HTTPS)) { + ImageUtils.validateImage(path); + style.setIcon(path); + } else { + throw new FeatureNotEnabledException(Features.HTTPS, "Icons via HTTPS are not permitted"); + } + } else if (Url.isHttpUrl(path)) { + if (context.getFeatures().isEnabled(Features.HTTP)) { + ImageUtils.validateImage(path); + style.setIcon(path); + } else { + throw new FeatureNotEnabledException(Features.HTTP, "Icons via HTTP are not permitted"); + } + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(context.getFile().getParent(), path); + if (file.exists() && !file.isDirectory()) { + try { + style.setIcon(ImageUtils.getImageAsDataUri(file)); + context.setDslPortable(false); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else { + throw new RuntimeException(path + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "!icon is not permitted"); + } + } + } else { + throw new RuntimeException("Expected: icon "); + } + } + + void parseIconPosition(ElementStyleDslContext context, Tokens tokens) { + Map iconPositions = new HashMap<>(); + String iconPositionsAsString = ""; + for (IconPosition iconPosition : IconPosition.values()) { + iconPositions.put(iconPosition.toString().toLowerCase(), iconPosition); + iconPositionsAsString += iconPosition; + iconPositionsAsString += "|"; + } + iconPositionsAsString = iconPositionsAsString.substring(0, iconPositionsAsString.length()-1); + + ElementStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: iconPosition <" + iconPositionsAsString + ">"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String iconPosition = tokens.get(1).toLowerCase(); + + if (iconPositions.containsKey(iconPosition)) { + style.setIconPosition(iconPositions.get(iconPosition)); + } else { + throw new RuntimeException("The icon position \"" + iconPosition + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: iconPosition <" + iconPositionsAsString + ">"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsDslContext.java new file mode 100644 index 000000000..3ef8706e3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsDslContext.java @@ -0,0 +1,42 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.ModelItem; + +import java.util.Set; +import java.util.stream.Collectors; + +class ElementsDslContext extends ModelItemsDslContext { + + private final Set elements; + + ElementsDslContext(DslContext parentDslContext, Set elements) { + super(parentDslContext); + + this.elements = elements; + } + + Set getElements() { + return elements; + } + + @Override + Set getModelItems() { + return elements.stream().map(e -> (ModelItem)e).collect(Collectors.toSet()); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsParser.java new file mode 100644 index 000000000..b2862307e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ElementsParser.java @@ -0,0 +1,50 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +final class ElementsParser extends AbstractParser { + + private final static int DESCRIPTION_INDEX = 1; + private final static int TECHNOLOGY_INDEX = 1; + + void parseDescription(ElementsDslContext context, Tokens tokens) { + // description + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: description "); + } + + if (!tokens.includes(DESCRIPTION_INDEX)) { + throw new RuntimeException("Expected: description "); + } + + String description = tokens.get(DESCRIPTION_INDEX); + for (Element element : context.getElements()) { + element.setDescription(description); + } + } + + void parseTechnology(ElementsDslContext context, Tokens tokens) { + // technology + if (tokens.hasMoreThan(TECHNOLOGY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(TECHNOLOGY_INDEX)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(TECHNOLOGY_INDEX); + for (Element element : context.getElements()) { + if (element instanceof Container) { + ((Container)element).setTechnology(technology); + } else if (element instanceof Component) { + ((Component)element).setTechnology(technology); + } else if (element instanceof DeploymentNode) { + ((DeploymentNode)element).setTechnology(technology); + } else if (element instanceof InfrastructureNode) { + ((InfrastructureNode)element).setTechnology(technology); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ExplicitRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExplicitRelationshipParser.java new file mode 100644 index 000000000..2158d596d --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExplicitRelationshipParser.java @@ -0,0 +1,194 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +import javax.lang.model.util.Elements; +import java.util.*; + +final class ExplicitRelationshipParser extends AbstractRelationshipParser { + + private static final String GRAMMAR = " -> [description] [technology] [tags]"; + + private static final int SOURCE_IDENTIFIER_INDEX = 0; + private static final int DESTINATION_IDENTIFIER_INDEX = 2; + private final static int DESCRIPTION_INDEX = 3; + private final static int TECHNOLOGY_INDEX = 4; + private final static int TAGS_INDEX = 5; + + Set parse(DslContext context, Tokens tokens, Archetype archetype) { + // -> [description] [technology] [tags] + + Set relationships = new HashSet<>(); + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String sourceId = tokens.get(SOURCE_IDENTIFIER_INDEX); + Element sourceElement = findElement(sourceId, context); + if (sourceElement == null) { + throw new RuntimeException("The source element \"" + sourceId + "\" does not exist"); + } + + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + Element destinationElement = findElement(destinationId, context); + if (destinationElement == null) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } else { + if (context instanceof NoRelationshipInDeploymentEnvironmentDslContext) { + description = ((NoRelationshipInDeploymentEnvironmentDslContext)context).getRelationship().getDescription(); + } + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } else { + if (context instanceof NoRelationshipInDeploymentEnvironmentDslContext) { + technology = ((NoRelationshipInDeploymentEnvironmentDslContext)context).getRelationship().getTechnology(); + } + } + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + + Set sourceElements = new HashSet<>(); + Set destinationElements = new HashSet<>(); + + if (context instanceof DeploymentEnvironmentDslContext || context instanceof DeploymentNodeDslContext) { + String deploymentEnvironment; + if (context instanceof DeploymentEnvironmentDslContext) { + deploymentEnvironment = ((DeploymentEnvironmentDslContext)context).getEnvironment().getName(); + } else { + deploymentEnvironment = ((DeploymentNodeDslContext)context).getDeploymentNode().getEnvironment(); + } + + if (sourceElement instanceof SoftwareSystem) { + // find the software system instances in the deployment environment + sourceElements.addAll(findSoftwareSystemInstances((SoftwareSystem)sourceElement, deploymentEnvironment)); + } else if (sourceElement instanceof Container) { + // find the container instances in the deployment environment + sourceElements.addAll(findContainerInstances((Container)sourceElement, deploymentEnvironment)); + } else { + sourceElements.add(sourceElement); + } + + if (destinationElement instanceof SoftwareSystem) { + // find the software system instances in the deployment environment + destinationElements.addAll(findSoftwareSystemInstances((SoftwareSystem)destinationElement, deploymentEnvironment)); + } else if (destinationElement instanceof Container) { + // find the container instances in the deployment environment + destinationElements.addAll(findContainerInstances((Container)destinationElement, deploymentEnvironment)); + } else { + destinationElements.add(destinationElement); + } + + for (Element se : sourceElements) { + for (Element de : destinationElements) { + Relationship relationship = createRelationship(se, description, technology, tags.toArray(new String[0]), de); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + } + } else { + Relationship relationship = createRelationship(sourceElement, description, technology, tags.toArray(new String[0]), destinationElement); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + + return relationships; + } + + Set parse(ElementsDslContext context, Tokens tokens, Archetype archetype) { + // -> [description] [technology] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String sourceId = tokens.get(SOURCE_IDENTIFIER_INDEX); + Set sourceElements = findElements(sourceId, context); + if (sourceElements.isEmpty()) { + throw new RuntimeException("The source element \"" + sourceId + "\" does not exist"); + } + + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + Set destinationElements = findElements(destinationId, context); + if (destinationElements.isEmpty()) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + String[] tags = archetype.getTags().toArray(new String[0]); + if (tokens.includes(TAGS_INDEX)) { + tags = tokens.get(TAGS_INDEX).split(","); + } + + Set relationships = new LinkedHashSet<>(); + for (Element sourceElement : sourceElements) { + for (Element destinationElement : destinationElements) { + Relationship relationship = createRelationship(sourceElement, description, technology, tags, destinationElement); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + } + + return relationships; + } + + private Element findElement(String identifier, DslContext context) { + Element element = context.getElement(identifier); + + if (element == null && StructurizrDslTokens.THIS_TOKEN.equalsIgnoreCase(identifier) && context instanceof ElementDslContext) { + element = ((ElementDslContext)context).getElement(); + } + + return element; + } + + private Set findElements(String identifier, ElementsDslContext context) { + Element element = context.getElement(identifier); + Set elements = new LinkedHashSet<>(); + + if (element == null) { + if (StructurizrDslTokens.THIS_TOKEN.equalsIgnoreCase(identifier)) { + elements.addAll(context.getElements()); + } + } else { + elements.add(element); + } + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ExpressionParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExpressionParser.java new file mode 100644 index 000000000..31c39eb98 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExpressionParser.java @@ -0,0 +1,463 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.structurizr.dsl.StructurizrDslExpressions.*; + +class ExpressionParser { + + private static final String WILDCARD = "*"; + + static boolean isExpression(String token) { + token = token.toLowerCase(); + + return + token.startsWith(ELEMENT_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_NOT_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_TYPE_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_TAG_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_TAG_NOT_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_TECHNOLOGY_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(ELEMENT_TECHNOLOGY_NOT_EQUALS_EXPRESSION.toLowerCase()) || + token.matches(ELEMENT_PROPERTY_EQUALS_EXPRESSION) || + token.startsWith(ELEMENT_PARENT_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(RELATIONSHIP) || token.endsWith(RELATIONSHIP) || token.contains(RELATIONSHIP) || + token.startsWith(RELATIONSHIP_TAG_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(RELATIONSHIP_TAG_NOT_EQUALS_EXPRESSION.toLowerCase()) || + token.matches(RELATIONSHIP_PROPERTY_EQUALS_EXPRESSION) || + token.startsWith(RELATIONSHIP_SOURCE_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(RELATIONSHIP_DESTINATION_EQUALS_EXPRESSION.toLowerCase()) || + token.startsWith(RELATIONSHIP_EQUALS_EXPRESSION); + } + + + final Set parseExpression(String expr, DslContext context) { + if (expr.contains(" && ")) { + String[] expressions = expr.split(" && "); + Set modelItems1 = evaluateExpression(expressions[0], context); + Set modelItems2 = evaluateExpression(expressions[1], context); + + Set modelItems = new HashSet<>(modelItems1); + modelItems.retainAll(modelItems2); + + return modelItems; + } else if (expr.contains(" || ")) { + String[] expressions = expr.split(" \\|\\| "); + Set modelItems1 = evaluateExpression(expressions[0], context); + Set modelItems2 = evaluateExpression(expressions[1], context); + + Set modelItems = new HashSet<>(modelItems1); + modelItems.addAll(modelItems2); + + return modelItems; + } else { + return evaluateExpression(expr, context); + } + } + + private Set evaluateExpression(String expr, DslContext context) { + Set modelItems = new LinkedHashSet<>(); + + if (expr.startsWith(ELEMENT_EQUALS_EXPRESSION)) { + expr = expr.substring(ELEMENT_EQUALS_EXPRESSION.length()); + + if (isExpression(expr)) { + modelItems.addAll(evaluateExpression(expr, context)); + } else { + modelItems.addAll(parseIdentifier(expr, context)); + } + } else if (expr.startsWith(ELEMENT_NOT_EQUALS_EXPRESSION)) { + expr = expr.substring(ELEMENT_NOT_EQUALS_EXPRESSION.length()); + + if (isExpression(expr)) { + Set mi = evaluateExpression(expr, context); + context.getWorkspace().getModel().getElements().forEach(element -> { + if (!mi.contains(element)) { + modelItems.add(element); + } + }); + } else { + Set mi = parseIdentifier(expr, context); + context.getWorkspace().getModel().getElements().forEach(element -> { + if (!mi.contains(element)) { + modelItems.add(element); + } + }); + } + } else if (expr.startsWith(RELATIONSHIP_EQUALS_EXPRESSION)) { + expr = expr.substring(RELATIONSHIP_EQUALS_EXPRESSION.length()); + + if (WILDCARD.equals(expr)) { + expr = WILDCARD + RELATIONSHIP + WILDCARD; + } + + if (isExpression(expr)) { + modelItems.addAll(evaluateExpression(expr, context)); + } else { + modelItems.addAll(parseIdentifier(expr, context)); + } + } else if (RELATIONSHIP.equals(expr)) { + throw new RuntimeException("Unexpected identifier \"->\""); + } else if (expr.startsWith(RELATIONSHIP) || expr.endsWith(RELATIONSHIP)) { + // this is an element expression: ->identifier identifier-> ->identifier-> + boolean includeAfferentCouplings = false; + boolean includeEfferentCouplings = false; + + String identifier = expr; + + if (identifier.startsWith(RELATIONSHIP)) { + includeAfferentCouplings = true; + identifier = identifier.substring(RELATIONSHIP.length()); + } + if (identifier.endsWith(RELATIONSHIP)) { + includeEfferentCouplings = true; + identifier = identifier.substring(0, identifier.length() - RELATIONSHIP.length()); + } + + identifier = identifier.trim(); + Set elements; + + if (isExpression(identifier)) { + elements = parseExpression(identifier, context).stream().filter(mi -> mi instanceof Element).map(mi -> (Element)mi).collect(Collectors.toSet()); + } else { + elements = getElements(identifier, context); + } + + if (elements.isEmpty()) { + throw new RuntimeException("The element \"" + identifier + "\" does not exist"); + } + + for (Element element : elements) { + modelItems.add(element); + + if (includeAfferentCouplings) { + modelItems.addAll(findAfferentCouplings(element)); + } + + if (includeEfferentCouplings) { + modelItems.addAll(findEfferentCouplings(element)); + } + } + } else if (expr.contains(RELATIONSHIP)) { + String[] identifiers = expr.split(RELATIONSHIP); + String sourceIdentifier = identifiers[0].trim(); + String destinationIdentifier = identifiers[1].trim(); + + String sourceExpression = RELATIONSHIP_SOURCE_EQUALS_EXPRESSION + sourceIdentifier; + String destinationExpression = RELATIONSHIP_DESTINATION_EQUALS_EXPRESSION + destinationIdentifier; + + if (WILDCARD.equals(sourceIdentifier) && WILDCARD.equals(destinationIdentifier)) { + modelItems.addAll(context.getWorkspace().getModel().getRelationships()); + } else if (WILDCARD.equals(destinationIdentifier)) { + modelItems.addAll(parseExpression(sourceExpression, context)); + } else if (WILDCARD.equals(sourceIdentifier)) { + modelItems.addAll(parseExpression(destinationExpression, context)); + } else { + modelItems.addAll(parseExpression(sourceExpression + " && " + destinationExpression, context)); + } + } else if (expr.toLowerCase().startsWith(ELEMENT_PARENT_EQUALS_EXPRESSION)) { + String parentIdentifier = expr.substring(ELEMENT_PARENT_EQUALS_EXPRESSION.length()); + Element parentElement = context.getElement(parentIdentifier); + if (parentElement == null) { + throw new RuntimeException("The parent element \"" + parentIdentifier + "\" does not exist"); + } else { + context.getWorkspace().getModel().getElements().forEach(element -> { + if (element.getParent() == parentElement) { + modelItems.add(element); + } + }); + } + } else if (expr.toLowerCase().startsWith(ELEMENT_TYPE_EQUALS_EXPRESSION)) { + modelItems.addAll(evaluateElementTypeExpression(expr, context)); + } else if (expr.toLowerCase().startsWith(ELEMENT_TAG_EQUALS_EXPRESSION.toLowerCase())) { + String[] tags = expr.substring(ELEMENT_TAG_EQUALS_EXPRESSION.length()).split(","); + context.getWorkspace().getModel().getElements().forEach(element -> { + if (hasAllTags(element, tags)) { + modelItems.add(element); + } + }); + } else if (expr.toLowerCase().startsWith(ELEMENT_TAG_NOT_EQUALS_EXPRESSION)) { + String[] tags = expr.substring(ELEMENT_TAG_NOT_EQUALS_EXPRESSION.length()).split(","); + context.getWorkspace().getModel().getElements().forEach(element -> { + if (!hasAllTags(element, tags)) { + modelItems.add(element); + } + }); + } else if (expr.toLowerCase().startsWith(ELEMENT_TECHNOLOGY_EQUALS_EXPRESSION.toLowerCase())) { + String technology = expr.substring(ELEMENT_TECHNOLOGY_EQUALS_EXPRESSION.length()); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Container).map(e -> (Container)e).filter(c -> technology.equals(c.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Component).map(e -> (Component)e).filter(c -> technology.equals(c.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).map(e -> (DeploymentNode)e).filter(dn -> technology.equals(dn.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).map(e -> (InfrastructureNode)e).filter(in -> technology.equals(in.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(c -> technology.equals(c.getContainer().getTechnology())).collect(Collectors.toSet())); + } else if (expr.toLowerCase().startsWith(ELEMENT_TECHNOLOGY_NOT_EQUALS_EXPRESSION)) { + String technology = expr.substring(ELEMENT_TECHNOLOGY_NOT_EQUALS_EXPRESSION.length()); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Container).map(e -> (Container)e).filter(c -> !technology.equals(c.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Component).map(e -> (Component)e).filter(c -> !technology.equals(c.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).map(e -> (DeploymentNode)e).filter(dn -> !technology.equals(dn.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).map(e -> (InfrastructureNode)e).filter(in -> !technology.equals(in.getTechnology())).collect(Collectors.toSet())); + modelItems.addAll(context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(c -> !technology.equals(c.getContainer().getTechnology())).collect(Collectors.toSet())); + } else if (expr.matches(ELEMENT_PROPERTY_EQUALS_EXPRESSION)) { + String propertyName = expr.substring(expr.indexOf("[")+1, expr.indexOf("]")); + String propertyValue = expr.substring(expr.indexOf("==")+2); + + context.getWorkspace().getModel().getElements().forEach(element -> { + if (hasProperty(element, propertyName, propertyValue)) { + modelItems.add(element); + } + }); + } else if (expr.startsWith(RELATIONSHIP_TAG_EQUALS_EXPRESSION)) { + String[] tags = expr.substring(RELATIONSHIP_TAG_EQUALS_EXPRESSION.length()).split(","); + context.getWorkspace().getModel().getRelationships().forEach(relationship -> { + if (hasAllTags(relationship, tags)) { + modelItems.add(relationship); + } + }); + } else if (expr.startsWith(RELATIONSHIP_TAG_NOT_EQUALS_EXPRESSION)) { + String[] tags = expr.substring(RELATIONSHIP_TAG_NOT_EQUALS_EXPRESSION.length()).split(","); + context.getWorkspace().getModel().getRelationships().forEach(relationship -> { + if (!hasAllTags(relationship, tags)) { + modelItems.add(relationship); + } + }); + } else if (expr.matches(RELATIONSHIP_PROPERTY_EQUALS_EXPRESSION)) { + String propertyName = expr.substring(expr.indexOf("[")+1, expr.indexOf("]")); + String propertyValue = expr.substring(expr.indexOf("==")+2); + + context.getWorkspace().getModel().getRelationships().forEach(relationship -> { + if (hasProperty(relationship, propertyName, propertyValue)) { + modelItems.add(relationship); + } + }); + } else if (expr.startsWith(RELATIONSHIP_SOURCE_EQUALS_EXPRESSION)) { + String identifier = expr.substring(RELATIONSHIP_SOURCE_EQUALS_EXPRESSION.length()); + Set sourceElements = new HashSet<>(); + + if (isExpression(identifier)) { + Set set = parseExpression(identifier, context); + for (ModelItem modelItem : set) { + if (modelItem instanceof Element) { + sourceElements.add((Element)modelItem); + } + } + } else { + Element source = context.getElement(identifier); + if (source == null) { + throw new RuntimeException("The element \"" + identifier + "\" does not exist"); + } + + if (source instanceof ElementGroup) { + sourceElements.addAll(((ElementGroup) source).getElements()); + } else { + sourceElements.add(source); + } + } + + context.getWorkspace().getModel().getRelationships().forEach(relationship -> { + if (sourceElements.contains(relationship.getSource())) { + modelItems.add(relationship); + } + }); + } else if (expr.startsWith(RELATIONSHIP_DESTINATION_EQUALS_EXPRESSION)) { + String identifier = expr.substring(RELATIONSHIP_DESTINATION_EQUALS_EXPRESSION.length()); + Set destinationElements = new HashSet<>(); + + if (isExpression(identifier)) { + Set set = parseExpression(identifier, context); + for (ModelItem modelItem : set) { + if (modelItem instanceof Element) { + destinationElements.add((Element)modelItem); + } + } + } else { + Element destination = context.getElement(identifier); + if (destination == null) { + throw new RuntimeException("The element \"" + identifier + "\" does not exist"); + } + + if (destination instanceof ElementGroup) { + destinationElements.addAll(((ElementGroup) destination).getElements()); + } else { + destinationElements.add(destination); + } + } + + context.getWorkspace().getModel().getRelationships().forEach(relationship -> { + if (destinationElements.contains(relationship.getDestination())) { + modelItems.add(relationship); + } + }); + } else { + // fallback that the expression is an identifier + Set elements = getElements(expr, context); + if (!elements.isEmpty()) { + modelItems.addAll(elements); + } + } + + return modelItems; + } + + protected Set evaluateElementTypeExpression(String expr, DslContext context) { + Set elements = new LinkedHashSet<>(); + + String type = expr.substring(ELEMENT_TYPE_EQUALS_EXPRESSION.length()); + switch (type.toLowerCase()) { + case "custom": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof CustomElement).forEach(elements::add); + break; + case "person": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Person).forEach(elements::add); + break; + case "softwaresystem": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystem).forEach(elements::add); + break; + case "container": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Container).forEach(elements::add); + break; + case "component": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Component).forEach(elements::add); + break; + case "deploymentnode": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).forEach(elements::add); + break; + case "infrastructurenode": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).forEach(elements::add); + break; + case "softwaresysteminstance": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).forEach(elements::add); + break; + case "containerinstance": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).forEach(elements::add); + break; + } + + return elements; + } + + private boolean hasAllTags(ModelItem modelItem, String[] tags) { + boolean result = true; + + for (String tag : tags) { + boolean hasTag = modelItem.hasTag(tag.trim()); + + if (!hasTag) { + // perhaps the tag is instead on a related model item? + if (modelItem instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)modelItem; + hasTag = elementInstance.getElement().hasTag(tag.trim()); + } else if (modelItem instanceof Relationship) { + Relationship relationship = (Relationship)modelItem; + if (!StringUtils.isNullOrEmpty(relationship.getLinkedRelationshipId())) { + Relationship linkedRelationship = relationship.getModel().getRelationship(relationship.getLinkedRelationshipId()); + if (linkedRelationship != null) { + hasTag = linkedRelationship.hasTag(tag.trim()); + } + } + } + } + + result = result && hasTag; + } + + return result; + } + + private boolean hasProperty(ModelItem modelItem, String name, String value) { + boolean result = modelItem.hasProperty(name, value); + + if (!result) { + // perhaps the property is instead on a related model item? + if (modelItem instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)modelItem; + result = elementInstance.getElement().hasProperty(name, value); + } else if (modelItem instanceof Relationship) { + Relationship relationship = (Relationship)modelItem; + if (!StringUtils.isNullOrEmpty(relationship.getLinkedRelationshipId())) { + Relationship linkedRelationship = relationship.getModel().getRelationship(relationship.getLinkedRelationshipId()); + if (linkedRelationship != null) { + result = linkedRelationship.hasProperty(name, value); + } + } + } + } + + return result; + } + + protected Set findAfferentCouplings(Element element) { + return new LinkedHashSet<>(findAfferentCouplings(element, Element.class)); + } + + protected Set findAfferentCouplings(Element element, Class typeOfElement) { + Set elements = new LinkedHashSet<>(); + + Set relationships = element.getModel().getRelationships(); + relationships.stream().filter(r -> r.getDestination().equals(element) && typeOfElement.isInstance(r.getSource())) + .map(Relationship::getSource) + .forEach(elements::add); + + return elements; + } + + protected Set findEfferentCouplings(Element element) { + return new LinkedHashSet<>(findEfferentCouplings(element, Element.class)); + } + + protected Set findEfferentCouplings(Element element, Class typeOfElement) { + Set elements = new LinkedHashSet<>(); + + Set relationships = element.getModel().getRelationships(); + relationships.stream().filter(r -> r.getSource().equals(element) && typeOfElement.isInstance(r.getDestination())) + .map(Relationship::getDestination) + .forEach(elements::add); + + return elements; + } + + protected Set parseIdentifier(String identifier, DslContext context) { + Set modelItems = new LinkedHashSet<>(); + + Element element = context.getElement(identifier); + if (element != null) { + modelItems.addAll(getElements(identifier, context)); + } + + Relationship relationship = context.getRelationship(identifier); + if (relationship != null) { + modelItems.add(relationship); + + // and also find all relationships linked to it (i.e. implied and replicated relationships) + relationship.getModel().getRelationships().stream().filter(r -> relationship.getId().equals(r.getLinkedRelationshipId())).forEach(modelItems::add); + } + + if (modelItems.isEmpty()) { + throw new RuntimeException("The element/relationship \"" + identifier + "\" does not exist"); + } else { + return modelItems; + } + } + + protected Set getElements(String identifier, DslContext context) { + Set elements = new HashSet<>(); + + Element element = context.getElement(identifier); + if (element != null) { + if (element instanceof ElementGroup) { + ElementGroup group = (ElementGroup) element; + elements.addAll(group.getElements()); + } else { + elements.add(element); + } + } + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ExternalScriptDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExternalScriptDslContext.java new file mode 100644 index 000000000..b8e8c4070 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ExternalScriptDslContext.java @@ -0,0 +1,35 @@ +package com.structurizr.dsl; + +import java.io.File; + +class ExternalScriptDslContext extends ScriptDslContext { + + private final String filename; + + ExternalScriptDslContext(DslContext parentContext, File dslFile, StructurizrDslParser dslParser, String filename) { + super(parentContext, dslFile, dslParser); + + this.filename = filename; + } + + @Override + void end() { + try { + File scriptFile = new File(dslFile.getParent(), filename); + if (!scriptFile.exists()) { + throw new RuntimeException("Script file " + scriptFile.getCanonicalPath() + " does not exist"); + } + + run(this, scriptFile); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Error running script at " + filename + ", caused by " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/Features.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/Features.java new file mode 100644 index 000000000..4e96d07ac --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/Features.java @@ -0,0 +1,20 @@ +package com.structurizr.dsl; + +public final class Features extends com.structurizr.util.Features { + + public static final String PLUGINS = "structurizr.feature.dsl.plugins"; + public static final String SCRIPTS = "structurizr.feature.dsl.scripts"; + + public static final String COMPONENT_FINDER = "structurizr.feature.dsl.componentfinder"; + + public static final String INCLUDE = "structurizr.feature.dsl.include"; + + public static final String DOCUMENTATION = "structurizr.feature.dsl.documentation"; + public static final String DECISIONS = "structurizr.feature.dsl.decisions"; + + public static final String ENVIRONMENT = "structurizr.feature.dsl.environment"; + public static final String FILE_SYSTEM = "structurizr.feature.dsl.filesystem"; + public static final String HTTP = "structurizr.feature.dsl.http"; + public static final String HTTPS = "structurizr.feature.dsl.https"; + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FileUtils.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FileUtils.java new file mode 100644 index 000000000..4f1279c62 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FileUtils.java @@ -0,0 +1,46 @@ +package com.structurizr.dsl; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class FileUtils { + + private static final String STRUCTURIZR_DSL_FILE_EXTENSION = ".dsl"; + + static List findFiles(File path) { + List files = new ArrayList<>(); + if (path.isDirectory()) { + files = findFilesInDirectory(path); + } else { + files.add(path); + } + + return files; + } + + private static List findFilesInDirectory(File directory) { + List files = new ArrayList<>(); + + File[] filesInDirectory = directory.listFiles(); + if (filesInDirectory == null || filesInDirectory.length == 0) { + return files; + } + + Arrays.sort(filesInDirectory); + + for (File file : filesInDirectory) { + if (!file.isDirectory() && file.getName().endsWith(STRUCTURIZR_DSL_FILE_EXTENSION)) { + files.add(file); + } + + if (file.isDirectory()) { + files.addAll(findFilesInDirectory(file)); + } + } + + return files; + } + +} diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewDslContext.java new file mode 100644 index 000000000..d385e6bf0 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewDslContext.java @@ -0,0 +1,25 @@ +package com.structurizr.dsl; + +import com.structurizr.view.FilteredView; + +class FilteredViewDslContext extends ViewDslContext { + + FilteredViewDslContext(FilteredView view) { + super(view); + } + + FilteredView getView() { + return (FilteredView)super.getView(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DEFAULT_VIEW_TOKEN, + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewParser.java new file mode 100644 index 000000000..8f03f9b4c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FilteredViewParser.java @@ -0,0 +1,84 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.text.DecimalFormat; +import java.util.HashSet; +import java.util.Set; + +final class FilteredViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "filtered [key] [description]"; + + private static final int BASE_KEY_INDEX = 1; + private static final int MODE_INDEX = 2; + private static final int TAGS_INDEX = 3; + private static final int KEY_INDEX = 4; + private static final int DESCRIPTION_INDEX = 5; + + private static final String FILTER_MODE_INCLUDE = "include"; + private static final String FILTER_MODE_EXCLUDE = "exclude"; + + FilteredView parse(DslContext context, Tokens tokens) { + // filtered [key} [description] + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(TAGS_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + String key = ""; + + View baseView; + String baseKey = tokens.get(BASE_KEY_INDEX); + String mode = tokens.get(MODE_INDEX); + String tagsAsString = tokens.get(TAGS_INDEX); + Set tags = new HashSet<>(); + + for (String tag : tagsAsString.split(",")) { + if (!StringUtils.isNullOrEmpty(tag)) { + tags.add(tag.trim()); + } + } + + String description = ""; + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + FilterMode filterMode; + if (FILTER_MODE_INCLUDE.equalsIgnoreCase(mode)) { + filterMode = FilterMode.Include; + } else if (FILTER_MODE_EXCLUDE.equalsIgnoreCase(mode)) { + filterMode = FilterMode.Exclude; + } else { + throw new RuntimeException("Filter mode should be include or exclude"); + } + + baseView = workspace.getViews().getViewWithKey(baseKey); + if (baseView == null) { + throw new RuntimeException("The view \"" + baseKey + "\" does not exist"); + } + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (baseView instanceof StaticView) { + return workspace.getViews().createFilteredView((StaticView)baseView, key, description, filterMode, tags.toArray(new String[0])); + } else if (baseView instanceof DeploymentView) { + return workspace.getViews().createFilteredView((DeploymentView)baseView, key, description, filterMode, tags.toArray(new String[0])); + } else { + throw new RuntimeException("The view \"" + baseKey + "\" must be a System Landscape, System Context, Container, Component, or Deployment view"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementParser.java new file mode 100644 index 000000000..a6737a585 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementParser.java @@ -0,0 +1,47 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.StaticStructureElement; + +final class FindElementParser extends AbstractParser { + + private static final String GRAMMAR = "!element "; + + private final static int IDENTIFIER_INDEX = 1; + + Element parse(DslContext context, Tokens tokens) { + // !element + + if (tokens.hasMoreThan(IDENTIFIER_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Element element; + + String s = tokens.get(IDENTIFIER_INDEX); + if (s.contains("://")) { + element = context.getWorkspace().getModel().getElementWithCanonicalName(s); + } else { + element = context.getElement(s); + } + + if (element == null) { + throw new RuntimeException("An element identified by \"" + s + "\" could not be found"); + } + + if (context instanceof GroupableDslContext && element instanceof StaticStructureElement) { + GroupableDslContext groupableDslContext = (GroupableDslContext)context; + StaticStructureElement staticStructureElement = (StaticStructureElement)element; + if (groupableDslContext.hasGroup()) { + staticStructureElement.setGroup(groupableDslContext.getGroup().getName()); + } + } + + return element; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementsParser.java new file mode 100644 index 000000000..47dc0f748 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindElementsParser.java @@ -0,0 +1,34 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.ModelItem; + +import java.util.Set; +import java.util.stream.Collectors; + +final class FindElementsParser extends AbstractParser { + + private static final String GRAMMAR = "!elements "; + + private final static int EXPRESSION_INDEX = 1; + + Set parse(DslContext context, Tokens tokens) { + // !elements + + if (tokens.hasMoreThan(EXPRESSION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + String expression = tokens.get(1); + Set modelItems = new ExpressionParser().parseExpression(expression, context); + + Set elements = modelItems.stream().filter(mi -> mi instanceof Element).map(mi -> (Element)mi).collect(Collectors.toSet()); + + if (elements.isEmpty()) { + throw new RuntimeException("No elements found for expression \"" + expression + "\""); + } + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipParser.java new file mode 100644 index 000000000..9643e57b8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipParser.java @@ -0,0 +1,39 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; + +final class FindRelationshipParser extends AbstractParser { + + private static final String GRAMMAR = "!relationship "; + + private final static int IDENTIFIER_INDEX = 1; + + Relationship parse(DslContext context, Tokens tokens) { + // !relationship + + if (tokens.hasMoreThan(IDENTIFIER_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Relationship relationship; + + String s = tokens.get(IDENTIFIER_INDEX); + if (s.startsWith("Relationship://")) { + relationship = context.getWorkspace().getModel().getRelationshipWithCanonicalName(s); + } else { + relationship = context.getRelationship(s); + } + + if (relationship == null) { + throw new RuntimeException("A relationship identified by \"" + s + "\" could not be found"); + } + + return relationship; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipsParser.java new file mode 100644 index 000000000..2bc4c7d31 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/FindRelationshipsParser.java @@ -0,0 +1,34 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Relationship; + +import java.util.Set; +import java.util.stream.Collectors; + +final class FindRelationshipsParser extends AbstractParser { + + private static final String GRAMMAR = "!relationships "; + + private final static int EXPRESSION_INDEX = 1; + + Set parse(DslContext context, Tokens tokens) { + // !relationships + + if (tokens.hasMoreThan(EXPRESSION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + String expression = tokens.get(1); + Set modelItems = new ExpressionParser().parseExpression(expression, context); + + Set relationships = modelItems.stream().filter(mi -> mi instanceof Relationship).map(mi -> (Relationship)mi).collect(Collectors.toSet()); + + if (relationships.isEmpty()) { + throw new RuntimeException("No relationships found for expression \"" + expression + "\""); + } + + return relationships; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupParser.java new file mode 100644 index 000000000..251500fb6 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupParser.java @@ -0,0 +1,76 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.util.StringUtils; + +class GroupParser { + + private static final String STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME = "structurizr.groupSeparator"; + + private static final String GRAMMAR_AS_CONTEXT = "group {"; + private static final String GRAMMAR_AS_PROPERTY = "group "; + + private final static int NAME_INDEX = 1; + private final static int BRACE_INDEX = 2; + + + ElementGroup parseContext(GroupableDslContext dslContext, Tokens tokens) { + // group { + + if (tokens.hasMoreThan(BRACE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR_AS_CONTEXT); + } + + if (!tokens.includes(BRACE_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR_AS_CONTEXT); + } + + if (!DslContext.CONTEXT_START_TOKEN.equalsIgnoreCase(tokens.get(BRACE_INDEX))) { + throw new RuntimeException("Expected: " + GRAMMAR_AS_CONTEXT); + } + + ElementGroup group; + if (dslContext.hasGroup()) { + String groupSeparator = ((DslContext)dslContext).getWorkspace().getModel().getProperties().getOrDefault(STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME, ""); + + if (StringUtils.isNullOrEmpty(groupSeparator)) { + throw new RuntimeException("To use nested groups, please define a model property named " + STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME); + } + + group = new ElementGroup(tokens.get(NAME_INDEX), groupSeparator, dslContext.getGroup()); + } else { + group = new ElementGroup(tokens.get(NAME_INDEX)); + } + + return group; + } + + void parseProperty(ComponentDslContext dslContext, Tokens tokens) { + // group + + if (tokens.includes(BRACE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR_AS_PROPERTY); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR_AS_PROPERTY); + } + + String group = tokens.get(NAME_INDEX); + + Component component = dslContext.getComponent(); + String existingGroup = component.getGroup(); + + if (!StringUtils.isNullOrEmpty(existingGroup)) { + String groupSeparator = dslContext.getWorkspace().getModel().getProperties().getOrDefault(STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME, ""); + if (StringUtils.isNullOrEmpty(groupSeparator)) { + throw new RuntimeException("To use nested groups, please define a model property named " + STRUCTURIZR_GROUP_SEPARATOR_PROPERTY_NAME); + } + + group = existingGroup + groupSeparator + group; + } + + component.setGroup(group); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableDslContext.java new file mode 100644 index 000000000..594ac14b6 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableDslContext.java @@ -0,0 +1,9 @@ +package com.structurizr.dsl; + +interface GroupableDslContext { + + boolean hasGroup(); + + ElementGroup getGroup(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableElementDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableElementDslContext.java new file mode 100644 index 000000000..bc6b337d1 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/GroupableElementDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.model.GroupableElement; + +abstract class GroupableElementDslContext extends ElementDslContext implements GroupableDslContext { + + private final ElementGroup group; + + GroupableElementDslContext() { + this.group = null; + } + + GroupableElementDslContext(ElementGroup group) { + this.group = group; + } + + @Override + public boolean hasGroup() { + return group != null; + } + + @Override + public ElementGroup getGroup() { + return group; + } + + abstract GroupableElement getElement(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/HealthCheckParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/HealthCheckParser.java new file mode 100644 index 000000000..6cc6ab9a1 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/HealthCheckParser.java @@ -0,0 +1,61 @@ +package com.structurizr.dsl; + +import com.structurizr.model.StaticStructureElementInstance; + +class HealthCheckParser extends AbstractParser { + + private static final String GRAMMAR = "healthCheck [interval] [timeout]"; + + private final static int NAME_INDEX = 1; + private final static int URL_INDEX = 2; + private final static int INTERVAL_INDEX = 3; + private final static int TIMEOUT_INDEX = 4; + + private final static int DEFAULT_INTERVAL = 60; + private final static long DEFAULT_TIMEOUT = 0; + + void parse(StaticStructureElementInstanceDslContext context, Tokens tokens) { + // healthCheck [interval] [timeout] + + if (tokens.hasMoreThan(TIMEOUT_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(URL_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String name = tokens.get(NAME_INDEX); + String url = tokens.get(URL_INDEX); + int interval = DEFAULT_INTERVAL; + long timeout = DEFAULT_TIMEOUT; + + if (tokens.includes(INTERVAL_INDEX)) { + try { + interval = Integer.parseInt(tokens.get(INTERVAL_INDEX)); + + if (interval < 1) { + throw new RuntimeException("The interval must be a positive integer (number of seconds)"); + } + } catch (NumberFormatException e) { + throw new RuntimeException("The interval of \"" + tokens.get(INTERVAL_INDEX) + "\" is not valid - it must be a positive integer (number of seconds)"); + } + } + + if (tokens.includes(TIMEOUT_INDEX)) { + try { + timeout = Integer.parseInt(tokens.get(TIMEOUT_INDEX)); + + if (timeout < 0) { + throw new RuntimeException("The timeout must be zero or a positive integer (number of milliseconds)"); + } + } catch (NumberFormatException e) { + throw new RuntimeException("The timeout of \"" + tokens.get(TIMEOUT_INDEX) + "\" is not valid - it must be zero or a positive integer (number of milliseconds)"); + } + } + + StaticStructureElementInstance elementInstance = context.getElementInstance(); + elementInstance.addHealthCheck(name, url, interval, timeout); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScope.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScope.java new file mode 100644 index 000000000..1596bcc4b --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScope.java @@ -0,0 +1,8 @@ +package com.structurizr.dsl; + +enum IdentifierScope { + + Flat, + Hierarchical, + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScopeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScopeParser.java new file mode 100644 index 000000000..93599f71e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifierScopeParser.java @@ -0,0 +1,30 @@ +package com.structurizr.dsl; + +final class IdentifierScopeParser extends AbstractParser { + + private static final String GRAMMAR = "!identifiers "; + + private static final int MODE_INDEX = 1; + + IdentifierScope parse(DslContext context, Tokens tokens) { + // !identifiers + + if (tokens.hasMoreThan(MODE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(MODE_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String name = tokens.get(MODE_INDEX); + if ("flat".equalsIgnoreCase(name)) { + return IdentifierScope.Flat; + } else if ("hierarchical".equalsIgnoreCase(name)) { + return IdentifierScope.Hierarchical; + } else { + throw new RuntimeException("Expected: " + GRAMMAR); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifiersRegister.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifiersRegister.java new file mode 100644 index 000000000..3ef9f1a37 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/IdentifiersRegister.java @@ -0,0 +1,250 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * A register of elements and relationships that were created with an identifier in the DSL. + */ +public class IdentifiersRegister { + + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("\\w[a-zA-Z0-9_-]*"); + + private IdentifierScope identifierScope = IdentifierScope.Flat; + + private final Map elementsByIdentifier = new HashMap<>(); + + private final Map relationshipsByIdentifier = new HashMap<>(); + + IdentifiersRegister() { + } + + /** + * Gets the identifier scope in use (i.e. Flat or Hierarchical ... applies to elements only). + * + * @return an IdentifierScope enum + */ + public IdentifierScope getIdentifierScope() { + return identifierScope; + } + + /** + * Sets the identifier scope (i.e. Flat or Hierarchical ... applies to elements only). + * + * @param identifierScope an IdentifierScope enum + */ + public void setIdentifierScope(IdentifierScope identifierScope) { + this.identifierScope = identifierScope; + } + + /** + * Gets the set of element identifiers. + * + * @return a Set of String identifiers + */ + public Set getElementIdentifiers() { + return elementsByIdentifier.keySet(); + } + + /** + * Gets the set of relationship identifiers. + * + * @return a Set of String identifiers + */ + public Set getRelationshipIdentifiers() { + return relationshipsByIdentifier.keySet(); + } + + /** + * Gets the element identified by the specified identifier. + * + * @param identifier a String identifier + * @return an Element, or null if one doesn't exist + */ + public Element getElement(String identifier) { + for (String key : elementsByIdentifier.keySet()) { + if (key.equalsIgnoreCase(identifier)) { + return elementsByIdentifier.get(key); + } + } + + return null; + } + + /** + * Registers an element with the given identifier. + * + * @param identifier an identifier + * @param element an Element instance + */ + public void register(String identifier, Element element) { + if (element == null) { + throw new IllegalArgumentException("An element must be specified"); + } + + if (StringUtils.isNullOrEmpty(identifier)) { + identifier = UUID.randomUUID().toString(); + } + + if (identifierScope == IdentifierScope.Hierarchical) { + identifier = calculateHierarchicalIdentifier(identifier, element); + } + + // check whether this element has already been registered with another identifier + for (String id : elementsByIdentifier.keySet()) { + Element e = elementsByIdentifier.get(id); + + if (e.equals(element) && !id.equalsIgnoreCase(identifier)) { + if (id.matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}")) { + throw new RuntimeException("Please assign an identifier to \"" + element.getCanonicalName() + "\" before using it"); + } else { + throw new RuntimeException("The element is already registered with an identifier of \"" + id + "\""); + } + } + } + + Element e = getElement(identifier); + Relationship r = getRelationship(identifier); + + if ((e == null && r == null) || (e == element)) { + elementsByIdentifier.put(identifier, element); + } else { + throw new RuntimeException("The identifier \"" + identifier + "\" is already in use"); + } + } + + /** + * Gets the relationship identified by the specified identifier. + * + * @param identifier a String identifier + * @return a Relationship, or null if one doesn't exist + */ + public Relationship getRelationship(String identifier) { + for (String key : relationshipsByIdentifier.keySet()) { + if (key.equalsIgnoreCase(identifier)) { + return relationshipsByIdentifier.get(key); + } + } + + return null; + } + + /** + * Registers a relationship with the given identifier. + * + * @param identifier an identifier + * @param relationship a Relationship instance + */ + public void register(String identifier, Relationship relationship) { + if (relationship == null) { + throw new IllegalArgumentException("A relationship must be specified"); + } + + if (StringUtils.isNullOrEmpty(identifier)) { + identifier = UUID.randomUUID().toString(); + } + + // check whether this relationship has already been registered with another identifier + for (String id : relationshipsByIdentifier.keySet()) { + Relationship r = relationshipsByIdentifier.get(id); + + if (r.equals(relationship) && !id.equalsIgnoreCase(identifier)) { + if (id.matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}")) { + throw new RuntimeException("Please assign an identifier to \"" + relationship.getCanonicalName() + "\" before using it"); + } else { + throw new RuntimeException("The relationship is already registered with an identifier of \"" + id + "\""); + } + } + } + + Element e = getElement(identifier); + Relationship r = getRelationship(identifier); + + if ((e == null && r == null) || (r == relationship)) { + relationshipsByIdentifier.put(identifier, relationship); + } else { + throw new RuntimeException("The identifier \"" + identifier + "\" is already in use"); + } + } + + private String calculateHierarchicalIdentifier(String identifier, Element element) { + if (element.getParent() == null) { + if (element instanceof DeploymentNode) { + DeploymentNode dn = (DeploymentNode)element; + return findIdentifier(new DeploymentEnvironment(dn.getEnvironment())) + "." + identifier; + } else { + return identifier; + } + } else { + return findIdentifier(element.getParent()) + "." + identifier; + } + } + + /** + * Finds the identifier used when defining an element. + * + * @param element an Element instance + * @return a String identifier (could be null if no identifier was explicitly specified) + */ + public String findIdentifier(Element element) { + if (elementsByIdentifier.containsValue(element)) { + for (String identifier : elementsByIdentifier.keySet()) { + Element e = elementsByIdentifier.get(identifier); + + if (e.equals(element)) { + return identifier; + } + } + } + + return null; + } + + /** + * Finds the identifier used when defining a relationship. + * + * @param relationship a Relationship instance + * @return a String identifier (could be null if no identifier was explicitly specified, or for implied relationships) + */ + public String findIdentifier(Relationship relationship) { + if (relationshipsByIdentifier.containsValue(relationship)) { + for (String identifier : relationshipsByIdentifier.keySet()) { + Relationship r = relationshipsByIdentifier.get(identifier); + + if (r.equals(relationship)) { + return identifier; + } + } + } + + return null; + } + + void validateIdentifierName(String identifier) { + if (identifier.startsWith("-")) { + throw new RuntimeException("Identifiers cannot start with a - character"); + } + + if (!IDENTIFIER_PATTERN.matcher(identifier).matches()) { + throw new RuntimeException("Identifiers can only contain the following characters: a-zA-Z0-9_-"); + } + } + + static String toIdentifier(String s) { + String identifierName = s.replaceAll("[^a-zA-Z0-9_-]", ""); + if (identifierName.startsWith("-")) { + identifierName = identifierName.substring(1); + } + + return identifierName; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewContentParser.java new file mode 100644 index 000000000..e78d72ba3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewContentParser.java @@ -0,0 +1,258 @@ +package com.structurizr.dsl; + +import com.structurizr.export.mermaid.MermaidDiagramExporter; +import com.structurizr.export.plantuml.StructurizrPlantUMLExporter; +import com.structurizr.http.RemoteContent; +import com.structurizr.importer.diagrams.image.ImageImporter; +import com.structurizr.importer.diagrams.kroki.KrokiImporter; +import com.structurizr.importer.diagrams.mermaid.MermaidImporter; +import com.structurizr.importer.diagrams.plantuml.PlantUMLImporter; +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.StringUtils; +import com.structurizr.util.Url; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ModelView; +import com.structurizr.view.View; + +import java.io.File; + +final class ImageViewContentParser extends AbstractParser { + + private static final String PLANTUML_GRAMMAR = "plantuml "; + private static final String MERMAID_GRAMMAR = "mermaid "; + private static final String KROKI_GRAMMAR = "kroki "; + private static final String IMAGE_GRAMMAR = "image "; + + private static final int PLANTUML_SOURCE_INDEX = 1; + + private static final int MERMAID_SOURCE_INDEX = 1; + + private static final int KROKI_FORMAT_INDEX = 1; + private static final int KROKI_SOURCE_INDEX = 2; + + private static final int IMAGE_SOURCE_INDEX = 1; + + ImageViewContentParser() { + } + + void parsePlantUML(ImageViewDslContext context, File dslFile, Tokens tokens) { + // plantuml + + if (!tokens.includes(PLANTUML_SOURCE_INDEX)) { + throw new RuntimeException("Expected: " + PLANTUML_GRAMMAR); + } + + if (tokens.hasMoreThan(PLANTUML_SOURCE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + PLANTUML_GRAMMAR); + } + + String source = tokens.get(PLANTUML_SOURCE_INDEX); + ColorScheme colorScheme = context.getColorScheme(); + + try { + if (source.contains("\n")) { + // inline source + new PlantUMLImporter(context.getHttpClient()).importDiagram(context.getView(), source, colorScheme); + } else { + View viewWithKey = context.getWorkspace().getViews().getViewWithKey(source); + if (viewWithKey instanceof ModelView) { + String plantumlLight = new StructurizrPlantUMLExporter(ColorScheme.Light).export((ModelView) viewWithKey).getDefinition(); + new PlantUMLImporter(context.getHttpClient()).importDiagram(context.getView(), plantumlLight, ColorScheme.Light); + + String plantumlDark = new StructurizrPlantUMLExporter(ColorScheme.Dark).export((ModelView) viewWithKey).getDefinition(); + new PlantUMLImporter(context.getHttpClient()).importDiagram(context.getView(), plantumlDark, ColorScheme.Dark); + + if (!StringUtils.isNullOrEmpty(viewWithKey.getTitle())) { + context.getView().setTitle(viewWithKey.getTitle()); + } else { + context.getView().setTitle(viewWithKey.getName()); + } + context.getView().setDescription(viewWithKey.getDescription()); + } else { + if (Url.isHttpsUrl(source) || Url.isHttpUrl(source)) { + if (Url.isHttpsUrl(source) && !context.getFeatures().isEnabled(Features.HTTPS)) { + throw new FeatureNotEnabledException(Features.HTTPS, "Image views via HTTPS are not permitted"); + } + if (Url.isHttpUrl(source) && !context.getFeatures().isEnabled(Features.HTTP)) { + throw new FeatureNotEnabledException(Features.HTTP, "Image views via HTTP are not permitted"); + } + + RemoteContent content = context.getHttpClient().get(source); + new PlantUMLImporter(context.getHttpClient()).importDiagram(context.getView(), content.getContentAsString(), colorScheme); + context.getView().setTitle(source.substring(source.lastIndexOf("/") + 1)); + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(dslFile.getParentFile(), source); + if (file.exists()) { + context.setDslPortable(false); + new PlantUMLImporter(context.getHttpClient()).importDiagram(context.getView(), file, colorScheme); + } else { + throw new RuntimeException("The file at " + file.getAbsolutePath() + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "plantuml is not permitted"); + } + } + } + } + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + void parseMermaid(ImageViewDslContext context, File dslFile, Tokens tokens) { + // mermaid + + if (!tokens.includes(MERMAID_SOURCE_INDEX)) { + throw new RuntimeException("Expected: " + MERMAID_GRAMMAR); + } + + if (tokens.hasMoreThan(MERMAID_SOURCE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + MERMAID_GRAMMAR); + } + + String source = tokens.get(MERMAID_SOURCE_INDEX); + ColorScheme colorScheme = context.getColorScheme(); + + try { + if (source.contains("\n")) { + // inline source + new MermaidImporter(context.getHttpClient()).importDiagram(context.getView(), source, colorScheme); + } else { + View viewWithKey = context.getWorkspace().getViews().getViewWithKey(source); + if (viewWithKey instanceof ModelView) { + MermaidDiagramExporter exporter = new MermaidDiagramExporter(); + String mermaid = exporter.export((ModelView) viewWithKey).getDefinition(); + new MermaidImporter(context.getHttpClient()).importDiagram(context.getView(), mermaid, colorScheme); + + if (!StringUtils.isNullOrEmpty(viewWithKey.getTitle())) { + context.getView().setTitle(viewWithKey.getTitle()); + } else { + context.getView().setTitle(viewWithKey.getName()); + } + context.getView().setDescription(viewWithKey.getDescription()); + } else { + if (Url.isHttpsUrl(source) || Url.isHttpUrl(source)) { + if (Url.isHttpsUrl(source) && !context.getFeatures().isEnabled(Features.HTTPS)) { + throw new FeatureNotEnabledException(Features.HTTPS, "Image views via HTTPS are not permitted"); + } + if (Url.isHttpUrl(source) && !context.getFeatures().isEnabled(Features.HTTP)) { + throw new FeatureNotEnabledException(Features.HTTP, "Image views via HTTP are not permitted"); + } + + RemoteContent content = context.getHttpClient().get(source); + new MermaidImporter(context.getHttpClient()).importDiagram(context.getView(), content.getContentAsString(), colorScheme); + context.getView().setTitle(source.substring(source.lastIndexOf("/") + 1)); + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(dslFile.getParentFile(), source); + if (file.exists()) { + context.setDslPortable(false); + new MermaidImporter(context.getHttpClient()).importDiagram(context.getView(), file, colorScheme); + } else { + throw new RuntimeException("The file at " + file.getAbsolutePath() + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "mermaid is not permitted"); + } + } + } + } + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + void parseKroki(ImageViewDslContext context, File dslFile, Tokens tokens) { + // kroki + + if (!tokens.includes(KROKI_SOURCE_INDEX)) { + throw new RuntimeException("Expected: " + KROKI_GRAMMAR); + } + + if (tokens.hasMoreThan(KROKI_SOURCE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + KROKI_GRAMMAR); + } + + String format = tokens.get(KROKI_FORMAT_INDEX); + String source = tokens.get(KROKI_SOURCE_INDEX); + ColorScheme colorScheme = context.getColorScheme(); + + try { + if (source.contains("\n")) { + // inline source + new KrokiImporter(context.getHttpClient()).importDiagram(context.getView(), format, source, colorScheme); + } else { + if (Url.isHttpsUrl(source) || Url.isHttpUrl(source)) { + if (Url.isHttpsUrl(source) && !context.getFeatures().isEnabled(Features.HTTPS)) { + throw new FeatureNotEnabledException(Features.HTTPS, "Image views via HTTPS are not permitted"); + } + if (Url.isHttpUrl(source) && !context.getFeatures().isEnabled(Features.HTTP)) { + throw new FeatureNotEnabledException(Features.HTTP, "Image views via HTTP are not permitted"); + } + + RemoteContent content = context.getHttpClient().get(source); + new KrokiImporter(context.getHttpClient()).importDiagram(context.getView(), format, content.getContentAsString(), colorScheme); + context.getView().setTitle(source.substring(source.lastIndexOf("/") + 1)); + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(dslFile.getParentFile(), source); + if (file.exists()) { + context.setDslPortable(false); + new KrokiImporter(context.getHttpClient()).importDiagram(context.getView(), format, file, colorScheme); + } else { + throw new RuntimeException("The file at " + file.getAbsolutePath() + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "kroki " + format + " is not permitted"); + } + } + } + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + void parseImage(ImageViewDslContext context, File dslFile, Tokens tokens) { + // image + + if (!tokens.includes(IMAGE_SOURCE_INDEX)) { + throw new RuntimeException("Expected: " + IMAGE_GRAMMAR); + } + + if (tokens.hasMoreThan(IMAGE_SOURCE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + IMAGE_GRAMMAR); + } + + String source = tokens.get(IMAGE_SOURCE_INDEX); + ColorScheme colorScheme = context.getColorScheme(); + + try { + if (Url.isHttpsUrl(source) || Url.isHttpUrl(source)) { + if (Url.isHttpsUrl(source) && !context.getFeatures().isEnabled(Features.HTTPS)) { + throw new FeatureNotEnabledException(Features.HTTPS, "Image views via HTTPS are not permitted"); + } + if (Url.isHttpUrl(source) && !context.getFeatures().isEnabled(Features.HTTP)) { + throw new FeatureNotEnabledException(Features.HTTP, "Image views via HTTP are not permitted"); + } + + new ImageImporter(context.getHttpClient()).importDiagram(context.getView(), source, colorScheme); + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + File file = new File(dslFile.getParentFile(), source); + if (file.exists()) { + context.setDslPortable(false); + new ImageImporter(context.getHttpClient()).importDiagram(context.getView(), file, colorScheme); + } else { + throw new RuntimeException("The file at " + file.getAbsolutePath() + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "image is not permitted"); + } + } + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewDslContext.java new file mode 100644 index 000000000..b33c4cbe7 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewDslContext.java @@ -0,0 +1,54 @@ +package com.structurizr.dsl; + +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; + +import java.io.IOException; + +class ImageViewDslContext extends ViewDslContext { + + private ColorScheme colorScheme; + + ImageViewDslContext(ImageView view) { + super(view); + } + + ImageView getView() { + return (ImageView)super.getView(); + } + + ColorScheme getColorScheme() { + return colorScheme; + } + + void setColorScheme(ColorScheme colorScheme) { + this.colorScheme = colorScheme; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PLANTUML_TOKEN, + StructurizrDslTokens.MERMAID_TOKEN, + StructurizrDslTokens.KROKI_TOKEN, + StructurizrDslTokens.IMAGE_TOKEN + }; + } + + @Override + void end() { + super.end(); + + if (colorScheme != null) { + colorScheme = null; + } + + if (!getView().hasContent()) { + throw new RuntimeException("The image view \"" + getView().getKey() + "\" has no content"); + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewParser.java new file mode 100644 index 000000000..aa2c1a817 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImageViewParser.java @@ -0,0 +1,52 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.view.ImageView; + +class ImageViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "image <*|element identifier> [key] {"; + + private static final int SCOPE_IDENTIFIER_INDEX = 1; + private static final int KEY_INDEX = 2; + + private static final String WILDCARD = "*"; + + ImageView parse(DslContext context, Tokens tokens) { + // image <*|element identifier> [key] { + + Workspace workspace = context.getWorkspace(); + String key = ""; + + if (tokens.hasMoreThan(KEY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(SCOPE_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + ImageView view; + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + String scopeIdentifier = tokens.get(SCOPE_IDENTIFIER_INDEX); + if (WILDCARD.equals(scopeIdentifier)) { + + view = workspace.getViews().createImageView(key); + } else { + Element element = context.getElement(scopeIdentifier); + if (element == null) { + throw new RuntimeException("The element \"" + scopeIdentifier + "\" does not exist"); + } + + view = workspace.getViews().createImageView(element, key); + } + + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ImplicitRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImplicitRelationshipParser.java new file mode 100644 index 000000000..6d761096a --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImplicitRelationshipParser.java @@ -0,0 +1,140 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; + +import java.util.*; + +final class ImplicitRelationshipParser extends AbstractRelationshipParser { + + private static final String GRAMMAR = "-> [description] [technology] [tags]"; + + private static final int DESTINATION_IDENTIFIER_INDEX = 1; + private final static int DESCRIPTION_INDEX = 2; + private final static int TECHNOLOGY_INDEX = 3; + private final static int TAGS_INDEX = 4; + + Set parse(ElementDslContext context, Tokens tokens, Archetype archetype) { + // -> [description] [technology] [tags] + + Set relationships = new HashSet<>(); + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + + Element sourceElement = context.getElement(); + + Element destinationElement = context.getElement(destinationId); + if (destinationElement == null) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + + Set sourceElements = new HashSet<>(); + Set destinationElements = new HashSet<>(); + + if (context instanceof InfrastructureNodeDslContext) { + String deploymentEnvironment = ((InfrastructureNodeDslContext)context).getInfrastructureNode().getEnvironment(); + + sourceElements.add(sourceElement); + + if (destinationElement instanceof SoftwareSystem) { + // find the software system instances in the deployment environment + destinationElements.addAll(findSoftwareSystemInstances((SoftwareSystem)destinationElement, deploymentEnvironment)); + } else if (destinationElement instanceof Container) { + // find the container instances in the deployment environment + destinationElements.addAll(findContainerInstances((Container)destinationElement, deploymentEnvironment)); + } else { + destinationElements.add(destinationElement); + } + + for (Element se : sourceElements) { + for (Element de : destinationElements) { + Relationship relationship = createRelationship(se, description, technology, tags.toArray(new String[0]), de); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + } + } else { + Relationship relationship = createRelationship(sourceElement, description, technology, tags.toArray(new String[0]), destinationElement); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + + return relationships; + } + + Set parse(ElementsDslContext context, Tokens tokens, Archetype archetype) { + // -> [description] [technology] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Set sourceElements = context.getElements(); + + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + Element destinationElement = context.getElement(destinationId); + if (destinationElement == null) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + String[] tags = archetype.getTags().toArray(new String[0]); + if (tokens.includes(TAGS_INDEX)) { + tags = tokens.get(TAGS_INDEX).split(","); + } + + Set relationships = new LinkedHashSet<>(); + for (Element sourceElement : sourceElements) { + Relationship relationship = createRelationship(sourceElement, description, technology, tags, destinationElement); + relationship.addProperties(archetype.getProperties()); + relationship.addPerspectives(archetype.getPerspectives()); + + relationships.add(relationship); + } + + return relationships; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ImpliedRelationshipsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImpliedRelationshipsParser.java new file mode 100644 index 000000000..086fdb9b3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ImpliedRelationshipsParser.java @@ -0,0 +1,67 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy; +import com.structurizr.model.DefaultImpliedRelationshipsStrategy; +import com.structurizr.model.ImpliedRelationshipsStrategy; +import com.structurizr.util.FeatureNotEnabledException; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.util.Set; + +final class ImpliedRelationshipsParser extends AbstractParser { + + private static final String GRAMMAR = "!impliedRelationships "; + + private static final Set BUILT_IN_IMPLIED_RELATIONSHIPS_STRATEGIES = Set.of( + "com.structurizr.model.DefaultImpliedRelationshipsStrategy", + "com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy", + "com.structurizr.model.CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy" + ); + + private static final int OPTION_INDEX = 1; + private static final String TRUE = "true"; + private static final String FALSE = "false"; + + void parse(DslContext context, Tokens tokens, File dslFile) { + // impliedRelationships + + if (tokens.hasMoreThan(OPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(OPTION_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String option = tokens.get(OPTION_INDEX); + + if (option.equalsIgnoreCase(FALSE)) { + context.getWorkspace().getModel().setImpliedRelationshipsStrategy(new DefaultImpliedRelationshipsStrategy()); + } else if (option.equalsIgnoreCase(TRUE)) { + context.getWorkspace().getModel().setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + } else { + if (!context.getFeatures().isEnabled(Features.PLUGINS)) { + if (!BUILT_IN_IMPLIED_RELATIONSHIPS_STRATEGIES.contains(option)) { + throw new FeatureNotEnabledException(Features.PLUGINS, "The implied relationships strategy " + option + " is not available"); + } + } + + if (!BUILT_IN_IMPLIED_RELATIONSHIPS_STRATEGIES.contains(option)) { + context.setDslPortable(false); + } + + try { + Class impliedRelationshipsStrategyClass = context.loadClass(option, dslFile); + Constructor constructor = impliedRelationshipsStrategyClass.getDeclaredConstructor(); + ImpliedRelationshipsStrategy impliedRelationshipsStrategy = (ImpliedRelationshipsStrategy)constructor.newInstance(); + context.getWorkspace().getModel().setImpliedRelationshipsStrategy(impliedRelationshipsStrategy); + } catch (ClassNotFoundException cnfe) { + throw new RuntimeException("Error loading implied relationships strategy: " + option + " was not found"); + } catch (Exception e) { + throw new RuntimeException("Error loading implied relationships strategy: " + e.getMessage()); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludeParser.java new file mode 100644 index 000000000..d8ac5caef --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludeParser.java @@ -0,0 +1,107 @@ +package com.structurizr.dsl; + +import com.structurizr.http.RemoteContent; +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.Url; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class IncludeParser extends AbstractParser { + + private static final String GRAMMAR = "!include "; + + private static final int SOURCE_INDEX = 1; + + List parse(DslContext context, File dslFile, Tokens tokens) { + // !include + + if (!context.getFeatures().isEnabled(Features.INCLUDE)) { + throw new FeatureNotEnabledException(Features.INCLUDE, "!include is not permitted"); + } + + List includedFiles = new ArrayList<>(); + + if (tokens.hasMoreThan(SOURCE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(SOURCE_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String source = tokens.get(SOURCE_INDEX); + if (Url.isHttpsUrl(source)) { + if (context.getFeatures().isEnabled(Features.HTTPS)) { + RemoteContent content = context.getHttpClient().get(source); + List lines = Arrays.asList(content.getContentAsString().split("\n")); + includedFiles.add(new IncludedFile(dslFile, lines)); + } else { + throw new FeatureNotEnabledException(Features.HTTPS, "Includes via HTTPS are not permitted"); + } + } else if (Url.isHttpUrl(source)) { + if (context.getFeatures().isEnabled(Features.HTTP)) { + RemoteContent content = context.getHttpClient().get(source); + List lines = Arrays.asList(content.getContentAsString().split("\n")); + includedFiles.add(new IncludedFile(dslFile, lines)); + } else { + throw new FeatureNotEnabledException(Features.HTTP, "Includes via HTTP are not permitted"); + } + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + if (dslFile != null) { + File path = new File(dslFile.getParent(), source); + + try { + if (!path.exists()) { + throw new RuntimeException(path.getCanonicalPath() + " could not be found"); + } + + includedFiles.addAll(readFiles(path)); + context.setDslPortable(false); + } catch (IOException e) { + throw new RuntimeException("Error including " + path.getAbsolutePath() + ": " + e.getMessage()); + } + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "!include is not permitted"); + } + } + + return includedFiles; + } + + private List readFiles(File path) throws IOException { + List includedFiles = new ArrayList<>(); + + if (path.isHidden() || path.getName().startsWith(".")) { + // ignore + return includedFiles; + } + + if (path.isDirectory()) { + File[] files = path.listFiles(); + if (files != null) { + Arrays.sort(files); + + for (File file : files) { + includedFiles.addAll(readFiles(file)); + } + } + } else { + try { + includedFiles.add(new IncludedFile(path, Files.readAllLines(path.toPath(), StandardCharsets.UTF_8))); + } catch (IOException e) { + throw new RuntimeException("Error reading file at " + path.getAbsolutePath() + ": " + e.getMessage()); + } + } + + return includedFiles; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludedFile.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludedFile.java new file mode 100644 index 000000000..f2b5a63ea --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/IncludedFile.java @@ -0,0 +1,24 @@ +package com.structurizr.dsl; + +import java.io.File; +import java.util.List; + +final class IncludedFile { + + private final File file; + private final List lines; + + IncludedFile(File file, List lines) { + this.file = file; + this.lines = lines; + } + + List getLines() { + return lines; + } + + File getFile() { + return file; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeArchetypeDslContext.java new file mode 100644 index 000000000..fc6f06ae5 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class InfrastructureNodeArchetypeDslContext extends ElementArchetypeDslContext { + + InfrastructureNodeArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeDslContext.java new file mode 100644 index 000000000..bda1154ee --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeDslContext.java @@ -0,0 +1,42 @@ +package com.structurizr.dsl; + +import com.structurizr.model.GroupableElement; +import com.structurizr.model.InfrastructureNode; +import com.structurizr.model.ModelItem; + +final class InfrastructureNodeDslContext extends GroupableElementDslContext { + + private final InfrastructureNode infrastructureNode; + + InfrastructureNodeDslContext(InfrastructureNode infrastructureNode) { + this.infrastructureNode = infrastructureNode; + } + + InfrastructureNode getInfrastructureNode() { + return infrastructureNode; + } + + @Override + ModelItem getModelItem() { + return getInfrastructureNode(); + } + + @Override + GroupableElement getElement() { + return infrastructureNode; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeParser.java new file mode 100644 index 000000000..8d8165a65 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/InfrastructureNodeParser.java @@ -0,0 +1,79 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.InfrastructureNode; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class InfrastructureNodeParser extends AbstractParser { + + private static final String GRAMMAR = "infrastructureNode [description] [technology] [tags]"; + + private static final int NAME_INDEX = 1; + private static final int DESCRIPTION_INDEX = 2; + private static final int TECHNOLOGY_INDEX = 3; + private static final int TAGS_INDEX = 4; + + InfrastructureNode parse(DeploymentNodeDslContext context, Tokens tokens, Archetype archetype) { + // infrastructureNode [description] [technology] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + DeploymentNode deploymentNode = context.getDeploymentNode(); + InfrastructureNode infrastructureNode; + String name = tokens.get(NAME_INDEX); + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + String technology = archetype.getTechnology(); + if (tokens.includes(TECHNOLOGY_INDEX)) { + technology = tokens.get(TECHNOLOGY_INDEX); + } + + infrastructureNode = deploymentNode.addInfrastructureNode(name, description, technology); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + infrastructureNode.addTags(tags.toArray(new String[0])); + + infrastructureNode.addProperties(archetype.getProperties()); + infrastructureNode.addPerspectives(archetype.getPerspectives()); + + if (context.hasGroup()) { + infrastructureNode.setGroup(context.getGroup().getName()); + context.getGroup().addElement(infrastructureNode); + } + + return infrastructureNode; + } + + void parseTechnology(InfrastructureNodeDslContext context, Tokens tokens) { + int index = 1; + + // technology + if (tokens.hasMoreThan(index)) { + throw new RuntimeException("Too many tokens, expected: technology "); + } + + if (!tokens.includes(index)) { + throw new RuntimeException("Expected: technology "); + } + + String technology = tokens.get(index); + context.getInfrastructureNode().setTechnology(technology); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/InlineScriptDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/InlineScriptDslContext.java new file mode 100644 index 000000000..adabd3693 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/InlineScriptDslContext.java @@ -0,0 +1,55 @@ +package com.structurizr.dsl; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class InlineScriptDslContext extends ScriptDslContext { + + static final Map SUPPORTED_LANGUAGES = new HashMap<>(); + + private final String language; + private final List lines = new ArrayList<>(); + + static { + SUPPORTED_LANGUAGES.put("javascript", "js"); + SUPPORTED_LANGUAGES.put("groovy", "groovy"); + SUPPORTED_LANGUAGES.put("kotlin", "kts"); + SUPPORTED_LANGUAGES.put("ruby", "rb"); + } + + InlineScriptDslContext(DslContext parentContext, File dslFile, StructurizrDslParser dslParser, String language) { + super(parentContext, dslFile, dslParser); + + this.language = language.toLowerCase(); + } + + void addLine(String line) { + lines.add(line); + } + + @Override + void end() { + try { + String fileExtension; + + if (SUPPORTED_LANGUAGES.containsKey(language)) { + fileExtension = SUPPORTED_LANGUAGES.get(language); + } else { + throw new RuntimeException("Unsupported scripting language \"" + language + "\""); + } + + run(this, fileExtension, lines); + } catch (Exception e) { + throw new RuntimeException("Error running inline script, caused by " + e.getClass().getName() + ": " + e.getMessage(), e); + } + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/InstanceOfParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/InstanceOfParser.java new file mode 100644 index 000000000..2a0d4264c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/InstanceOfParser.java @@ -0,0 +1,40 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +final class InstanceOfParser { + + private static final String GRAMMAR = "instanceOf [deploymentGroups] [tags]"; + + private static final int IDENTIFIER_INDEX = 1; + private static final int TAGS_INDEX = 3; + + StaticStructureElementInstance parse(DeploymentNodeDslContext context, Tokens tokens) { + // instanceOf [tags] + // instanceOf [deploymentGroup] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String elementIdentifier = tokens.get(IDENTIFIER_INDEX); + + Element element = context.getElement(elementIdentifier); + if (element == null) { + throw new RuntimeException("The element \"" + elementIdentifier + "\" does not exist"); + } + + if (element instanceof SoftwareSystem) { + return new SoftwareSystemInstanceParser().parse(context, tokens); + } else if (element instanceof Container) { + return new ContainerInstanceParser().parse(context, tokens); + } else { + throw new RuntimeException("The element \"" + elementIdentifier + "\" must be a software system or a container"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelDslContext.java new file mode 100644 index 000000000..56460da9f --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelDslContext.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +final class ModelDslContext extends DslContext implements GroupableDslContext { + + private ElementGroup group; + + ModelDslContext() { + } + + ModelDslContext(ElementGroup group) { + this.group = group; + } + + @Override + public boolean hasGroup() { + return group != null; + } + + @Override + public ElementGroup getGroup() { + return group; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ARCHETYPES_TOKEN, + StructurizrDslTokens.IDENTIFIERS_TOKEN, + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.PERSON_TOKEN, + StructurizrDslTokens.SOFTWARE_SYSTEM_TOKEN, + StructurizrDslTokens.DEPLOYMENT_ENVIRONMENT_TOKEN, + StructurizrDslTokens.CUSTOM_ELEMENT_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemDslContext.java new file mode 100644 index 000000000..823702ffc --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemDslContext.java @@ -0,0 +1,13 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; + +abstract class ModelItemDslContext extends DslContext { + + ModelItemDslContext() { + super(); + } + + abstract ModelItem getModelItem(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemParser.java new file mode 100644 index 000000000..e4c48ac90 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemParser.java @@ -0,0 +1,51 @@ +package com.structurizr.dsl; + +final class ModelItemParser extends AbstractParser { + + private final static int DESCRIPTION_INDEX = 1; + + private final static int TAGS_INDEX = 1; + + private final static int URL_INDEX = 1; + + void parseTags(ModelItemDslContext context, Tokens tokens) { + // tags [tags] + if (!tokens.includes(TAGS_INDEX)) { + throw new RuntimeException("Expected: tags [tags]"); + } + + for (int i = TAGS_INDEX; i < tokens.size(); i++) { + String tags = tokens.get(i); + context.getModelItem().addTags(tags.split(",")); + } + } + + void parseDescription(ElementDslContext context, Tokens tokens) { + // description + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: description "); + } + + if (!tokens.includes(DESCRIPTION_INDEX)) { + throw new RuntimeException("Expected: description "); + } + + String description = tokens.get(DESCRIPTION_INDEX); + context.getElement().setDescription(description); + } + + void parseUrl(ModelItemDslContext context, Tokens tokens) { + // url + if (tokens.hasMoreThan(URL_INDEX)) { + throw new RuntimeException("Too many tokens, expected: url "); + } + + if (!tokens.includes(URL_INDEX)) { + throw new RuntimeException("Expected: url "); + } + + String url = tokens.get(URL_INDEX); + context.getModelItem().setUrl(url); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsDslContext.java new file mode 100644 index 000000000..b4bedc9bc --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsDslContext.java @@ -0,0 +1,30 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; + +import java.util.Set; + +abstract class ModelItemsDslContext extends DslContext { + + private final DslContext parentDslContext; + + public ModelItemsDslContext() { + this.parentDslContext = null; + } + + ModelItemsDslContext(DslContext parentDslContext) { + this.parentDslContext = parentDslContext; + } + + DslContext getParentDslContext() { + return parentDslContext; + } + + abstract Set getModelItems(); + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsParser.java new file mode 100644 index 000000000..2c44be60b --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelItemsParser.java @@ -0,0 +1,41 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; + +final class ModelItemsParser extends AbstractParser { + + private final static int TAGS_INDEX = 1; + private final static int URL_INDEX = 1; + + void parseTags(ModelItemsDslContext context, Tokens tokens) { + // tags [tags] + if (!tokens.includes(TAGS_INDEX)) { + throw new RuntimeException("Expected: tags [tags]"); + } + + for (int i = TAGS_INDEX; i < tokens.size(); i++) { + String tags = tokens.get(i); + + for (ModelItem modelItem : context.getModelItems()) { + modelItem.addTags(tags.split(",")); + } + } + } + + void parseUrl(ModelItemsDslContext context, Tokens tokens) { + // url + if (tokens.hasMoreThan(URL_INDEX)) { + throw new RuntimeException("Too many tokens, expected: url "); + } + + if (!tokens.includes(URL_INDEX)) { + throw new RuntimeException("Expected: url "); + } + + String url = tokens.get(URL_INDEX); + for (ModelItem modelItem : context.getModelItems()) { + modelItem.setUrl(url); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewContentParser.java new file mode 100644 index 000000000..4f16ebcdb --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewContentParser.java @@ -0,0 +1,20 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Relationship; +import com.structurizr.view.ModelView; + +abstract class ModelViewContentParser extends AbstractParser { + + protected static final String WILDCARD = "*"; + protected static final String WILDCARD_RELUCTANT = "*?"; + protected static final String ELEMENT_WILDCARD = "element==*"; + + protected boolean isExpression(String token) { + return ExpressionParser.isExpression(token.toLowerCase()); + } + + protected void removeRelationshipFromView(Relationship relationship, ModelView view) { + view.remove(relationship); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewDslContext.java new file mode 100644 index 000000000..a7e966451 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ModelViewDslContext.java @@ -0,0 +1,15 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ModelView; + +abstract class ModelViewDslContext extends ViewDslContext { + + ModelViewDslContext(ModelView view) { + super(view); + } + + ModelView getView() { + return (ModelView)super.getView(); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValuePair.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValuePair.java new file mode 100644 index 000000000..d415d50e5 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValuePair.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +class NameValuePair { + + private NameValueType type; + private final String name; + private final String value; + + NameValuePair(String name, String value) { + this.name = name; + this.value = value; + } + + NameValueType getType() { + return type; + } + + void setType(NameValueType type) { + this.type = type; + } + + String getName() { + return name; + } + + String getValue() { + return value; + } + +} + +enum NameValueType { + + Constant, + Variable, + TextBlock + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValueParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValueParser.java new file mode 100644 index 000000000..ad16b1fc7 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/NameValueParser.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +final class NameValueParser extends AbstractParser { + + private static final String GRAMMAR = "%s "; + + private static final int KEYWORD_INDEX = 0; + private static final int NAME_INDEX = 1; + private static final int VALUE_INDEX = 2; + + private static final String NAME_REGEX = "[a-zA-Z0-9-_.]+"; + + NameValuePair parseConstant(Tokens tokens) { + NameValuePair nvp = parse(tokens); + nvp.setType(NameValueType.Constant); + + return nvp; + } + + NameValuePair parseVariable(Tokens tokens) { + NameValuePair nvp = parse(tokens); + nvp.setType(NameValueType.Variable); + + return nvp; + } + + private NameValuePair parse(Tokens tokens) { + // !const name value + // !var name value + + if (tokens.hasMoreThan(VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + String.format(GRAMMAR, tokens.get(KEYWORD_INDEX))); + } + + if (!tokens.includes(VALUE_INDEX)) { + throw new RuntimeException("Expected: " + String.format(GRAMMAR, tokens.get(KEYWORD_INDEX))); + } + + String name = tokens.get(NAME_INDEX); + String value = tokens.get(VALUE_INDEX); + + if (!name.matches(NAME_REGEX)) { + throw new RuntimeException("Constant/variable names must only contain the following characters: a-zA-Z0-9-_."); + } + + return new NameValuePair(name, value); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipInDeploymentEnvironmentDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipInDeploymentEnvironmentDslContext.java new file mode 100644 index 000000000..c694fe977 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipInDeploymentEnvironmentDslContext.java @@ -0,0 +1,26 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Relationship; + +final class NoRelationshipInDeploymentEnvironmentDslContext extends DeploymentEnvironmentDslContext { + + private final Relationship relationship; + + NoRelationshipInDeploymentEnvironmentDslContext(DeploymentEnvironmentDslContext parent, Relationship relationship) { + super(parent.getEnvironment().getName(), parent.getGroup()); + + this.relationship = relationship; + } + + Relationship getRelationship() { + return relationship; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipParser.java new file mode 100644 index 000000000..989e4ca93 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/NoRelationshipParser.java @@ -0,0 +1,98 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +import java.util.*; + +final class NoRelationshipParser extends AbstractRelationshipParser { + + private static final String GRAMMAR = " -/> [description]"; + + private static final int SOURCE_IDENTIFIER_INDEX = 0; + private static final int DESTINATION_IDENTIFIER_INDEX = 2; + private static final int DESCRIPTION_IDENTIFIER_INDEX = 3; + + Set parse(DeploymentEnvironmentDslContext context, Tokens tokens) { + // -/> [description] + + Set relationships = new HashSet<>(); + + if (tokens.hasMoreThan(DESCRIPTION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(DESTINATION_IDENTIFIER_INDEX)) { + throw new RuntimeException("Not enough tokens, expected: " + GRAMMAR); + } + + String sourceId = tokens.get(SOURCE_IDENTIFIER_INDEX); + Element sourceElement = context.getElement(sourceId); + Set sourceElements = new HashSet<>(); + + if (sourceElement == null) { + throw new RuntimeException("The source element \"" + sourceId + "\" does not exist"); + } else if (sourceElement instanceof SoftwareSystem) { + sourceElements = findSoftwareSystemInstances((SoftwareSystem)sourceElement, context.getEnvironment().getName()); + } else if (sourceElement instanceof Container) { + sourceElements = findContainerInstances((Container)sourceElement, context.getEnvironment().getName()); + } else if (sourceElement instanceof StaticStructureElementInstance) { + sourceElements.add((StaticStructureElementInstance)sourceElement); + } else { + throw new RuntimeException("The source element \"" + sourceId + "\" is not valid - expecting a software system, software system instance, container, or container instance"); + } + + String destinationId = tokens.get(DESTINATION_IDENTIFIER_INDEX); + Element destinationElement = context.getElement(destinationId); + Set destinationElements = new HashSet<>(); + + if (destinationElement == null) { + throw new RuntimeException("The destination element \"" + destinationId + "\" does not exist"); + } else if (destinationElement instanceof SoftwareSystem) { + destinationElements = findSoftwareSystemInstances((SoftwareSystem)destinationElement, context.getEnvironment().getName()); + } else if (destinationElement instanceof Container) { + destinationElements = findContainerInstances((Container)destinationElement, context.getEnvironment().getName()); + } else if (destinationElement instanceof StaticStructureElementInstance) { + destinationElements.add((StaticStructureElementInstance)destinationElement); + } else { + throw new RuntimeException("The destination element \"" + destinationId + "\" is not valid - expecting a software system, software system instance, container, or container instance"); + } + + String description = null; + + if (tokens.includes(DESCRIPTION_IDENTIFIER_INDEX)) { + description = tokens.get(DESCRIPTION_IDENTIFIER_INDEX); + } + + int count = 0; + for (Element se : sourceElements) { + for (Element de : destinationElements) { + Relationship relationship; + + do { + if (description != null) { + relationship = se.getEfferentRelationshipWith(de, description); + } else { + relationship = se.getEfferentRelationshipWith(de); + } + + if (relationship != null && relationship.getLinkedRelationshipId() != null) { + context.getWorkspace().remove(relationship); + relationships.add(relationship); + count++; + } + } while (relationship != null); + } + } + + if (count == 0) { + if (description != null) { + throw new RuntimeException("A relationship between \"" + sourceId + "\" and \"" + destinationId + "\" with description \"" + description + "\" does not exist"); + } else { + throw new RuntimeException("A relationship between \"" + sourceId + "\" and \"" + destinationId + "\" does not exist"); + } + } + + return relationships; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonArchetypeDslContext.java new file mode 100644 index 000000000..b8eefe9f8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonArchetypeDslContext.java @@ -0,0 +1,20 @@ +package com.structurizr.dsl; + +final class PersonArchetypeDslContext extends ElementArchetypeDslContext { + + PersonArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonDslContext.java new file mode 100644 index 000000000..46d01acbb --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonDslContext.java @@ -0,0 +1,41 @@ +package com.structurizr.dsl; + +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; +import com.structurizr.model.Person; + +final class PersonDslContext extends GroupableElementDslContext { + + private Person person; + + PersonDslContext(Person person) { + this.person = person; + } + + Person getPerson() { + return person; + } + + @Override + ModelItem getModelItem() { + return getPerson(); + } + + @Override + GroupableElement getElement() { + return person; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonParser.java new file mode 100644 index 000000000..992fbf6ca --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PersonParser.java @@ -0,0 +1,62 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Person; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class PersonParser extends AbstractParser { + + private static final String GRAMMAR = "person [description] [tags]"; + + private final static int NAME_INDEX = 1; + private final static int DESCRIPTION_INDEX = 2; + private final static int TAGS_INDEX = 3; + + Person parse(ModelDslContext context, Tokens tokens, Archetype archetype) { + // person [description] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + Person person = null; + String name = tokens.get(NAME_INDEX); + + if (context.isExtendingWorkspace()) { + person = context.getWorkspace().getModel().getPersonWithName(name); + } + + if (person == null) { + person = context.getWorkspace().getModel().addPerson(name); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + person.setDescription(description); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + person.addTags(tags.toArray(new String[0])); + + person.addProperties(archetype.getProperties()); + person.addPerspectives(archetype.getPerspectives()); + + if (context.hasGroup()) { + person.setGroup(context.getGroup().getName()); + context.getGroup().addElement(person); + } + + return person; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectiveParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectiveParser.java new file mode 100644 index 000000000..6fcc92c1d --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectiveParser.java @@ -0,0 +1,35 @@ +package com.structurizr.dsl; + +import com.structurizr.PerspectivesHolder; + +final class PerspectiveParser extends AbstractParser { + + private final static int PERSPECTIVE_NAME_INDEX = 0; + private final static int PERSPECTIVE_DESCRIPTION_INDEX = 1; + private final static int PERSPECTIVE_VALUE_INDEX = 2; + + void parse(PerspectivesDslContext context, Tokens tokens) { + // [value] + + if (tokens.hasMoreThan(PERSPECTIVE_VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: [value]"); + } + + if (!tokens.includes(PERSPECTIVE_DESCRIPTION_INDEX)) { + throw new RuntimeException("Expected: [value]"); + } + + String name = tokens.get(PERSPECTIVE_NAME_INDEX); + String description = tokens.get(PERSPECTIVE_DESCRIPTION_INDEX); + String value = ""; + + if (tokens.includes(PERSPECTIVE_VALUE_INDEX)) { + value = tokens.get(PERSPECTIVE_VALUE_INDEX); + } + + for (PerspectivesHolder perspectivesHolder : context.getPerspectivesHolders()) { + perspectivesHolder.addPerspective(name, description, value); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectivesDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectivesDslContext.java new file mode 100644 index 000000000..fe1338f57 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PerspectivesDslContext.java @@ -0,0 +1,30 @@ +package com.structurizr.dsl; + +import com.structurizr.PerspectivesHolder; +import com.structurizr.model.ModelItem; + +import java.util.ArrayList; +import java.util.Collection; + +final class PerspectivesDslContext extends DslContext { + + private final Collection perspectivesHolders = new ArrayList<>(); + + PerspectivesDslContext(PerspectivesHolder perspectivesHolder) { + this.perspectivesHolders.add(perspectivesHolder); + } + + PerspectivesDslContext(Collection perspectivesHolders) { + this.perspectivesHolders.addAll(perspectivesHolders); + } + + Collection getPerspectivesHolders() { + return this.perspectivesHolders; + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginDslContext.java new file mode 100644 index 000000000..7885db081 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginDslContext.java @@ -0,0 +1,43 @@ +package com.structurizr.dsl; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +class PluginDslContext extends DslContext { + + private final String fullyQualifiedClassName; + private final File dslFile; + private final StructurizrDslParser dslParser; + private final Map parameters = new HashMap<>(); + + PluginDslContext(String fullyQualifiedClassName, File dslFile, StructurizrDslParser dslParser) { + this.fullyQualifiedClassName = fullyQualifiedClassName; + this.dslFile = dslFile; + this.dslParser = dslParser; + setDslPortable(false); + } + + void addParameter(String name, String value) { + parameters.put(name, value); + } + + @Override + void end() { + try { + Class pluginClass = loadClass(fullyQualifiedClassName, dslFile); + StructurizrDslPlugin plugin = (StructurizrDslPlugin)pluginClass.getDeclaredConstructor().newInstance(); + StructurizrDslPluginContext pluginContext = new StructurizrDslPluginContext(dslParser, dslFile, getWorkspace(), parameters); + plugin.run(pluginContext); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Error running plugin " + fullyQualifiedClassName + ", caused by " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginParser.java new file mode 100644 index 000000000..b6471a87c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PluginParser.java @@ -0,0 +1,43 @@ +package com.structurizr.dsl; + +final class PluginParser extends AbstractParser { + + private static final String GRAMMAR = "!plugin "; + + private static final int FQN_INDEX = 1; + + private final static int PARAMETER_NAME_INDEX = 0; + private final static int PARAMETER_VALUE_INDEX = 1; + + String parse(DslContext context, Tokens tokens) { + // !plugin + + if (tokens.hasMoreThan(FQN_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(FQN_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + return tokens.get(FQN_INDEX); + } + + void parseParameter(PluginDslContext context, Tokens tokens) { + // + + if (tokens.hasMoreThan(PARAMETER_VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: "); + } + + if (tokens.size() != 2) { + throw new RuntimeException("Expected: "); + } + + String name = tokens.get(PARAMETER_NAME_INDEX); + String value = tokens.get(PARAMETER_VALUE_INDEX); + + context.addParameter(name, value); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertiesDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertiesDslContext.java new file mode 100644 index 000000000..6b31503de --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertiesDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.PropertyHolder; + +import java.util.ArrayList; +import java.util.Collection; + +final class PropertiesDslContext extends DslContext { + + private final Collection propertyHolders = new ArrayList<>(); + + public PropertiesDslContext(PropertyHolder propertyHolder) { + this.propertyHolders.add(propertyHolder); + } + + public PropertiesDslContext(Collection propertyHolders) { + this.propertyHolders.addAll(propertyHolders); + } + + Collection getPropertyHolders() { + return this.propertyHolders; + } + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertyParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertyParser.java new file mode 100644 index 000000000..0a1ba6fb5 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/PropertyParser.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.PropertyHolder; + +final class PropertyParser extends AbstractParser { + + private final static int PROPERTY_NAME_INDEX = 0; + private final static int PROPERTY_VALUE_INDEX = 1; + + void parse(PropertiesDslContext context, Tokens tokens) { + // + + if (tokens.hasMoreThan(PROPERTY_VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: "); + } + + if (tokens.size() != 2) { + throw new RuntimeException("Expected: "); + } + + String name = tokens.get(PROPERTY_NAME_INDEX); + String value = tokens.get(PROPERTY_VALUE_INDEX); + + for (PropertyHolder propertyHolder : context.getPropertyHolders()) { + propertyHolder.addProperty(name, value); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipArchetypeDslContext.java new file mode 100644 index 000000000..d25d329ae --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipArchetypeDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class RelationshipArchetypeDslContext extends ArchetypeDslContext { + + RelationshipArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TECHNOLOGY_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipDslContext.java new file mode 100644 index 000000000..b39e3043f --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipDslContext.java @@ -0,0 +1,33 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Relationship; + +final class RelationshipDslContext extends ModelItemDslContext { + + private Relationship relationship; + + RelationshipDslContext(Relationship relationship) { + this.relationship = relationship; + } + + Relationship getRelationship() { + return relationship; + } + + @Override + ModelItem getModelItem() { + return getRelationship(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleDslContext.java new file mode 100644 index 000000000..58239b18f --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleDslContext.java @@ -0,0 +1,33 @@ +package com.structurizr.dsl; + +import com.structurizr.view.RelationshipStyle; + +final class RelationshipStyleDslContext extends DslContext { + + private RelationshipStyle style; + + RelationshipStyleDslContext(RelationshipStyle style) { + this.style = style; + } + + RelationshipStyle getStyle() { + return style; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_STYLE_THICKNESS_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_COLOR_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_COLOUR_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_LINE_STYLE_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_ROUTING_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_FONT_SIZE_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_WIDTH_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_POSITION_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_OPACITY_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleParser.java new file mode 100644 index 000000000..c126aae43 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipStyleParser.java @@ -0,0 +1,260 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.HashMap; +import java.util.Map; + +final class RelationshipStyleParser extends AbstractParser { + + private static final int FIRST_PROPERTY_INDEX = 1; + + RelationshipStyle parseRelationshipStyle(StylesDslContext context, Tokens tokens) { + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: relationship {"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String tag = tokens.get(1); + + if (StringUtils.isNullOrEmpty(tag)) { + throw new RuntimeException("A tag must be specified"); + } + + Workspace workspace = context.getWorkspace(); + RelationshipStyle relationshipStyle = workspace.getViews().getConfiguration().getStyles().getRelationshipStyle(tag, context.getColorScheme()); + if (relationshipStyle == null) { + relationshipStyle = workspace.getViews().getConfiguration().getStyles().addRelationshipStyle(tag, context.getColorScheme()); + } + + return relationshipStyle; + } else { + throw new RuntimeException("Expected: relationship {"); + } + } + + void parseThickness(RelationshipStyleDslContext context, Tokens tokens) { + // thickness + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: thickness "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String thicknessAsString = tokens.get(1); + + try { + int thickness = Integer.parseInt(thicknessAsString); + style.setThickness(thickness); + } catch (NumberFormatException e) { + throw new RuntimeException("Thickness must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: thickness "); + } + } + + void parseColour(RelationshipStyleDslContext context, Tokens tokens) { + // colour #rrggbb|color name + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: colour <#rrggbb|color name>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String colour = tokens.get(1); + style.setColor(colour); + } else { + throw new RuntimeException("Expected: colour <#rrggbb|color name>"); + } + } + + void parseDashed(RelationshipStyleDslContext context, Tokens tokens) { + // dashed true|false + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: dashed "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String dashed = tokens.get(1); + + if ("true".equalsIgnoreCase(dashed)) { + style.setDashed(true); + } else if ("false".equalsIgnoreCase(dashed)) { + style.setDashed(false); + } else { + throw new RuntimeException("Dashed must be true or false"); + } + } else { + throw new RuntimeException("Expected: dashed "); + } + } + + void parseOpacity(RelationshipStyleDslContext context, Tokens tokens) { + // opacity 0-100 + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: opacity <0-100>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String opacityAsString = tokens.get(1); + + try { + int opacity = Integer.parseInt(opacityAsString); + style.setOpacity(opacity); + } catch (NumberFormatException e) { + throw new RuntimeException("Opacity must be an integer between 0 and 100"); + } + } else { + throw new RuntimeException("Expected: opacity <0-100>"); + } + } + + void parseWidth(RelationshipStyleDslContext context, Tokens tokens) { + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: width "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String widthAsString = tokens.get(1); + + try { + int width = Integer.parseInt(widthAsString); + style.setWidth(width); + } catch (NumberFormatException e) { + throw new RuntimeException("Width must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: width "); + } + } + + void parseFontSize(RelationshipStyleDslContext context, Tokens tokens) { + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: fontSize "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String fontSizeAsString = tokens.get(1); + + try { + int fontSize = Integer.parseInt(fontSizeAsString); + style.setFontSize(fontSize); + } catch (NumberFormatException e) { + throw new RuntimeException("Font size must be a positive integer"); + } + } else { + throw new RuntimeException("Expected: fontSize "); + } + } + + void parsePosition(RelationshipStyleDslContext context, Tokens tokens) { + // position 0-100 + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: position <0-100>"); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String positionAsString = tokens.get(1); + + try { + int opacity = Integer.parseInt(positionAsString); + style.setPosition(opacity); + } catch (NumberFormatException e) { + throw new RuntimeException("Position must be an integer between 0 and 100"); + } + } else { + throw new RuntimeException("Expected: position <0-100>"); + } + } + + void parseLineStyle(RelationshipStyleDslContext context, Tokens tokens) { + // style solid|dashed|dotted + Map lineStyles = new HashMap<>(); + for (LineStyle lineStyle : LineStyle.values()) { + lineStyles.put(lineStyle.toString().toLowerCase(), lineStyle); + } + + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: style "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String lineStyle = tokens.get(1).toLowerCase(); + + if (lineStyles.containsKey(lineStyle)) { + style.setStyle(lineStyles.get(lineStyle)); + } else { + throw new RuntimeException("The line style \"" + lineStyle + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: style "); + } + } + + void parseRouting(RelationshipStyleDslContext context, Tokens tokens) { + // routing direct|orthogonal|curved + Map routings = new HashMap<>(); + for (Routing routing : Routing.values()) { + routings.put(routing.toString().toLowerCase(), routing); + } + + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: routing "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String routing = tokens.get(1).toLowerCase(); + + if (routings.containsKey(routing)) { + style.setRouting(routings.get(routing)); + } else { + throw new RuntimeException("The routing \"" + routing + "\" is not valid"); + } + } else { + throw new RuntimeException("Expected: routing "); + } + } + + void parseJump(RelationshipStyleDslContext context, Tokens tokens) { + // jump + RelationshipStyle style = context.getStyle(); + + if (tokens.hasMoreThan(FIRST_PROPERTY_INDEX)) { + throw new RuntimeException("Too many tokens, expected: jump "); + } + + if (tokens.includes(FIRST_PROPERTY_INDEX)) { + String jump = tokens.get(1); + + if ("true".equalsIgnoreCase(jump)) { + style.setJump(true); + } else if ("false".equalsIgnoreCase(jump)) { + style.setJump(false); + } else { + throw new RuntimeException("Jump must be true or false"); + } + } else { + throw new RuntimeException("Expected: jump "); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipsDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipsDslContext.java new file mode 100644 index 000000000..9a830de7f --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/RelationshipsDslContext.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Relationship; + +import java.util.Set; +import java.util.stream.Collectors; + +class RelationshipsDslContext extends ModelItemsDslContext { + + private final Set relationships; + + RelationshipsDslContext(DslContext parentDslContext, Set relationships) { + super(parentDslContext); + this.relationships = relationships; + } + + Set getRelationships() { + return relationships; + } + + @Override + Set getModelItems() { + return relationships.stream().map(e -> (ModelItem)e).collect(Collectors.toSet()); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptDslContext.java new file mode 100644 index 000000000..ee25451ec --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptDslContext.java @@ -0,0 +1,101 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; + +import javax.script.*; +import java.io.File; +import java.io.FileReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +abstract class ScriptDslContext extends DslContext { + + private static final String CONTEXT_VARIABLE_NAME = "context"; + private static final String WORKSPACE_VARIABLE_NAME = "workspace"; + private static final String VIEW_VARIABLE_NAME = "view"; + private static final String ELEMENT_VARIABLE_NAME = "element"; + private static final String RELATIONSHIP_VARIABLE_NAME = "relationship"; + + private final DslContext parentContext; + + protected final File dslFile; + private final StructurizrDslParser dslParser; + + private final Map parameters = new HashMap<>(); + + ScriptDslContext(DslContext parentContext, File dslFile, StructurizrDslParser dslParser) { + this.parentContext = parentContext; + this.dslFile = dslFile; + this.dslParser = dslParser; + setDslPortable(false); + } + + void addParameter(String name, String value) { + parameters.put(name, value); + } + + void run(DslContext context, String extension, List lines) throws Exception { + StringBuilder script = new StringBuilder(); + for (String line : lines) { + script.append(line); + script.append('\n'); + } + + ScriptEngineManager manager = new ScriptEngineManager(); + ScriptEngine engine = manager.getEngineByExtension(extension); + + if (engine != null) { + Bindings bindings = engine.createBindings(); + populateBindings(bindings, context); + + engine.eval(script.toString(), bindings); + } else { + throw new RuntimeException("Could not load a scripting engine for extension \"" + extension + "\""); + } + } + + void run(DslContext context, File scriptFile) throws Exception { + String extension = scriptFile.getName().substring(scriptFile.getName().lastIndexOf('.') + 1); + ScriptEngineManager manager = new ScriptEngineManager(); + ScriptEngine engine = manager.getEngineByExtension(extension); + + if (engine != null) { + Bindings bindings = engine.createBindings(); + populateBindings(bindings, context); + + ScriptContext scriptContext = new SimpleScriptContext(); + scriptContext.setBindings(bindings, ScriptContext.ENGINE_SCOPE); + scriptContext.setAttribute(ScriptEngine.FILENAME, scriptFile.getAbsolutePath(), ScriptContext.ENGINE_SCOPE); + engine.eval(new FileReader(scriptFile), scriptContext); + } else { + throw new RuntimeException("Could not load a scripting engine for extension \"" + extension + "\""); + } + } + + private void populateBindings(Bindings bindings, DslContext context) { + bindings.put(WORKSPACE_VARIABLE_NAME, context.getWorkspace()); + + if (parentContext instanceof ViewDslContext) { + bindings.put(VIEW_VARIABLE_NAME, ((ViewDslContext)parentContext).getView()); + } else if (parentContext instanceof ModelItemDslContext) { + ModelItemDslContext modelItemDslContext = (ModelItemDslContext)parentContext; + if (modelItemDslContext.getModelItem() instanceof Element) { + bindings.put(ELEMENT_VARIABLE_NAME, modelItemDslContext.getModelItem()); + } else if (modelItemDslContext.getModelItem() instanceof Relationship) { + bindings.put(RELATIONSHIP_VARIABLE_NAME, modelItemDslContext.getModelItem()); + } + } + + // bind a context object + StructurizrDslScriptContext scriptContext = new StructurizrDslScriptContext(dslParser, dslFile, getWorkspace(), parameters); + bindings.put(CONTEXT_VARIABLE_NAME, scriptContext); + + // and any custom parameters + for (String name : parameters.keySet()) { + bindings.put(name, parameters.get(name)); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptParser.java new file mode 100644 index 000000000..6e11a0764 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ScriptParser.java @@ -0,0 +1,66 @@ +package com.structurizr.dsl; + +final class ScriptParser extends AbstractParser { + + private static final String EXTERNAL_GRAMMAR = "!script "; + private static final String INLINE_GRAMMAR = "!script "; + + private static final int FILENAME_INDEX = 1; + private static final int LANGUAGE_INDEX = 1; + + private final static int PARAMETER_NAME_INDEX = 0; + private final static int PARAMETER_VALUE_INDEX = 1; + + boolean isInlineScript(Tokens tokens) { + return + DslContext.CONTEXT_START_TOKEN.equalsIgnoreCase(tokens.get(tokens.size()-1)) && + tokens.includes(LANGUAGE_INDEX) && + InlineScriptDslContext.SUPPORTED_LANGUAGES.containsKey(tokens.get(LANGUAGE_INDEX).toLowerCase()); + } + + String parseExternal(Tokens tokens) { + // !script + + if (tokens.hasMoreThan(FILENAME_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + EXTERNAL_GRAMMAR); + } + + if (!tokens.includes(FILENAME_INDEX)) { + throw new RuntimeException("Expected: " + EXTERNAL_GRAMMAR); + } + + return tokens.get(FILENAME_INDEX); + } + + void parseParameter(ExternalScriptDslContext context, Tokens tokens) { + // + + if (tokens.hasMoreThan(PARAMETER_VALUE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: "); + } + + if (tokens.size() != 2) { + throw new RuntimeException("Expected: "); + } + + String name = tokens.get(PARAMETER_NAME_INDEX); + String value = tokens.get(PARAMETER_VALUE_INDEX); + + context.addParameter(name, value); + } + + String parseInline(Tokens tokens) { + // !script + + if (tokens.hasMoreThan(LANGUAGE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + INLINE_GRAMMAR); + } + + if (!tokens.includes(LANGUAGE_INDEX)) { + throw new RuntimeException("Expected: " + INLINE_GRAMMAR); + } + + return tokens.get(LANGUAGE_INDEX); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemArchetypeDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemArchetypeDslContext.java new file mode 100644 index 000000000..0283d5716 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemArchetypeDslContext.java @@ -0,0 +1,20 @@ +package com.structurizr.dsl; + +final class SoftwareSystemArchetypeDslContext extends ElementArchetypeDslContext { + + SoftwareSystemArchetypeDslContext(Archetype archetype) { + super(archetype); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAG_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemDslContext.java new file mode 100644 index 000000000..d86cbdaeb --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemDslContext.java @@ -0,0 +1,51 @@ +package com.structurizr.dsl; + +import com.structurizr.model.GroupableElement; +import com.structurizr.model.ModelItem; +import com.structurizr.model.SoftwareSystem; + +final class SoftwareSystemDslContext extends GroupableElementDslContext { + + private SoftwareSystem softwareSystem; + + SoftwareSystemDslContext(SoftwareSystem softwareSystem) { + this(softwareSystem, null); + } + + SoftwareSystemDslContext(SoftwareSystem softwareSystem, ElementGroup group) { + super(group); + + this.softwareSystem = softwareSystem; + } + + SoftwareSystem getSoftwareSystem() { + return softwareSystem; + } + + @Override + ModelItem getModelItem() { + return getSoftwareSystem(); + } + + @Override + GroupableElement getElement() { + return softwareSystem; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.DOCS_TOKEN, + StructurizrDslTokens.DECISIONS_TOKEN, + StructurizrDslTokens.GROUP_TOKEN, + StructurizrDslTokens.CONTAINER_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.RELATIONSHIP_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceDslContext.java new file mode 100644 index 000000000..607db5485 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceDslContext.java @@ -0,0 +1,48 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystemInstance; +import com.structurizr.model.ModelItem; +import com.structurizr.model.StaticStructureElementInstance; + +final class SoftwareSystemInstanceDslContext extends StaticStructureElementInstanceDslContext { + + private final SoftwareSystemInstance softwareSystemInstance; + + SoftwareSystemInstanceDslContext(SoftwareSystemInstance softwareSystemInstance) { + this.softwareSystemInstance = softwareSystemInstance; + } + + SoftwareSystemInstance getSoftwareSystemInstance() { + return softwareSystemInstance; + } + + @Override + ModelItem getModelItem() { + return getSoftwareSystemInstance(); + } + + @Override + Element getElement() { + return getSoftwareSystemInstance(); + } + + @Override + StaticStructureElementInstance getElementInstance() { + return getSoftwareSystemInstance(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.RELATIONSHIP_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.TAGS_TOKEN, + StructurizrDslTokens.URL_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.PERSPECTIVES_TOKEN, + StructurizrDslTokens.HEALTH_CHECK_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceParser.java new file mode 100644 index 000000000..88f5da70d --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemInstanceParser.java @@ -0,0 +1,60 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.SoftwareSystemInstance; + +import java.util.HashSet; +import java.util.Set; + +final class SoftwareSystemInstanceParser extends StaticStructureInstanceParser { + + private static final String GRAMMAR = "softwareSystemInstance [deploymentGroups] [tags]"; + + private static final int IDENTIFIER_INDEX = 1; + private static final int DEPLOYMENT_GROUPS_TOKEN = 2; + private static final int TAGS_INDEX = 3; + + SoftwareSystemInstance parse(DeploymentNodeDslContext context, Tokens tokens) { + // softwareSystemInstance [tags] + // softwareSystemInstance [deploymentGroup] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String softwareSystemIdentifier = tokens.get(IDENTIFIER_INDEX); + + Element element = context.getElement(softwareSystemIdentifier, SoftwareSystem.class); + if (element == null) { + throw new RuntimeException("The software system \"" + softwareSystemIdentifier + "\" does not exist"); + } + + DeploymentNode deploymentNode = context.getDeploymentNode(); + + Set deploymentGroups = new HashSet<>(); + if (tokens.includes(DEPLOYMENT_GROUPS_TOKEN)) { + deploymentGroups = getDeploymentGroups(context, tokens.get(DEPLOYMENT_GROUPS_TOKEN)); + } + + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add((SoftwareSystem)element, deploymentGroups.toArray(new String[]{})); + + if (tokens.includes(TAGS_INDEX)) { + String tags = tokens.get(TAGS_INDEX); + softwareSystemInstance.addTags(tags.split(",")); + } + + if (context.hasGroup()) { + softwareSystemInstance.setGroup(context.getGroup().getName()); + context.getGroup().addElement(softwareSystemInstance); + } + + return softwareSystemInstance; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemParser.java new file mode 100644 index 000000000..ffae2403e --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SoftwareSystemParser.java @@ -0,0 +1,62 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +final class SoftwareSystemParser extends AbstractParser { + + private static final String GRAMMAR = "softwareSystem [description] [tags]"; + + private final static int NAME_INDEX = 1; + private final static int DESCRIPTION_INDEX = 2; + private final static int TAGS_INDEX = 3; + + SoftwareSystem parse(ModelDslContext context, Tokens tokens, Archetype archetype) { + // softwareSystem [description] [tags] + + if (tokens.hasMoreThan(TAGS_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(NAME_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + SoftwareSystem softwareSystem = null; + String name = tokens.get(NAME_INDEX); + + if (context.isExtendingWorkspace()) { + softwareSystem = context.getWorkspace().getModel().getSoftwareSystemWithName(name); + } + + if (softwareSystem == null) { + softwareSystem = context.getWorkspace().getModel().addSoftwareSystem(name); + } + + String description = archetype.getDescription(); + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + softwareSystem.setDescription(description); + + List tags = new ArrayList<>(archetype.getTags()); + if (tokens.includes(TAGS_INDEX)) { + tags.addAll(Arrays.asList(tokens.get(TAGS_INDEX).split(","))); + } + softwareSystem.addTags(tags.toArray(new String[0])); + + softwareSystem.addProperties(archetype.getProperties()); + softwareSystem.addPerspectives(archetype.getPerspectives()); + + if (context.hasGroup()) { + softwareSystem.setGroup(context.getGroup().getName()); + context.getGroup().addElement(softwareSystem); + } + + return softwareSystem; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureElementInstanceDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureElementInstanceDslContext.java new file mode 100644 index 000000000..7066961c3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureElementInstanceDslContext.java @@ -0,0 +1,9 @@ +package com.structurizr.dsl; + +import com.structurizr.model.StaticStructureElementInstance; + +abstract class StaticStructureElementInstanceDslContext extends ElementDslContext { + + abstract StaticStructureElementInstance getElementInstance(); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureInstanceParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureInstanceParser.java new file mode 100644 index 000000000..c1b191be9 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticStructureInstanceParser.java @@ -0,0 +1,34 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; + +import java.util.HashSet; +import java.util.Set; + +abstract class StaticStructureInstanceParser extends AbstractParser { + + protected Set getDeploymentGroups(DeploymentNodeDslContext context, String token) { + Set deploymentGroups = new HashSet<>(); + String[] deploymentGroupReferences = token.split(","); + for (String deploymentGroupReference : deploymentGroupReferences) { + Element e = context.getElement(deploymentGroupReference, DeploymentGroup.class); + + if (e == null) { + // try to find deployment group via hierarchical identifier + String deploymentEnvironmentName = context.getDeploymentNode().getEnvironment(); + String deploymentEnvironmentIdentifier = context.findIdentifier(new DeploymentEnvironment(deploymentEnvironmentName)); + + e = context.getElement(deploymentEnvironmentIdentifier + "." + deploymentGroupReference, DeploymentGroup.class); + } + + if (e instanceof DeploymentGroup) { + deploymentGroups.add(e.getName()); + } else { + // backwards compatibility - deployment environment name rather than identifier + } + } + + return deploymentGroups; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationDslContext.java new file mode 100644 index 000000000..641575692 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationDslContext.java @@ -0,0 +1,26 @@ +package com.structurizr.dsl; + +import com.structurizr.view.StaticView; + +class StaticViewAnimationDslContext extends DslContext { + + private StaticView view; + + StaticViewAnimationDslContext(StaticView view) { + super(); + + this.view = view; + } + + StaticView getView() { + return view; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ANIMATION_STEP_IN_VIEW_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationStepParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationStepParser.java new file mode 100644 index 000000000..9ce90cfee --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewAnimationStepParser.java @@ -0,0 +1,72 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.ModelItem; +import com.structurizr.model.StaticStructureElement; +import com.structurizr.view.StaticView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +final class StaticViewAnimationStepParser extends AbstractParser { + + private static final String GRAMMAR = " [identifier|element expression...]"; + + void parse(StaticViewDslContext context, Tokens tokens) { + // animationStep [identifier|element expression...] + + if (!tokens.includes(1)) { + throw new RuntimeException("Expected: animationStep " + GRAMMAR); + } + + parse(context, context.getView(), tokens, 1); + } + + void parse(StaticViewAnimationDslContext context, Tokens tokens) { + // [identifier|element expression...] + + if (!tokens.includes(0)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + parse(context, context.getView(), tokens, 0); + } + + private void parse(DslContext context, StaticView view, Tokens tokens, int startIndex) { + List staticStructureElements = new ArrayList<>(); + + for (int i = startIndex; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (ExpressionParser.isExpression(token.toLowerCase())) { + Set elements = new StaticViewExpressionParser().parseExpression(token, context); + + for (ModelItem element : elements) { + if (element instanceof StaticStructureElement) { + staticStructureElements.add((StaticStructureElement)element); + } + } + } else { + Set elements = new StaticViewExpressionParser().parseIdentifier(token, context); + + if (elements.isEmpty()) { + throw new RuntimeException("The element \"" + token + "\" does not exist"); + } + + for (ModelItem element : elements) { + if (element instanceof StaticStructureElement) { + staticStructureElements.add((StaticStructureElement)element); + } + } + } + } + + if (!staticStructureElements.isEmpty()) { + view.addAnimation(staticStructureElements.toArray(new Element[0])); + } else { + throw new RuntimeException("No elements were found"); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewContentParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewContentParser.java new file mode 100644 index 000000000..a6c653d0b --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewContentParser.java @@ -0,0 +1,121 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +final class StaticViewContentParser extends ModelViewContentParser { + + private static final int FIRST_IDENTIFIER_INDEX = 1; + + void parseInclude(StaticViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + if (context.getView() instanceof SystemContextView || context.getView() instanceof ContainerView || context.getView() instanceof ComponentView) { + throw new RuntimeException("Expected: include <*|*?|identifier|expression> [*|identifier|expression...]"); + } else { + throw new RuntimeException("Expected: include <*|identifier|expression> [*|identifier|expression...]"); + } + } + + StaticView view = context.getView(); + + // include [identifier|expression...] + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (token.equals(WILDCARD) || token.equals(ELEMENT_WILDCARD)) { + // include * or include element==* + view.addDefaultElements(true); + } else if (token.equals(WILDCARD_RELUCTANT)) { + // include *? + view.addDefaultElements(false); + } else if (isExpression(token)) { + new StaticViewExpressionParser().parseExpression(token, context).forEach(mi -> addModelItemToView(mi, view, null)); + } else { + new StaticViewExpressionParser().parseIdentifier(token, context).forEach(mi -> addModelItemToView(mi, view, token)); + } + } + } + + void parseExclude(StaticViewDslContext context, Tokens tokens) { + if (!tokens.includes(FIRST_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: exclude [identifier|expression...]"); + } + + StaticView view = context.getView(); + + // exclude [identifier|expression...] + for (int i = FIRST_IDENTIFIER_INDEX; i < tokens.size(); i++) { + String token = tokens.get(i); + + if (isExpression(token)) { + new StaticViewExpressionParser().parseExpression(token, context).forEach(mi -> removeModelItemFromView(mi, view)); + } else { + new StaticViewExpressionParser().parseIdentifier(token, context).forEach(mi -> removeModelItemFromView(mi, view)); + } + } + } + + private void addModelItemToView(ModelItem modelItem, StaticView view, String identifier) { + if (modelItem instanceof Element) { + addElementToView((Element)modelItem, view, identifier); + } else { + addRelationshipToView((Relationship)modelItem, view); + } + } + + private void addElementToView(Element element, StaticView view, String identifier) { + try { + if (element instanceof CustomElement) { + view.add((CustomElement) element); + } else if (element instanceof Person) { + view.add((Person) element); + } else if (element instanceof SoftwareSystem) { + view.add((SoftwareSystem) element); + } else if (element instanceof Container && (view instanceof ContainerView)) { + ((ContainerView) view).add((Container) element); + } else if (element instanceof Container && (view instanceof ComponentView)) { + ((ComponentView) view).add((Container) element); + } else if (element instanceof Component && (view instanceof ComponentView)) { + ((ComponentView) view).add((Component) element); + } else { + if (!StringUtils.isNullOrEmpty(identifier)) { + throw new RuntimeException("The element \"" + identifier + "\" can not be added to this type of view"); + } + } + } catch (ElementNotPermittedInViewException e) { + // ignore + } + } + + private void removeModelItemFromView(ModelItem modelItem, StaticView view) { + if (modelItem instanceof Element) { + removeElementFromView((Element)modelItem, view); + } else { + removeRelationshipFromView((Relationship)modelItem, view); + } + } + + private void removeElementFromView(Element element, StaticView view) { + if (element instanceof CustomElement) { + view.remove((CustomElement) element); + } else if (element instanceof Person) { + view.remove((Person) element); + } else if (element instanceof SoftwareSystem) { + view.remove((SoftwareSystem) element); + } else if (element instanceof Container && (view instanceof ContainerView)) { + ((ContainerView) view).remove((Container) element); + } else if (element instanceof Container && (view instanceof ComponentView)) { + ((ComponentView) view).remove((Container) element); + } else if (element instanceof Component && (view instanceof ComponentView)) { + ((ComponentView) view).remove((Component) element); + } + } + + private void addRelationshipToView(Relationship relationship, StaticView view) { + if (view.isElementInView(relationship.getSource()) && view.isElementInView(relationship.getDestination())) { + view.add(relationship); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewDslContext.java new file mode 100644 index 000000000..01c0c5307 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.view.StaticView; + +class StaticViewDslContext extends ModelViewDslContext { + + StaticViewDslContext(StaticView view) { + super(view); + } + + StaticView getView() { + return (StaticView)super.getView(); + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.INCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.EXCLUDE_IN_VIEW_TOKEN, + StructurizrDslTokens.AUTOLAYOUT_VIEW_TOKEN, + StructurizrDslTokens.DEFAULT_VIEW_TOKEN, + StructurizrDslTokens.ANIMATION_IN_VIEW_TOKEN, + StructurizrDslTokens.VIEW_TITLE_TOKEN, + StructurizrDslTokens.VIEW_DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewExpressionParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewExpressionParser.java new file mode 100644 index 000000000..dfa32f147 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StaticViewExpressionParser.java @@ -0,0 +1,64 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static com.structurizr.dsl.StructurizrDslExpressions.ELEMENT_TYPE_EQUALS_EXPRESSION; + +final class StaticViewExpressionParser extends ExpressionParser { + + @Override + protected Set evaluateElementTypeExpression(String expr, DslContext context) { + Set elements = new LinkedHashSet<>(); + + String type = expr.substring(ELEMENT_TYPE_EQUALS_EXPRESSION.length()); + switch (type.toLowerCase()) { + case "custom": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof CustomElement).forEach(elements::add); + break; + case "person": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Person).forEach(elements::add); + break; + case "softwaresystem": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof SoftwareSystem).forEach(elements::add); + break; + case "container": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Container).forEach(elements::add); + break; + case "component": + context.getWorkspace().getModel().getElements().stream().filter(e -> e instanceof Component).forEach(elements::add); + break; + default: + throw new RuntimeException("The element type of \"" + type + "\" is not valid for this view"); + } + + return elements; + } + + protected Set findAfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findAfferentCouplings(element, CustomElement.class)); + elements.addAll(findAfferentCouplings(element, Person.class)); + elements.addAll(findAfferentCouplings(element, SoftwareSystem.class)); + elements.addAll(findAfferentCouplings(element, Container.class)); + elements.addAll(findAfferentCouplings(element, Component.class)); + + return elements; + } + + protected Set findEfferentCouplings(Element element) { + Set elements = new LinkedHashSet<>(); + + elements.addAll(findEfferentCouplings(element, CustomElement.class)); + elements.addAll(findEfferentCouplings(element, Person.class)); + elements.addAll(findEfferentCouplings(element, SoftwareSystem.class)); + elements.addAll(findEfferentCouplings(element, Container.class)); + elements.addAll(findEfferentCouplings(element, Component.class)); + + return elements; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslExpressions.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslExpressions.java new file mode 100644 index 000000000..ba9ae26fb --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslExpressions.java @@ -0,0 +1,27 @@ +package com.structurizr.dsl; + +class StructurizrDslExpressions { + + static final String ELEMENT_TYPE_EQUALS_EXPRESSION = "element.type=="; + static final String ELEMENT_TECHNOLOGY_EQUALS_EXPRESSION = "element.technology=="; + static final String ELEMENT_TECHNOLOGY_NOT_EQUALS_EXPRESSION = "element.technology!="; + static final String ELEMENT_TAG_EQUALS_EXPRESSION = "element.tag=="; + static final String ELEMENT_TAG_NOT_EQUALS_EXPRESSION = "element.tag!="; + static final String ELEMENT_PROPERTY_EQUALS_EXPRESSION = "element\\.properties\\[.*]==.*"; + + static final String ELEMENT_EQUALS_EXPRESSION = "element=="; + static final String ELEMENT_NOT_EQUALS_EXPRESSION = "element!="; + static final String ELEMENT_PARENT_EQUALS_EXPRESSION = "element.parent=="; + + static final String RELATIONSHIP_TAG_EQUALS_EXPRESSION = "relationship.tag=="; + static final String RELATIONSHIP_TAG_NOT_EQUALS_EXPRESSION = "relationship.tag!="; + static final String RELATIONSHIP_PROPERTY_EQUALS_EXPRESSION = "relationship\\.properties\\[.*]==.*"; + + static final String RELATIONSHIP_SOURCE_EQUALS_EXPRESSION = "relationship.source=="; + static final String RELATIONSHIP_DESTINATION_EQUALS_EXPRESSION = "relationship.destination=="; + + static final String RELATIONSHIP_EQUALS_EXPRESSION = "relationship=="; + + static final String RELATIONSHIP = "->"; + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParser.java new file mode 100644 index 000000000..faf98ce36 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParser.java @@ -0,0 +1,1612 @@ +package com.structurizr.dsl; + +import com.structurizr.PropertyHolder; +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.model.*; +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Main DSL parser class - forms the API for using the parser. + */ +public final class StructurizrDslParser extends StructurizrDslTokens { + + private static final Log log = LogFactory.getLog(StructurizrDslParser.class); + + private static final String BOM = "\uFEFF"; + + private static final Pattern EMPTY_LINE_PATTERN = Pattern.compile("^\\s*"); + + private static final Pattern COMMENT_PATTERN = Pattern.compile("^\\s*?(//|#).*$"); + private static final String MULTI_LINE_COMMENT_START_TOKEN = "/*"; + private static final String MULTI_LINE_COMMENT_END_TOKEN = "*/"; + private static final String MULTI_LINE_SEPARATOR = "\\"; + private static final String TEXT_BLOCK_MARKER = "\"\"\""; + + private static final Pattern STRING_SUBSTITUTION_PATTERN = Pattern.compile("(\\$\\{[a-zA-Z0-9-_.]+?})"); + private static final String STRING_SUBSTITUTION_TEMPLATE = "${%s}"; + + private static final String STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME = "structurizr.dsl.identifier"; + + private Charset characterEncoding = StandardCharsets.UTF_8; + private IdentifierScope identifierScope = IdentifierScope.Flat; + private final Stack contextStack; + private final Set parsedTokens = new HashSet<>(); + private final IdentifiersRegister identifiersRegister; + private Map constantsAndVariables; + private Features features = new Features(); + private HttpClient httpClient = new HttpClient(); + + private Map> archetypes = Map.of( + StructurizrDslTokens.GROUP_TOKEN, new HashMap<>(), + StructurizrDslTokens.CUSTOM_ELEMENT_TOKEN, new HashMap<>(), + StructurizrDslTokens.PERSON_TOKEN, new HashMap<>(), + StructurizrDslTokens.SOFTWARE_SYSTEM_TOKEN, new HashMap<>(), + StructurizrDslTokens.CONTAINER_TOKEN, new HashMap<>(), + StructurizrDslTokens.COMPONENT_TOKEN, new HashMap<>(), + StructurizrDslTokens.DEPLOYMENT_NODE_TOKEN, new HashMap<>(), + StructurizrDslTokens.INFRASTRUCTURE_NODE_TOKEN, new HashMap<>(), + StructurizrDslTokens.RELATIONSHIP_TOKEN, new HashMap<>() + ); + + private boolean dslPortable = true; + private final List dslSourceLines = new ArrayList<>(); + private Workspace workspace; + private boolean extendingWorkspace = false; + + /** + * Creates a new instance of the parser. + */ + public StructurizrDslParser() { + contextStack = new Stack<>(); + identifiersRegister = new IdentifiersRegister(); + constantsAndVariables = new HashMap<>(); + + features.enable(Features.ENVIRONMENT); + features.enable(Features.FILE_SYSTEM); + features.enable(Features.HTTP); + features.enable(Features.HTTPS); + + features.enable(Features.PLUGINS); + features.enable(Features.SCRIPTS); + features.enable(Features.COMPONENT_FINDER); + + features.enable(Features.DOCUMENTATION); + features.enable(Features.DECISIONS); + + features.enable(Features.INCLUDE); + } + + void configureFrom(StructurizrDslParser parser) { + setIdentifierScope(parser.getIdentifierScope()); + archetypes = parser.archetypes; + constantsAndVariables = parser.constantsAndVariables; + } + + /** + * Provides a way to change the character encoding used by the DSL parser. + * + * @param characterEncoding a Charset instance + */ + public void setCharacterEncoding(Charset characterEncoding) { + if (characterEncoding == null) { + throw new IllegalArgumentException("A character encoding must be specified"); + } + + this.characterEncoding = characterEncoding; + } + + IdentifierScope getIdentifierScope() { + return identifierScope; + } + + private void setIdentifierScope(IdentifierScope identifierScope) { + if (identifierScope == null) { + identifierScope = IdentifierScope.Flat; + } + + this.identifierScope = identifierScope; + this.identifiersRegister.setIdentifierScope(identifierScope); + } + + /** + * Sets whether to run this parser in restricted mode (this stops !include, !docs, !adrs from working). + * + * @param restricted true for restricted mode, false otherwise + */ + @Deprecated + public void setRestricted(boolean restricted) { + features.configure(Features.ENVIRONMENT, !restricted); + features.configure(Features.FILE_SYSTEM, !restricted); + + features.configure(Features.PLUGINS, !restricted); + features.configure(Features.SCRIPTS, !restricted); + features.configure(Features.COMPONENT_FINDER, !restricted); + + features.configure(Features.DOCUMENTATION, !restricted); + features.configure(Features.DECISIONS, !restricted); + } + + /** + * Gets the workspace that has been created by parsing the Structurizr DSL. + * + * @return a Workspace instance + */ + public Workspace getWorkspace() { + if (workspace != null) { + if (dslPortable) { + String value = workspace.getProperties().get(DslUtils.STRUCTURIZR_DSL_RETAIN_SOURCE_PROPERTY_NAME); + if (value == null || value.equalsIgnoreCase("true")) { + DslUtils.setDsl(workspace, getParsedDsl()); + } + } + } + + return workspace; + } + + private String getParsedDsl() { + return String.join(System.lineSeparator(), dslSourceLines); + } + + void parse(DslParserContext context, File path) throws StructurizrDslParserException { + parse(path); + + context.copyFrom(identifiersRegister); + } + + /** + * Parses the specified Structurizr DSL file. + * + * @param dslFile a File object representing a DSL file + * @throws StructurizrDslParserException when something goes wrong + */ + public void parse(File dslFile) throws StructurizrDslParserException { + if (dslFile == null) { + throw new StructurizrDslParserException("A file must be specified"); + } + + if (!dslFile.exists()) { + throw new StructurizrDslParserException("The file at " + dslFile.getAbsolutePath() + " does not exist"); + } + + try { + parse(Files.readAllLines(dslFile.toPath(), characterEncoding), dslFile, false, true); + } catch (IOException e) { + throw new StructurizrDslParserException(e.getMessage()); + } + } + + void parse(DslParserContext context, String dsl) throws StructurizrDslParserException { + parse(dsl); + + context.copyFrom(identifiersRegister); + } + + /** + * Parses the specified Structurizr DSL, adding the parsed content to the workspace. + * + * @param dsl a Structurizr DSL definition, as a single String + * @throws StructurizrDslParserException when something goes wrong + */ + public void parse(String dsl) throws StructurizrDslParserException { + parse(dsl, new File(".")); + } + + /** + * Parses the specified Structurizr DSL, adding the parsed content to the workspace. + * + * @param dsl a Structurizr DSL definition, as a single String + * @param dslFile a File representing the DSL file, and therefore where includes/images/etc should be loaded relative to + * @throws StructurizrDslParserException when something goes wrong + */ + public void parse(String dsl, File dslFile) throws StructurizrDslParserException { + if (StringUtils.isNullOrEmpty(dsl)) { + throw new StructurizrDslParserException("A DSL fragment must be specified"); + } + + List lines = Arrays.asList(dsl.split("\\r?\\n")); + parse(lines, dslFile, false, true); + } + + void parse(List lines, DslContext dslContext) throws StructurizrDslParserException { + startContext(dslContext); + parse(lines, null, true, false); + endContext(); + } + + /** + * Parses a list of Structurizr DSL lines. + * + * @param lines a Structurizr DSL definition, as a List of String objects (one per line) + * @param dslFile a File representing the DSL file, and therefore where includes/images/etc should be loaded relative to + * @throws StructurizrDslParserException when something goes wrong + */ + void parse(List lines, File dslFile, boolean fragment, boolean includeInDslSourceLines) throws StructurizrDslParserException { + if (includeInDslSourceLines) { + dslSourceLines.addAll(lines); + } + + List dslLines = preProcessLines(lines); + + for (DslLine dslLine : dslLines) { + String line = dslLine.getSource(); + + if (line.startsWith(BOM)) { + // this caters for files encoded as "UTF-8 with BOM" + line = line.substring(1); + } + + try { + if (EMPTY_LINE_PATTERN.matcher(line).matches()) { + // do nothing + } else if (COMMENT_PATTERN.matcher(line).matches()) { + // do nothing + } else if (inContext(InlineScriptDslContext.class)) { + if (DslContext.CONTEXT_END_TOKEN.equals(line.trim())) { + endContext(); + } else { + getContext(InlineScriptDslContext.class).addLine(line); + } + } else { + List listOfTokens = new Tokenizer().tokenize(line); + listOfTokens = listOfTokens.stream().map(this::substituteStrings).collect(Collectors.toList()); + + Tokens tokens = new Tokens(listOfTokens); + + String identifier = null; + if (tokens.size() >= 3 && ASSIGNMENT_OPERATOR_TOKEN.equals(tokens.get(1))) { + identifier = tokens.get(0); + identifiersRegister.validateIdentifierName(identifier); + + tokens = new Tokens(listOfTokens.subList(2, listOfTokens.size())); + } + + String firstToken = tokens.get(0); + + if (line.trim().startsWith(MULTI_LINE_COMMENT_START_TOKEN) && line.trim().endsWith(MULTI_LINE_COMMENT_END_TOKEN)) { + // do nothing + } else if (firstToken.startsWith(MULTI_LINE_COMMENT_START_TOKEN)) { + startContext(new CommentDslContext()); + + } else if (inContext(CommentDslContext.class) && line.trim().endsWith(MULTI_LINE_COMMENT_END_TOKEN)) { + endContext(); + + } else if (inContext(CommentDslContext.class)) { + // do nothing + + } else if (DslContext.CONTEXT_END_TOKEN.equals(tokens.get(0))) { + endContext(); + + } else if (INCLUDE_FILE_TOKEN.equalsIgnoreCase(firstToken)) { + String leadingSpace = line.substring(0, line.indexOf(INCLUDE_FILE_TOKEN)); + + List files = new IncludeParser().parse(getContext(), dslFile, tokens); + for (IncludedFile includedFile : files) { + List paddedLines = new ArrayList<>(); + for (String unpaddedLine : includedFile.getLines()) { + if (unpaddedLine.startsWith(BOM)) { + // this caters for files encoded as "UTF-8 with BOM" + unpaddedLine = unpaddedLine.substring(1); + } + paddedLines.add(leadingSpace + unpaddedLine); + } + + parse(paddedLines, includedFile.getFile(), true, false); + } + + } else if (PLUGIN_TOKEN.equalsIgnoreCase(firstToken)) { + if (features.isEnabled(Features.PLUGINS)) { + String fullyQualifiedClassName = new PluginParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new PluginDslContext(fullyQualifiedClassName, dslFile, this)); + if (!shouldStartContext(tokens)) { + // run the plugin immediately, without looking for parameters + endContext(); + } + } else { + throw new FeatureNotEnabledException(Features.PLUGINS, firstToken + " is not permitted"); + } + + } else if (inContext(PluginDslContext.class)) { + new PluginParser().parseParameter(getContext(PluginDslContext.class), tokens); + + } else if (SCRIPT_TOKEN.equalsIgnoreCase(firstToken)) { + if (features.isEnabled(Features.SCRIPTS)) { + ScriptParser scriptParser = new ScriptParser(); + if (scriptParser.isInlineScript(tokens)) { + String language = scriptParser.parseInline(tokens.withoutContextStartToken()); + startContext(new InlineScriptDslContext(getContext(), dslFile, this, language)); + } else { + String filename = scriptParser.parseExternal(tokens.withoutContextStartToken()); + startContext(new ExternalScriptDslContext(getContext(), dslFile, this, filename)); + + if (shouldStartContext(tokens)) { + // we'll wait for parameters before executing the script + } else { + endContext(); + } + } + } else { + throw new FeatureNotEnabledException(Features.SCRIPTS, firstToken + " is not permitted"); + } + + } else if (inContext(ExternalScriptDslContext.class)) { + new ScriptParser().parseParameter(getContext(ExternalScriptDslContext.class), tokens); + + } else if (tokens.size() >= 4 && tokens.get(1).equals(NO_RELATIONSHIP_TOKEN) && shouldStartContext(tokens) && inContext(DeploymentEnvironmentDslContext.class)) { + // source -/> destination { + // or + // source -/> destination "description" { + + // remove source -> destination (between instances) in the deployment model + Set relationships = new NoRelationshipParser().parse(getContext(DeploymentEnvironmentDslContext.class), tokens.withoutContextStartToken()); + + // find the static element -> static element relationship that the removed relationships were based upon + Relationship relationship = workspace.getModel().getRelationship(relationships.iterator().next().getLinkedRelationshipId()); + + startContext(new NoRelationshipInDeploymentEnvironmentDslContext(getContext(DeploymentEnvironmentDslContext.class), relationship)); + + } else if (tokens.size() > 2 && isRelationshipKeywordOrArchetype(tokens.get(1)) && (inContext(ModelDslContext.class) || inContext(DeploymentEnvironmentDslContext.class) || inContext(ElementDslContext.class))) { + // explicit without archetype: a -> b + // explicit with archetype: a --https-> b + Archetype archetype = getArchetype(RELATIONSHIP_TOKEN, tokens.get(1)); + Set relationships = new ExplicitRelationshipParser().parse(getContext(), tokens.withoutContextStartToken(), archetype); + + if (relationships.size() == 1) { + Relationship relationship = relationships.iterator().next(); + registerIdentifier(identifier, relationship); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipDslContext(relationship)); + } + } else { + if (shouldStartContext(tokens)) { + startContext(new RelationshipsDslContext(getContext(), relationships)); + } + } + + } else if (tokens.size() >= 2 && isRelationshipKeywordOrArchetype(tokens.get(0)) && inContext(ElementDslContext.class)) { + // implicit without archetype: -> this + // implicit with archetype: --https-> this + Archetype archetype = getArchetype(RELATIONSHIP_TOKEN, tokens.get(0)); + Set relationships = new ImplicitRelationshipParser().parse(getContext(ElementDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (relationships.size() == 1) { + Relationship relationship = relationships.iterator().next(); + registerIdentifier(identifier, relationship); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipDslContext(relationship)); + } + } else { + if (shouldStartContext(tokens)) { + startContext(new RelationshipsDslContext(getContext(), relationships)); + } + } + + } else if (tokens.size() > 2 && isRelationshipKeywordOrArchetype(tokens.get(1)) && inContext(ElementsDslContext.class)) { + Archetype archetype = getArchetype(RELATIONSHIP_TOKEN, tokens.get(1)); + Set relationships = new ExplicitRelationshipParser().parse(getContext(ElementsDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipsDslContext(getContext(), relationships)); + } + + } else if (tokens.size() >= 2 && isRelationshipKeywordOrArchetype(tokens.get(0)) && inContext(ElementsDslContext.class)) { + Archetype archetype = getArchetype(RELATIONSHIP_TOKEN, tokens.get(1)); + Set relationships = new ImplicitRelationshipParser().parse(getContext(ElementsDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipsDslContext(getContext(), relationships)); + } + + } else if ((FIND_ELEMENT_TOKEN.equalsIgnoreCase(firstToken) || FIND_RELATIONSHIP_TOKEN.equalsIgnoreCase(firstToken) || REF_TOKEN.equalsIgnoreCase(firstToken) || EXTEND_TOKEN.equalsIgnoreCase(firstToken)) && (inContext(ModelItemDslContext.class) || inContext(ModelDslContext.class))) { + ModelItem modelItem = null; + + if (REF_TOKEN.equalsIgnoreCase(firstToken)) { + throw new RuntimeException(REF_TOKEN + " was previously deprecated, and has now been removed - please use " + FIND_ELEMENT_TOKEN + " or " + FIND_RELATIONSHIP_TOKEN + " instead"); + } else if (EXTEND_TOKEN.equalsIgnoreCase(firstToken)) { + throw new RuntimeException(EXTEND_TOKEN + " was previously deprecated, and has now been removed - please use " + FIND_ELEMENT_TOKEN + " or " + FIND_RELATIONSHIP_TOKEN + " instead"); + } else if (FIND_ELEMENT_TOKEN.equalsIgnoreCase(firstToken)) { + modelItem = new FindElementParser().parse(getContext(), tokens.withoutContextStartToken()); + } else if (FIND_RELATIONSHIP_TOKEN.equalsIgnoreCase(firstToken)) { + modelItem = new FindRelationshipParser().parse(getContext(), tokens.withoutContextStartToken()); + } + + if (shouldStartContext(tokens)) { + if (modelItem instanceof Person) { + startContext(new PersonDslContext((Person)modelItem)); + } else if (modelItem instanceof SoftwareSystem) { + startContext(new SoftwareSystemDslContext((SoftwareSystem)modelItem)); + } else if (modelItem instanceof Container) { + startContext(new ContainerDslContext((Container) modelItem)); + } else if (modelItem instanceof Component) { + startContext(new ComponentDslContext((Component)modelItem)); + } else if (modelItem instanceof DeploymentEnvironment) { + startContext(new DeploymentEnvironmentDslContext(((DeploymentEnvironment)modelItem).getName())); + } else if (modelItem instanceof DeploymentNode) { + startContext(new DeploymentNodeDslContext((DeploymentNode)modelItem)); + } else if (modelItem instanceof InfrastructureNode) { + startContext(new InfrastructureNodeDslContext((InfrastructureNode)modelItem)); + } else if (modelItem instanceof SoftwareSystemInstance) { + startContext(new SoftwareSystemInstanceDslContext((SoftwareSystemInstance)modelItem)); + } else if (modelItem instanceof ContainerInstance) { + startContext(new ContainerInstanceDslContext((ContainerInstance)modelItem)); + } else if (modelItem instanceof Relationship) { + startContext(new RelationshipDslContext((Relationship)modelItem)); + } + } + + if (!StringUtils.isNullOrEmpty(identifier)) { + if (modelItem instanceof Element) { + registerIdentifier(identifier, (Element)modelItem); + } else if (modelItem instanceof Relationship) { + registerIdentifier(identifier, (Relationship)modelItem); + } + } + + } else if (FIND_ELEMENTS_TOKEN.equalsIgnoreCase(firstToken) && (inContext(ModelDslContext.class) || inContext(DeploymentEnvironmentDslContext.class) || inContext(ElementDslContext.class))) { + Set elements = new FindElementsParser().parse(getContext(), tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new ElementsDslContext(getContext(), elements)); + } + + } else if (FIND_RELATIONSHIPS_TOKEN.equalsIgnoreCase(firstToken) && (inContext(ModelDslContext.class) || inContext(DeploymentEnvironmentDslContext.class) || inContext(ElementDslContext.class))) { + Set relationships = new FindRelationshipsParser().parse(getContext(), tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipsDslContext(getContext(), relationships)); + } + + } else if (isElementKeywordOrArchetype(firstToken, CUSTOM_ELEMENT_TOKEN) && (inContext(ModelDslContext.class))) { + Archetype archetype = getArchetype(CUSTOM_ELEMENT_TOKEN, firstToken); + CustomElement customElement = new CustomElementParser().parse(getContext(ModelDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new CustomElementDslContext(customElement)); + } + + registerIdentifier(identifier, customElement); + + } else if (isElementKeywordOrArchetype(firstToken, PERSON_TOKEN) && (inContext(ModelDslContext.class))) { + Archetype archetype = getArchetype(PERSON_TOKEN, firstToken); + Person person = new PersonParser().parse(getContext(ModelDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new PersonDslContext(person)); + } + + registerIdentifier(identifier, person); + + } else if (isElementKeywordOrArchetype(firstToken, SOFTWARE_SYSTEM_TOKEN) && (inContext(ModelDslContext.class))) { + Archetype archetype = getArchetype(SOFTWARE_SYSTEM_TOKEN, firstToken); + SoftwareSystem softwareSystem = new SoftwareSystemParser().parse(getContext(ModelDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new SoftwareSystemDslContext(softwareSystem)); + } + + registerIdentifier(identifier, softwareSystem); + + } else if (isElementKeywordOrArchetype(firstToken, CONTAINER_TOKEN) && inContext(SoftwareSystemDslContext.class)) { + Archetype archetype = getArchetype(CONTAINER_TOKEN, firstToken); + Container container = new ContainerParser().parse(getContext(SoftwareSystemDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new ContainerDslContext(container)); + } + + registerIdentifier(identifier, container); + + } else if (isElementKeywordOrArchetype(firstToken, COMPONENT_TOKEN) && inContext(ContainerDslContext.class)) { + Archetype archetype = getArchetype(COMPONENT_TOKEN, firstToken); + Component component = new ComponentParser().parse(getContext(ContainerDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new ComponentDslContext(component)); + } + + registerIdentifier(identifier, component); + + } else if (COMPONENT_FINDER_TOKEN.equalsIgnoreCase(firstToken) && inContext(ContainerDslContext.class)) { + if (features.isEnabled(Features.COMPONENT_FINDER)) { + if (shouldStartContext(tokens)) { + startContext(new ComponentFinderDslContext(this, getContext(ContainerDslContext.class))); + } + } else { + throw new FeatureNotEnabledException(Features.COMPONENT_FINDER, firstToken + " is not permitted"); + } + + } else if (COMPONENT_FINDER_CLASSES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderDslContext.class)) { + new ComponentFinderParser().parseClasses(getContext(ComponentFinderDslContext.class), tokens); + + } else if (COMPONENT_FINDER_SOURCE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderDslContext.class)) { + new ComponentFinderParser().parseSource(getContext(ComponentFinderDslContext.class), tokens); + + } else if (COMPONENT_FINDER_FILTER_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderDslContext.class)) { + new ComponentFinderParser().parseFilter(getContext(ComponentFinderDslContext.class), tokens); + + } else if (COMPONENT_FINDER_STRATEGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderDslContext.class)) { + if (shouldStartContext(tokens)) { + startContext(new ComponentFinderStrategyDslContext(getContext(ComponentFinderDslContext.class))); + } + + } else if (COMPONENT_FINDER_STRATEGY_TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseTechnology(getContext(ComponentFinderStrategyDslContext.class), tokens); + + } else if (COMPONENT_FINDER_STRATEGY_MATCHER_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseMatcher(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_FILTER_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseFilter(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_SUPPORTING_TYPES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseSupportingTypes(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_NAME_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseName(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseDescription(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_URL_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + new ComponentFinderStrategyParser().parseUrl(getContext(ComponentFinderStrategyDslContext.class), tokens, dslFile); + + } else if (COMPONENT_FINDER_STRATEGY_FOREACH_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentFinderStrategyDslContext.class)) { + if (shouldStartContext(tokens)) { + startContext(new ComponentFinderStrategyForEachDslContext(getContext(ComponentFinderStrategyDslContext.class), this)); + } + + } else if (inContext(ComponentFinderStrategyForEachDslContext.class)) { + getContext(ComponentFinderStrategyForEachDslContext.class).addLine(line); + + } else if (ENTERPRISE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelDslContext.class)) { + throw new RuntimeException("The enterprise keyword was previously deprecated, and has now been removed - please use group instead (https://docs.structurizr.com/dsl/language#group)"); + + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(ModelDslContext.class)) { + ElementGroup group = new GroupParser().parseContext(getContext(ModelDslContext.class), tokens); + + startContext(new ModelDslContext(group)); + registerIdentifier(identifier, group); + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(SoftwareSystemDslContext.class)) { + ElementGroup group = new GroupParser().parseContext(getContext(SoftwareSystemDslContext.class), tokens); + + SoftwareSystem softwareSystem = getContext(SoftwareSystemDslContext.class).getSoftwareSystem(); + group.setParent(softwareSystem); + startContext(new SoftwareSystemDslContext(softwareSystem, group)); + registerIdentifier(identifier, group); + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(ContainerDslContext.class)) { + ElementGroup group = new GroupParser().parseContext(getContext(ContainerDslContext.class), tokens); + + Container container = getContext(ContainerDslContext.class).getContainer(); + group.setParent(container); + startContext(new ContainerDslContext(container, group)); + registerIdentifier(identifier, group); + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(DeploymentEnvironmentDslContext.class)) { + ElementGroup group = new GroupParser().parseContext(getContext(DeploymentEnvironmentDslContext.class), tokens); + + DeploymentEnvironment environment = getContext(DeploymentEnvironmentDslContext.class).getEnvironment(); + startContext(new DeploymentEnvironmentDslContext(environment.getName(), group)); + registerIdentifier(identifier, group); + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(DeploymentNodeDslContext.class)) { + ElementGroup group = new GroupParser().parseContext(getContext(DeploymentNodeDslContext.class), tokens); + + DeploymentNode deploymentNode = getContext(DeploymentNodeDslContext.class).getDeploymentNode(); + startContext(new DeploymentNodeDslContext(deploymentNode, group)); + registerIdentifier(identifier, group); + } else if ((TAGS_TOKEN.equalsIgnoreCase(firstToken) || TAG_TOKEN.equalsIgnoreCase(firstToken)) && inContext(ModelItemDslContext.class) && !isGroup(getContext())) { + new ModelItemParser().parseTags(getContext(ModelItemDslContext.class), tokens); + + } else if ((TAGS_TOKEN.equalsIgnoreCase(firstToken) || TAG_TOKEN.equalsIgnoreCase(firstToken)) && inContext(ModelItemsDslContext.class)) { + new ModelItemsParser().parseTags(getContext(ModelItemsDslContext.class), tokens); + + } else if (DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementDslContext.class) && !isGroup(getContext())) { + new ModelItemParser().parseDescription(getContext(ElementDslContext.class), tokens); + + } else if (DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementsDslContext.class)) { + new ElementsParser().parseDescription(getContext(ElementsDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ContainerDslContext.class) && !getContext(ContainerDslContext.class).hasGroup()) { + new ContainerParser().parseTechnology(getContext(ContainerDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentDslContext.class) && !getContext(ComponentDslContext.class).hasGroup()) { + new ComponentParser().parseTechnology(getContext(ComponentDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentNodeDslContext.class)) { + new DeploymentNodeParser().parseTechnology(getContext(DeploymentNodeDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(InfrastructureNodeDslContext.class)) { + new InfrastructureNodeParser().parseTechnology(getContext(InfrastructureNodeDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementsDslContext.class)) { + new ElementsParser().parseTechnology(getContext(ElementsDslContext.class), tokens); + + } else if (INSTANCES_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentNodeDslContext.class)) { + new DeploymentNodeParser().parseInstances(getContext(DeploymentNodeDslContext.class), tokens); + + } else if (URL_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemDslContext.class) && !isGroup(getContext())) { + new ModelItemParser().parseUrl(getContext(ModelItemDslContext.class), tokens); + + } else if (URL_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemsDslContext.class)) { + new ModelItemsParser().parseUrl(getContext(ModelItemsDslContext.class), tokens); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + startContext(new PropertiesDslContext(workspace)); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelDslContext.class)) { + startContext(new PropertiesDslContext(workspace.getModel())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ConfigurationDslContext.class)) { + startContext(new PropertiesDslContext(getContext(ConfigurationDslContext.class).getWorkspace())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemDslContext.class) && !isGroup(getContext())) { + startContext(new PropertiesDslContext(getContext(ModelItemDslContext.class).getModelItem())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemsDslContext.class)) { + startContext(new PropertiesDslContext(getContext(ModelItemsDslContext.class).getModelItems().stream().map(mi -> (PropertyHolder)mi).toList())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + startContext(new PropertiesDslContext(workspace.getViews().getConfiguration())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewDslContext.class)) { + startContext(new PropertiesDslContext(getContext(ViewDslContext.class).getView())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(DynamicViewRelationshipContext.class)) { + startContext(new PropertiesDslContext(getContext((DynamicViewRelationshipContext.class)).getRelationshipView())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + startContext(new PropertiesDslContext(getContext((ElementStyleDslContext.class)).getStyle())); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + startContext(new PropertiesDslContext(getContext((RelationshipStyleDslContext.class)).getStyle())); + + } else if (inContext(PropertiesDslContext.class)) { + new PropertyParser().parse(getContext(PropertiesDslContext.class), tokens); + + } else if (PERSPECTIVES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemDslContext.class) && !isGroup(getContext())) { + startContext(new PerspectivesDslContext(getContext(ModelItemDslContext.class).getModelItem())); + + } else if (PERSPECTIVES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelItemsDslContext.class)) { + startContext(new PerspectivesDslContext(getContext(ModelItemsDslContext.class).getModelItems())); + + } else if (inContext(PerspectivesDslContext.class)) { + new PerspectiveParser().parse(getContext(PerspectivesDslContext.class), tokens); + + } else if (GROUP_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentDslContext.class)) { + new GroupParser().parseProperty(getContext(ComponentDslContext.class), tokens); + + } else if (WORKSPACE_TOKEN.equalsIgnoreCase(firstToken) && contextStack.empty()) { + if (parsedTokens.contains(WORKSPACE_TOKEN)) { + throw new RuntimeException("Multiple workspaces are not permitted in a DSL definition"); + } + DslParserContext dslParserContext = new DslParserContext(this, dslFile); + dslParserContext.setIdentifierRegister(identifiersRegister); + dslParserContext.setFeatures(features); + dslParserContext.setHttpClient(httpClient); + + workspace = new WorkspaceParser().parse(dslParserContext, tokens.withoutContextStartToken()); + extendingWorkspace = !workspace.getModel().isEmpty(); + WorkspaceDslContext context = new WorkspaceDslContext(); + context.setDslPortable(dslParserContext.isDslPortable()); + startContext(context); + parsedTokens.add(WORKSPACE_TOKEN); + + } else if (IMPLIED_RELATIONSHIPS_TOKEN.equalsIgnoreCase(firstToken) || IMPLIED_RELATIONSHIPS_TOKEN.substring(1).equalsIgnoreCase(firstToken)) { + new ImpliedRelationshipsParser().parse(getContext(), tokens, dslFile); + + } else if (NAME_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + new WorkspaceParser().parseName(getContext(), tokens); + + } else if (DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + new WorkspaceParser().parseDescription(getContext(), tokens); + + } else if (MODEL_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + if (parsedTokens.contains(MODEL_TOKEN)) { + throw new RuntimeException("Multiple models are not permitted in a DSL definition"); + } + + startContext(new ModelDslContext()); + parsedTokens.add(MODEL_TOKEN); + + } else if (ARCHETYPES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelDslContext.class)) { + startContext(new ArchetypesDslContext()); + + } else if (isElementKeywordOrArchetype(firstToken, GROUP_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, GROUP_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + } else if (isElementKeywordOrArchetype(firstToken, CUSTOM_ELEMENT_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, CUSTOM_ELEMENT_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new CustomElementArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, PERSON_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, PERSON_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + + if (shouldStartContext(tokens)) { + startContext(new PersonArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, SOFTWARE_SYSTEM_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, SOFTWARE_SYSTEM_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new SoftwareSystemArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, CONTAINER_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, CONTAINER_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new ContainerArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, COMPONENT_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, COMPONENT_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new ComponentArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, DEPLOYMENT_NODE_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, DEPLOYMENT_NODE_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new DeploymentNodeArchetypeDslContext(archetype)); + } + + } else if (isElementKeywordOrArchetype(firstToken, INFRASTRUCTURE_NODE_TOKEN) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, INFRASTRUCTURE_NODE_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new InfrastructureNodeArchetypeDslContext(archetype)); + } + + } else if (isRelationshipKeywordOrArchetype(firstToken) && inContext(ArchetypesDslContext.class)) { + Archetype archetype = new Archetype(identifier, RELATIONSHIP_TOKEN); + extendArchetype(archetype, firstToken); + addArchetype(archetype); + + if (shouldStartContext(tokens)) { + startContext(new RelationshipArchetypeDslContext(archetype)); + } + + } else if (METADATA_TOKEN.equalsIgnoreCase(firstToken) && inContext(CustomElementArchetypeDslContext.class)) { + new ArchetypeParser().parseMetadata(getContext(ArchetypeDslContext.class), tokens); + + } else if (DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ArchetypeDslContext.class)) { + new ArchetypeParser().parseDescription(getContext(ArchetypeDslContext.class), tokens); + + } else if (TECHNOLOGY_TOKEN.equalsIgnoreCase(firstToken) && (inContext(ContainerArchetypeDslContext.class) || inContext(ComponentArchetypeDslContext.class) || inContext(DeploymentNodeArchetypeDslContext.class) || inContext(InfrastructureNodeArchetypeDslContext.class) || inContext(RelationshipArchetypeDslContext.class))) { + new ArchetypeParser().parseTechnology(getContext(ArchetypeDslContext.class), tokens); + + } else if (TAG_TOKEN.equalsIgnoreCase(firstToken) && inContext(ArchetypeDslContext.class)) { + new ArchetypeParser().parseTag(getContext(ArchetypeDslContext.class), tokens); + + } else if (TAGS_TOKEN.equalsIgnoreCase(firstToken) && inContext(ArchetypeDslContext.class)) { + new ArchetypeParser().parseTags(getContext(ArchetypeDslContext.class), tokens); + + } else if (PROPERTIES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ArchetypeDslContext.class)) { + Archetype archetype = getContext(ArchetypeDslContext.class).getArchetype(); + startContext(new PropertiesDslContext(archetype)); + + } else if (PERSPECTIVES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ArchetypeDslContext.class)) { + Archetype archetype = getContext(ArchetypeDslContext.class).getArchetype(); + startContext(new PerspectivesDslContext(archetype)); + + } else if (VIEWS_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + if (parsedTokens.contains(VIEWS_TOKEN)) { + throw new RuntimeException("Multiple view sets are not permitted in a DSL definition"); + } + + startContext(new ViewsDslContext()); + parsedTokens.add(VIEWS_TOKEN); + + } else if (BRANDING_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + startContext(new BrandingDslContext(dslFile)); + + } else if (BRANDING_LOGO_TOKEN.equalsIgnoreCase(firstToken) && inContext(BrandingDslContext.class)) { + new BrandingParser().parseLogo(getContext(BrandingDslContext.class), tokens); + + } else if (BRANDING_FONT_TOKEN.equalsIgnoreCase(firstToken) && inContext(BrandingDslContext.class)) { + new BrandingParser().parseFont(getContext(BrandingDslContext.class), tokens); + + } else if (STYLES_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + startContext(new StylesDslContext()); + + } else if (LIGHT_COLOR_SCHEME_TOKEN.equalsIgnoreCase(firstToken) && inContext(StylesDslContext.class)) { + startContext(new StylesDslContext(ColorScheme.Light)); + + } else if (DARK_COLOR_SCHEME_TOKEN.equalsIgnoreCase(firstToken) && inContext(StylesDslContext.class)) { + startContext(new StylesDslContext(ColorScheme.Dark)); + + } else if (ELEMENT_STYLE_TOKEN.equalsIgnoreCase(firstToken) && inContext(StylesDslContext.class)) { + ElementStyle elementStyle = new ElementStyleParser().parseElementStyle(getContext(StylesDslContext.class), tokens.withoutContextStartToken()); + startContext(new ElementStyleDslContext(elementStyle, dslFile)); + + } else if (ELEMENT_STYLE_BACKGROUND_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseBackground(getContext(ElementStyleDslContext.class), tokens); + + } else if ((ELEMENT_STYLE_COLOUR_TOKEN.equalsIgnoreCase(firstToken) || ELEMENT_STYLE_COLOR_TOKEN.equalsIgnoreCase(firstToken)) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseColour(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_STROKE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseStroke(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_STROKE_WIDTH_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseStrokeWidth(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_SHAPE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseShape(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_BORDER_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseBorder(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_OPACITY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseOpacity(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_WIDTH_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseWidth(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_HEIGHT_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseHeight(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_FONT_SIZE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseFontSize(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_METADATA_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseMetadata(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseDescription(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_ICON_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseIcon(getContext(ElementStyleDslContext.class), tokens); + + } else if (ELEMENT_STYLE_ICON_POSITION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ElementStyleDslContext.class)) { + new ElementStyleParser().parseIconPosition(getContext(ElementStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_TOKEN.equalsIgnoreCase(firstToken) && inContext(StylesDslContext.class)) { + RelationshipStyle relationshipStyle = new RelationshipStyleParser().parseRelationshipStyle(getContext(StylesDslContext.class), tokens.withoutContextStartToken()); + startContext(new RelationshipStyleDslContext(relationshipStyle)); + + } else if (RELATIONSHIP_STYLE_THICKNESS_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseThickness(getContext(RelationshipStyleDslContext.class), tokens); + + } else if ((RELATIONSHIP_STYLE_COLOUR_TOKEN.equalsIgnoreCase(firstToken) || RELATIONSHIP_STYLE_COLOR_TOKEN.equalsIgnoreCase(firstToken)) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseColour(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_DASHED_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseDashed(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_OPACITY_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseOpacity(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_WIDTH_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseWidth(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_FONT_SIZE_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseFontSize(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_POSITION_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parsePosition(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_LINE_STYLE_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseLineStyle(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_ROUTING_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseRouting(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (RELATIONSHIP_STYLE_JUMP_TOKEN.equalsIgnoreCase(firstToken) && inContext(RelationshipStyleDslContext.class)) { + new RelationshipStyleParser().parseJump(getContext(RelationshipStyleDslContext.class), tokens); + + } else if (DEPLOYMENT_ENVIRONMENT_TOKEN.equalsIgnoreCase(firstToken) && inContext(ModelDslContext.class)) { + String environment = new DeploymentEnvironmentParser().parse(tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new DeploymentEnvironmentDslContext(environment)); + } + + registerIdentifier(identifier, new DeploymentEnvironment(environment)); + + } else if (DEPLOYMENT_GROUP_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentEnvironmentDslContext.class)) { + String group = new DeploymentGroupParser().parse(tokens.withoutContextStartToken()); + + registerIdentifier(identifier, new DeploymentGroup(getContext(DeploymentEnvironmentDslContext.class).getEnvironment(), group)); + + } else if (isElementKeywordOrArchetype(firstToken, DEPLOYMENT_NODE_TOKEN) && inContext(DeploymentEnvironmentDslContext.class)) { + Archetype archetype = getArchetype(DEPLOYMENT_NODE_TOKEN, firstToken); + DeploymentNode deploymentNode = new DeploymentNodeParser().parse(getContext(DeploymentEnvironmentDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new DeploymentNodeDslContext(deploymentNode)); + } + + registerIdentifier(identifier, deploymentNode); + } else if (isElementKeywordOrArchetype(firstToken, DEPLOYMENT_NODE_TOKEN) && inContext(DeploymentNodeDslContext.class)) { + Archetype archetype = getArchetype(DEPLOYMENT_NODE_TOKEN, firstToken); + DeploymentNode deploymentNode = new DeploymentNodeParser().parse(getContext(DeploymentNodeDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new DeploymentNodeDslContext(deploymentNode)); + } + + registerIdentifier(identifier, deploymentNode); + } else if (isElementKeywordOrArchetype(firstToken, INFRASTRUCTURE_NODE_TOKEN) && inContext(DeploymentNodeDslContext.class)) { + Archetype archetype = getArchetype(INFRASTRUCTURE_NODE_TOKEN, firstToken); + InfrastructureNode infrastructureNode = new InfrastructureNodeParser().parse(getContext(DeploymentNodeDslContext.class), tokens.withoutContextStartToken(), archetype); + + if (shouldStartContext(tokens)) { + startContext(new InfrastructureNodeDslContext(infrastructureNode)); + } + + registerIdentifier(identifier, infrastructureNode); + + } else if (INSTANCE_OF_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentNodeDslContext.class)) { + StaticStructureElementInstance instance = new InstanceOfParser().parse(getContext(DeploymentNodeDslContext.class), tokens.withoutContextStartToken()); + + if (instance instanceof SoftwareSystemInstance) { + if (shouldStartContext(tokens)) { + startContext(new SoftwareSystemInstanceDslContext((SoftwareSystemInstance)instance)); + } + } else if (instance instanceof ContainerInstance) { + if (shouldStartContext(tokens)) { + startContext(new ContainerInstanceDslContext((ContainerInstance)instance)); + } + } + + registerIdentifier(identifier, instance); + + } else if (SOFTWARE_SYSTEM_INSTANCE_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentNodeDslContext.class)) { + SoftwareSystemInstance softwareSystemInstance = new SoftwareSystemInstanceParser().parse(getContext(DeploymentNodeDslContext.class), tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new SoftwareSystemInstanceDslContext(softwareSystemInstance)); + } + + registerIdentifier(identifier, softwareSystemInstance); + + } else if (CONTAINER_INSTANCE_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentNodeDslContext.class)) { + ContainerInstance containerInstance = new ContainerInstanceParser().parse(getContext(DeploymentNodeDslContext.class), tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new ContainerInstanceDslContext(containerInstance)); + } + + registerIdentifier(identifier, containerInstance); + + } else if (HEALTH_CHECK_TOKEN.equalsIgnoreCase(firstToken) && inContext(StaticStructureElementInstanceDslContext.class)) { + new HealthCheckParser().parse(getContext(StaticStructureElementInstanceDslContext.class), tokens.withoutContextStartToken()); + } else if (CUSTOM_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + CustomView view = new CustomViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new CustomViewDslContext(view)); + + } else if (SYSTEM_LANDSCAPE_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + SystemLandscapeView view = new SystemLandscapeViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new SystemLandscapeViewDslContext(view)); + + } else if (SYSTEM_CONTEXT_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + SystemContextView view = new SystemContextViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new SystemContextViewDslContext(view)); + + } else if (CONTAINER_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + ContainerView view = new ContainerViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new ContainerViewDslContext(view)); + + } else if (COMPONENT_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + ComponentView view = new ComponentViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new ComponentViewDslContext(view)); + + } else if (DYNAMIC_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + DynamicView view = new DynamicViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new DynamicViewDslContext(view)); + + } else if (DEPLOYMENT_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + DeploymentView view = new DeploymentViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new DeploymentViewDslContext(view)); + + } else if (FILTERED_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + FilteredView view = new FilteredViewParser().parse(getContext(), tokens.withoutContextStartToken()); + + if (shouldStartContext(tokens)) { + startContext(new FilteredViewDslContext(view)); + } + + } else if (IMAGE_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + ImageView view = new ImageViewParser().parse(getContext(), tokens.withoutContextStartToken()); + startContext(new ImageViewDslContext(view)); + + } else if (DslContext.CONTEXT_START_TOKEN.equalsIgnoreCase(firstToken) && inContext(DynamicViewDslContext.class)) { + startContext(new DynamicViewParallelSequenceDslContext(getContext(DynamicViewDslContext.class))); + + } else if (INCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(CustomViewDslContext.class)) { + new CustomViewContentParser().parseInclude(getContext(CustomViewDslContext.class), tokens); + + } else if (EXCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(CustomViewDslContext.class)) { + new CustomViewContentParser().parseExclude(getContext(CustomViewDslContext.class), tokens); + + } else if (ANIMATION_STEP_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(CustomViewDslContext.class)) { + new CustomViewAnimationStepParser().parse(getContext(CustomViewDslContext.class), tokens); + + } else if (ANIMATION_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(CustomViewDslContext.class)) { + startContext(new CustomViewAnimationDslContext(getContext(CustomViewDslContext.class).getCustomView())); + + } else if (inContext(CustomViewAnimationDslContext.class)) { + new CustomViewAnimationStepParser().parse(getContext(CustomViewAnimationDslContext.class), tokens); + + } else if (INCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(StaticViewDslContext.class)) { + new StaticViewContentParser().parseInclude(getContext(StaticViewDslContext.class), tokens); + + } else if (EXCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(StaticViewDslContext.class)) { + new StaticViewContentParser().parseExclude(getContext(StaticViewDslContext.class), tokens); + + } else if (ANIMATION_STEP_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(StaticViewDslContext.class)) { + new StaticViewAnimationStepParser().parse(getContext(StaticViewDslContext.class), tokens); + + } else if (ANIMATION_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(StaticViewDslContext.class)) { + startContext(new StaticViewAnimationDslContext(getContext(StaticViewDslContext.class).getView())); + + } else if (inContext(StaticViewAnimationDslContext.class)) { + new StaticViewAnimationStepParser().parse(getContext(StaticViewAnimationDslContext.class), tokens); + + } else if (INCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentViewDslContext.class)) { + new DeploymentViewContentParser().parseInclude(getContext(DeploymentViewDslContext.class), tokens); + + } else if (EXCLUDE_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentViewDslContext.class)) { + new DeploymentViewContentParser().parseExclude(getContext(DeploymentViewDslContext.class), tokens); + + } else if (ANIMATION_STEP_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentViewDslContext.class)) { + new DeploymentViewAnimationStepParser().parse(getContext(DeploymentViewDslContext.class), tokens); + + } else if (ANIMATION_IN_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(DeploymentViewDslContext.class)) { + startContext(new DeploymentViewAnimationDslContext(getContext(DeploymentViewDslContext.class).getView())); + + } else if (inContext(DeploymentViewAnimationDslContext.class)) { + new DeploymentViewAnimationStepParser().parse(getContext(DeploymentViewAnimationDslContext.class), tokens); + + } else if (AUTOLAYOUT_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewDslContext.class)) { + new AutoLayoutParser().parse(getContext(ModelViewDslContext.class), tokens); + + } else if (DEFAULT_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewDslContext.class)) { + new DefaultViewParser().parse(getContext(ViewDslContext.class)); + + } else if (VIEW_TITLE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewDslContext.class)) { + new ViewParser().parseTitle(getContext(ViewDslContext.class), tokens); + + } else if (VIEW_DESCRIPTION_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewDslContext.class)) { + new ViewParser().parseDescription(getContext(ViewDslContext.class), tokens); + + } else if (PLANTUML_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class)) { + new ImageViewContentParser().parsePlantUML(getContext(ImageViewDslContext.class), dslFile, tokens); + + } else if (MERMAID_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class)) { + new ImageViewContentParser().parseMermaid(getContext(ImageViewDslContext.class), dslFile, tokens); + + } else if (KROKI_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class)) { + new ImageViewContentParser().parseKroki(getContext(ImageViewDslContext.class), dslFile, tokens); + + } else if (IMAGE_VIEW_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class)) { + new ImageViewContentParser().parseImage(getContext(ImageViewDslContext.class), dslFile, tokens); + + } else if (LIGHT_COLOR_SCHEME_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class) && shouldStartContext(tokens)) { + ImageViewDslContext context = getContext(ImageViewDslContext.class); + context.setColorScheme(ColorScheme.Light); + startContext(context); + + } else if (DARK_COLOR_SCHEME_TOKEN.equalsIgnoreCase(firstToken) && inContext(ImageViewDslContext.class) && shouldStartContext(tokens)) { + ImageViewDslContext context = getContext(ImageViewDslContext.class); + context.setColorScheme(ColorScheme.Dark); + startContext(context); + + } else if (inContext(DynamicViewDslContext.class)) { + RelationshipView relationshipView = new DynamicViewContentParser().parseRelationship(getContext(DynamicViewDslContext.class), tokens); + + if (inContext(DynamicViewParallelSequenceDslContext.class)) { + getContext(DynamicViewParallelSequenceDslContext.class).hasRelationships(true); + } + + if (shouldStartContext(tokens)) { + startContext(new DynamicViewRelationshipContext(relationshipView)); + } + + } else if (URL_TOKEN.equalsIgnoreCase(firstToken) && inContext(DynamicViewRelationshipContext.class)) { + new DynamicViewRelationshipParser().parseUrl(getContext(DynamicViewRelationshipContext.class), tokens.withoutContextStartToken()); + + } else if (THEME_TOKEN.equalsIgnoreCase(firstToken) && (inContext(ViewsDslContext.class) || inContext(StylesDslContext.class))) { + new ThemeParser().parseTheme(getContext(), dslFile, tokens); + + } else if (THEMES_TOKEN.equalsIgnoreCase(firstToken) && (inContext(ViewsDslContext.class) || inContext(StylesDslContext.class))) { + new ThemeParser().parseThemes(getContext(), dslFile, tokens); + + } else if (TERMINOLOGY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ViewsDslContext.class)) { + startContext(new TerminologyDslContext()); + + } else if (PERSON_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parsePerson(getContext(), tokens); + + } else if (SOFTWARE_SYSTEM_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseSoftwareSystem(getContext(), tokens); + + } else if (CONTAINER_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseContainer(getContext(), tokens); + + } else if (COMPONENT_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseComponent(getContext(), tokens); + + } else if (DEPLOYMENT_NODE_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseDeploymentNode(getContext(), tokens); + + } else if (INFRASTRUCTURE_NODE_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseInfrastructureNode(getContext(), tokens); + + } else if (TERMINOLOGY_RELATIONSHIP_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseRelationship(getContext(), tokens); + + } else if (METADATA_SYMBOLS_TOKEN.equalsIgnoreCase(firstToken) && inContext(TerminologyDslContext.class)) { + new TerminologyParser().parseMetadataSymbols(getContext(), tokens); + + } else if (CONFIGURATION_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + startContext(new ConfigurationDslContext()); + + } else if (SCOPE_TOKEN.equalsIgnoreCase(firstToken) && inContext(ConfigurationDslContext.class)) { + new ConfigurationParser().parseScope(getContext(), tokens); + + } else if (VISIBILITY_TOKEN.equalsIgnoreCase(firstToken) && inContext(ConfigurationDslContext.class)) { + new ConfigurationParser().parseVisibility(getContext(), tokens); + + } else if (USERS_TOKEN.equalsIgnoreCase(firstToken) && inContext(ConfigurationDslContext.class)) { + startContext(new UsersDslContext()); + + } else if (inContext(UsersDslContext.class)) { + new UserRoleParser().parse(getContext(), tokens); + + } else if (DOCS_TOKEN.equalsIgnoreCase(firstToken) && inContext(WorkspaceDslContext.class)) { + if (features.isEnabled(Features.DOCUMENTATION)) { + new DocsParser().parse(getContext(WorkspaceDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DOCUMENTATION, firstToken + " is not permitted"); + } + + } else if (DOCS_TOKEN.equalsIgnoreCase(firstToken) && inContext(SoftwareSystemDslContext.class)) { + if (features.isEnabled(Features.DOCUMENTATION)) { + new DocsParser().parse(getContext(SoftwareSystemDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DOCUMENTATION, firstToken + " is not permitted"); + } + + } else if (DOCS_TOKEN.equalsIgnoreCase(firstToken) && inContext(ContainerDslContext.class)) { + if (features.isEnabled(Features.DOCUMENTATION)) { + new DocsParser().parse(getContext(ContainerDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DOCUMENTATION, firstToken + " is not permitted"); + } + + } else if (DOCS_TOKEN.equalsIgnoreCase(firstToken) && inContext(ComponentDslContext.class)) { + if (features.isEnabled(Features.DOCUMENTATION)) { + new DocsParser().parse(getContext(ComponentDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DOCUMENTATION, firstToken + " is not permitted"); + } + + } else if ((ADRS_TOKEN.equalsIgnoreCase(firstToken) || DECISIONS_TOKEN.equalsIgnoreCase(firstToken)) && inContext(WorkspaceDslContext.class)) { + if (features.isEnabled(Features.DECISIONS)) { + new DecisionsParser().parse(getContext(WorkspaceDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DECISIONS, firstToken + " is not permitted"); + } + + } else if ((ADRS_TOKEN.equalsIgnoreCase(firstToken) || DECISIONS_TOKEN.equalsIgnoreCase(firstToken)) && inContext(SoftwareSystemDslContext.class)) { + if (features.isEnabled(Features.DECISIONS)) { + new DecisionsParser().parse(getContext(SoftwareSystemDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DECISIONS, firstToken + " is not permitted"); + } + + } else if ((ADRS_TOKEN.equalsIgnoreCase(firstToken) || DECISIONS_TOKEN.equalsIgnoreCase(firstToken)) && inContext(ContainerDslContext.class)) { + if (features.isEnabled(Features.DECISIONS)) { + new DecisionsParser().parse(getContext(ContainerDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DECISIONS, firstToken + " is not permitted"); + } + + } else if ((ADRS_TOKEN.equalsIgnoreCase(firstToken) || DECISIONS_TOKEN.equalsIgnoreCase(firstToken)) && inContext(ComponentDslContext.class)) { + if (features.isEnabled(Features.DECISIONS)) { + new DecisionsParser().parse(getContext(ComponentDslContext.class), dslFile, tokens); + } else { + throw new FeatureNotEnabledException(Features.DECISIONS, firstToken + " is not permitted"); + } + + } else if (CONSTANT_TOKEN.equalsIgnoreCase(firstToken)) { + throw new RuntimeException("!constant was previously deprecated, and has now been removed - please use !const or !var instead"); + + } else if (CONST_TOKEN.equalsIgnoreCase(firstToken)) { + NameValuePair nameValuePair = new NameValueParser().parseConstant(tokens); + try { + addConstant(nameValuePair); + } catch (IllegalArgumentException e) { + throw new StructurizrDslParserException(e.getMessage()); + } + + } else if (VAR_TOKEN.equalsIgnoreCase(firstToken)) { + NameValuePair nameValuePair = new NameValueParser().parseVariable(tokens); + addVariable(nameValuePair); + + } else if (IDENTIFIERS_TOKEN.equalsIgnoreCase(firstToken) && (inContext(WorkspaceDslContext.class) || inContext(ModelDslContext.class))) { + setIdentifierScope(new IdentifierScopeParser().parse(getContext(), tokens)); + + } else { + String[] expectedTokens; + if (getContext() == null) { + if (getWorkspace() == null) { + // the workspace hasn't yet been created + expectedTokens = new String[]{ + StructurizrDslTokens.WORKSPACE_TOKEN + }; + } else { + expectedTokens = new String[0]; + } + } else { + expectedTokens = getContext().getPermittedTokens(); + } + + if (expectedTokens.length > 0) { + StringBuilder buf = new StringBuilder(); + for (String expectedToken : expectedTokens) { + buf.append(expectedToken); + buf.append(", "); + } + throw new StructurizrDslParserException("Unexpected tokens (expected: " + buf.substring(0, buf.length() - 2) + ")"); + } else { + throw new StructurizrDslParserException("Unexpected tokens"); + } + } + } + } catch (Exception e) { + if (e.getMessage() != null) { + throw new StructurizrDslParserException(e.getMessage(), dslFile, dslLine.getLineNumber(), line); + } else { + throw new StructurizrDslParserException(e.getClass().getSimpleName(), dslFile, dslLine.getLineNumber(), line); + } + } + } + + if (!fragment && !contextStack.empty()) { + throw new StructurizrDslParserException("Unexpected end of DSL content - are one or more closing curly braces missing?"); + } + } + + private List preProcessLines(List lines) { + List dslLines = new ArrayList<>(); + + int lineNumber = 1; + StringBuilder buf = new StringBuilder(); + boolean lineComplete = true; + boolean textBlock = false; + int textBlockLeadingSpace = -1; + + for (String line : lines) { + if (textBlock) { + if (line.endsWith(TEXT_BLOCK_MARKER)) { + buf.append(TEXT_BLOCK_MARKER); + textBlock = false; + textBlockLeadingSpace = -1; + lineComplete = true; + } else { + if (textBlockLeadingSpace == -1) { + textBlockLeadingSpace = 0; + for (int i = 0; i < line.length(); i++) { + if (Character.isWhitespace(line.charAt(i))) { + textBlockLeadingSpace++; + } else { + break; + } + } + } + if (StringUtils.isNullOrEmpty(line)) { + buf.append("\n"); + } else { + buf.append(line, textBlockLeadingSpace, line.length()); + buf.append("\n"); + } + } + } else if (!COMMENT_PATTERN.matcher(line).matches() && line.endsWith(MULTI_LINE_SEPARATOR)) { + buf.append(line, 0, line.length() - 1); + lineComplete = false; + } else if (!COMMENT_PATTERN.matcher(line).matches() && line.endsWith(TEXT_BLOCK_MARKER)) { + buf.append(line, 0, line.length()); + lineComplete = false; + textBlock = true; + } else { + if (lineComplete) { + buf.append(line); + } else { + buf.append(line.stripLeading()); + lineComplete = true; + } + } + + if (lineComplete) { + // replace the text block with a constant (that will become substituted later) + // (this makes it possible for text blocks to include double-quote characters) + String source = buf.toString(); + + if (source.endsWith(TEXT_BLOCK_MARKER)) { + String[] parts = source.split(TEXT_BLOCK_MARKER); + String textBlockName = UUID.randomUUID().toString(); + String textBlockValue = parts[1].substring(0, parts[1].length() - 1); // remove final line break + addTextBlock(textBlockName, textBlockValue); + dslLines.add(new DslLine(parts[0] + "\"" + String.format(STRING_SUBSTITUTION_TEMPLATE, textBlockName) + "\"", lineNumber)); + } else { + dslLines.add(new DslLine(source, lineNumber)); + } + + buf = new StringBuilder(); + } + + lineNumber++; + } + + return dslLines; + } + + private String substituteStrings(String token) { + Matcher m = STRING_SUBSTITUTION_PATTERN.matcher(token); + while (m.find()) { + String before = m.group(0); + String after = null; + String name = before.substring(2, before.length()-1); + if (constantsAndVariables.containsKey(name)) { + NameValuePair nameValuePair = constantsAndVariables.get(name); + + if (nameValuePair.getType() == NameValueType.TextBlock) { + after = substituteStrings(nameValuePair.getValue()); + } else { + after = nameValuePair.getValue(); + } + } else { + if (getFeatures().isEnabled(Features.ENVIRONMENT)) { + String environmentVariable = System.getenv().get(name); + if (environmentVariable != null) { + after = environmentVariable; + } + } + } + + if (after != null) { + token = token.replace(before, after); + } + } + + return token; + } + + private boolean shouldStartContext(Tokens tokens) { + return DslContext.CONTEXT_START_TOKEN.equalsIgnoreCase(tokens.get(tokens.size()-1)); + } + + private void startContext(DslContext context) { + context.setWorkspace(workspace); + context.setIdentifierRegister(identifiersRegister); + context.setExtendingWorkspace(extendingWorkspace); + context.setFeatures(features); + context.setHttpClient(httpClient); + contextStack.push(context); + } + + private DslContext getContext() { + if (!contextStack.empty()) { + return contextStack.peek(); + } else { + return null; + } + } + + private T getContext(Class clazz) throws StructurizrDslParserException { + if (inContext(clazz)) { + return (T)contextStack.peek(); + } else { + throw new StructurizrDslParserException("Expected " + clazz.getName() + " but got " + contextStack.peek().getClass().getName()); + } + } + + private void endContext() throws StructurizrDslParserException { + if (!contextStack.empty()) { + DslContext context = contextStack.pop(); + context.end(); + + dslPortable &= context.isDslPortable(); + } else { + throw new StructurizrDslParserException("Unexpected end of context"); + } + } + + private boolean isGroup(DslContext context) { + if (context instanceof GroupableDslContext) { + return ((GroupableDslContext)context).hasGroup(); + } + + return false; + } + + public Features getFeatures() { + return features; + } + + void setFeatures(Features features) { + this.features = features; + } + + public HttpClient getHttpClient() { + return httpClient; + } + + void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + private boolean isElementKeywordOrArchetype(String token, String keyword) { + if (token.equalsIgnoreCase(keyword)) { + return true; + } else { + return (archetypes.get(keyword).containsKey(token.toLowerCase())); + } + } + + private boolean isRelationshipKeywordOrArchetype(String token) { + if (token.equalsIgnoreCase(RELATIONSHIP_TOKEN)) { + return true; + } else if (token.startsWith(RELATIONSHIP_ARCHETYPE_PREFIX) && token.endsWith(RELATIONSHIP_ARCHETYPE_SUFFIX)) { + token = token.substring(RELATIONSHIP_ARCHETYPE_PREFIX.length(), token.length()-RELATIONSHIP_ARCHETYPE_SUFFIX.length()); + return (archetypes.get(RELATIONSHIP_TOKEN).containsKey(token.toLowerCase())); + } + + return false; + } + + private void addArchetype(Archetype archetype) { + archetypes.get(archetype.getType()).put(archetype.getName(), archetype); + } + + private Archetype getArchetype(String archetypeType, String archetypeName) { + Archetype archetype = null; + + if (RELATIONSHIP_TOKEN.equals(archetypeType)) { + if (archetypeName.startsWith(RELATIONSHIP_ARCHETYPE_PREFIX) && archetypeName.endsWith(RELATIONSHIP_ARCHETYPE_SUFFIX)) { + archetypeName = archetypeName.substring(RELATIONSHIP_ARCHETYPE_PREFIX.length(), archetypeName.length() - RELATIONSHIP_ARCHETYPE_SUFFIX.length()); + } + } + archetype = archetypes.get(archetypeType).get(archetypeName.toLowerCase()); + + if (archetype == null) { + archetype = new Archetype(archetypeName, archetypeType); + } + + return archetype; + } + + private void extendArchetype(Archetype archetype, String archetypeName) { + archetypeName = archetypeName.toLowerCase(); + Archetype parentArchetype = getArchetype(archetype.getType(), archetypeName); + + archetype.setMetadata(parentArchetype.getMetadata()); + archetype.setDescription(parentArchetype.getDescription()); + archetype.setTechnology(parentArchetype.getTechnology()); + archetype.addTags(parentArchetype.getTags().toArray(new String[0])); + } + + /** + * Gets the identifier register in use (this is the mapping of DSL identifiers to elements/relationships). + * + * @return an IdentifiersRegister object + */ + public IdentifiersRegister getIdentifiersRegister() { + return identifiersRegister; + } + + void registerIdentifier(String identifier, Element element) { + identifiersRegister.register(identifier, element); + element.addProperty(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME, identifiersRegister.findIdentifier(element)); + } + + void registerIdentifier(String identifier, Relationship relationship) { + identifiersRegister.register(identifier, relationship); + + if (!StringUtils.isNullOrEmpty(identifier)) { + relationship.addProperty(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME, identifiersRegister.findIdentifier(relationship)); + } + } + + /** + * Gets the named constant. + * + * @param name the name of the constant + * @return the value, or an empty string if the named constant doesn't exist + */ + public String getConstant(String name) { + NameValuePair nameValuePair = constantsAndVariables.get(name); + if (nameValuePair != null) { + return nameValuePair.getValue(); + } else { + return ""; + } + } + + /** + * Adds a constant to the parser. + * @param name the name of the constant + * @param value the value of the constant + */ + public void addConstant(String name, String value) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A constant name must be specified"); + } + + addConstant(new NameValuePair(name, value)); + } + + private void addConstant(NameValuePair nameValuePair) { + if (constantsAndVariables.containsKey(nameValuePair.getName())) { + throw new IllegalArgumentException("A constant/variable \"" + nameValuePair.getName() + "\" already exists"); + } + constantsAndVariables.put(nameValuePair.getName(), nameValuePair); + } + + private void addVariable(NameValuePair nameValuePair) { + if (constantsAndVariables.containsKey(nameValuePair.getName()) && constantsAndVariables.get(nameValuePair.getName()).getType() == NameValueType.Constant) { + throw new IllegalArgumentException("A constant \"" + nameValuePair.getName() + "\" already exists"); + } + constantsAndVariables.put(nameValuePair.getName(), nameValuePair); + } + + private void addTextBlock(String name, String value) { + if (StringUtils.isNullOrEmpty(name)) { + throw new IllegalArgumentException("A text block name must be specified"); + } + + NameValuePair nameValuePair = new NameValuePair(name, value); + nameValuePair.setType(NameValueType.TextBlock); + addConstant(nameValuePair); + } + + private boolean inContext(Class clazz) { + if (contextStack.empty()) { + return false; + } + + return clazz.isAssignableFrom(contextStack.peek().getClass()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParserException.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParserException.java new file mode 100644 index 000000000..70dc54d43 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslParserException.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +import java.io.File; + +/** + * Throw when there are parsing errors. + */ +public final class StructurizrDslParserException extends Exception { + + /** line number */ + private int lineNumber; + + /** line */ + private String line; + + /** + * Creates a new instance with the specified message. + * + * @param message the message + */ + StructurizrDslParserException(String message) { + super(message); + } + + StructurizrDslParserException(String message, File dslFile, int lineNumber, String line) { + super((message.endsWith(".") ? message.substring(0, message.length()-1) : message) + " at line " + lineNumber + (dslFile != null && dslFile.isFile() ? " of " + dslFile.getAbsolutePath() : "") + ": " + line.trim()); + this.lineNumber = lineNumber; + this.line = line; + } + + /** + * Gets the line number associated with the parsing exception. + * + * @return the line number, an integer + */ + public int getLineNumber() { + return lineNumber; + } + + /** + * Gets the line associated with the parsing exception. + * + * @return the line, as a String + */ + public String getLine() { + return line; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPlugin.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPlugin.java new file mode 100644 index 000000000..cd73e109c --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPlugin.java @@ -0,0 +1,15 @@ +package com.structurizr.dsl; + +/** + * An interface implemented by DSL plugins. + */ +public interface StructurizrDslPlugin { + + /** + * Called to execute the plugin. + * + * @param context a StructurizrDslPluginContext instance + */ + void run(StructurizrDslPluginContext context); + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPluginContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPluginContext.java new file mode 100644 index 000000000..10f104907 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslPluginContext.java @@ -0,0 +1,88 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.StringUtils; + +import java.io.File; +import java.util.Map; + +/** + * Used to pass contextual information to DSL plugins when they are executed. + */ +public class StructurizrDslPluginContext { + + private final StructurizrDslParser dslParser; + private final File dslFile; + private final Workspace workspace; + private final Map parameters; + + /** + * Creates a new instance. + * + * @param dslParser a reference to the DSL parser that loaded the plugin + * @param dslFile a reference to the DSL file that loaded the plugin + * @param workspace the workspace + * @param parameters a map of name/value pairs representing parameters + */ + public StructurizrDslPluginContext(StructurizrDslParser dslParser, File dslFile, Workspace workspace, Map parameters) { + this.dslParser = dslParser; + this.dslFile = dslFile; + this.workspace = workspace; + this.parameters = parameters; + } + + /** + * Gets a reference to the DSL parser that initiated this plugin context. + * + * @return a StructurizrDslParser instance + */ + public StructurizrDslParser getDslParser() { + return dslParser; + } + + /** + * Gets a reference to the DSL file that initiated this plugin context. + * + * @return a File instance + */ + public File getDslFile() { + return dslFile; + } + + /** + * Gets the current workspace. + * + * @return a Workspace instance + */ + public Workspace getWorkspace() { + return workspace; + } + + /** + * Gets the named parameter. + * + * @param name the parameter name + * @return the parameter value (null if unset) + */ + public String getParameter(String name) { + return parameters.get(name); + } + + /** + * Gets the named parameter, with a default value if unset. + * + * @param name the parameter name + * @param defaultValue the default value + * @return the parameter value, or defaultValue if unset + */ + public String getParameter(String name, String defaultValue) { + String value = parameters.get(name); + + if (StringUtils.isNullOrEmpty(value)) { + value = defaultValue; + } + + return value; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslScriptContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslScriptContext.java new file mode 100644 index 000000000..85c03f352 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslScriptContext.java @@ -0,0 +1,88 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.util.StringUtils; + +import java.io.File; +import java.util.Map; + +/** + * Used to pass contextual information to DSL scripts when they are executed. + */ +public class StructurizrDslScriptContext { + + private final StructurizrDslParser dslParser; + private final File dslFile; + private final Workspace workspace; + private final Map parameters; + + /** + * Creates a new instance. + * + * @param dslParser a reference to the DSL parser that loaded the script + * @param dslFile a reference to the DSL file that loaded the script + * @param workspace the workspace + * @param parameters a map of name/value pairs representing parameters + */ + public StructurizrDslScriptContext(StructurizrDslParser dslParser, File dslFile, Workspace workspace, Map parameters) { + this.dslParser = dslParser; + this.dslFile = dslFile; + this.workspace = workspace; + this.parameters = parameters; + } + + /** + * Gets a reference to the DSL parser that initiated this script context. + * + * @return a StructurizrDslParser instance + */ + public StructurizrDslParser getDslParser() { + return dslParser; + } + + /** + * Gets a reference to the DSL file that initiated this script context. + * + * @return a File instance + */ + public File getDslFile() { + return dslFile; + } + + /** + * Gets the current workspace. + * + * @return a Workspace instance + */ + public Workspace getWorkspace() { + return workspace; + } + + /** + * Gets the named parameter. + * + * @param name the parameter name + * @return the parameter value (null if unset) + */ + public String getParameter(String name) { + return parameters.get(name); + } + + /** + * Gets the named parameter, with a default value if unset. + * + * @param name the parameter name + * @param defaultValue the default value + * @return the parameter value, or defaultValue if unset + */ + public String getParameter(String name, String defaultValue) { + String value = parameters.get(name); + + if (StringUtils.isNullOrEmpty(value)) { + value = defaultValue; + } + + return value; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslTokens.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslTokens.java new file mode 100644 index 000000000..9ddcd5958 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StructurizrDslTokens.java @@ -0,0 +1,146 @@ +package com.structurizr.dsl; + +/** + * Main DSL parser class - forms the API for using the parser. + */ +class StructurizrDslTokens { + + static final String ASSIGNMENT_OPERATOR_TOKEN = "="; + + static final String CUSTOM_ELEMENT_TOKEN = "element"; + static final String PERSON_TOKEN = "person"; + static final String SOFTWARE_SYSTEM_TOKEN = "softwareSystem"; + static final String RELATIONSHIP_TOKEN = "->"; + static final String NO_RELATIONSHIP_TOKEN = "-/>"; + static final String CONTAINER_TOKEN = "container"; + static final String COMPONENT_TOKEN = "component"; + static final String GROUP_TOKEN = "group"; + static final String NAME_TOKEN = "name"; + static final String METADATA_TOKEN = "metadata"; + static final String DESCRIPTION_TOKEN = "description"; + static final String TECHNOLOGY_TOKEN = "technology"; + static final String INSTANCES_TOKEN = "instances"; + static final String TAGS_TOKEN = "tags"; + static final String TAG_TOKEN = "tag"; + static final String URL_TOKEN = "url"; + static final String PROPERTIES_TOKEN = "properties"; + static final String PERSPECTIVES_TOKEN = "perspectives"; + static final String WORKSPACE_TOKEN = "workspace"; + static final String EXTENDS_TOKEN = "extends"; + static final String SCOPE_TOKEN = "scope"; + static final String MODEL_TOKEN = "model"; + static final String ARCHETYPES_TOKEN = "archetypes"; + static final String VIEWS_TOKEN = "views"; + static final String ENTERPRISE_TOKEN = "enterprise"; + static final String DEPLOYMENT_ENVIRONMENT_TOKEN = "deploymentEnvironment"; + static final String DEPLOYMENT_GROUP_TOKEN = "deploymentGroup"; + static final String DEPLOYMENT_NODE_TOKEN = "deploymentNode"; + static final String INFRASTRUCTURE_NODE_TOKEN = "infrastructureNode"; + static final String INSTANCE_OF_TOKEN = "instanceOf"; + static final String SOFTWARE_SYSTEM_INSTANCE_TOKEN = "softwareSystemInstance"; + static final String CONTAINER_INSTANCE_TOKEN = "containerInstance"; + static final String HEALTH_CHECK_TOKEN = "healthCheck"; + static final String CUSTOM_VIEW_TOKEN = "custom"; + static final String SYSTEM_LANDSCAPE_VIEW_TOKEN = "systemLandscape"; + static final String SYSTEM_CONTEXT_VIEW_TOKEN = "systemContext"; + static final String CONTAINER_VIEW_TOKEN = "container"; + static final String COMPONENT_VIEW_TOKEN = "component"; + static final String DYNAMIC_VIEW_TOKEN = "dynamic"; + static final String DEPLOYMENT_VIEW_TOKEN = "deployment"; + static final String FILTERED_VIEW_TOKEN = "filtered"; + static final String IMAGE_VIEW_TOKEN = "image"; + static final String INCLUDE_IN_VIEW_TOKEN = "include"; + static final String EXCLUDE_IN_VIEW_TOKEN = "exclude"; + static final String ANIMATION_IN_VIEW_TOKEN = "animation"; + static final String ANIMATION_STEP_IN_VIEW_TOKEN = "animationStep"; + static final String AUTOLAYOUT_VIEW_TOKEN = "autolayout"; + static final String DEFAULT_VIEW_TOKEN = "default"; + static final String VIEW_TITLE_TOKEN = "title"; + static final String VIEW_DESCRIPTION_TOKEN = "description"; + static final String PLANTUML_TOKEN = "plantuml"; + static final String MERMAID_TOKEN = "mermaid"; + static final String KROKI_TOKEN = "kroki"; + static final String IMAGE_TOKEN = "image"; + static final String STYLES_TOKEN = "styles"; + static final String LIGHT_COLOR_SCHEME_TOKEN = "light"; + static final String DARK_COLOR_SCHEME_TOKEN = "dark"; + static final String BRANDING_TOKEN = "branding"; + static final String BRANDING_LOGO_TOKEN = "logo"; + static final String BRANDING_FONT_TOKEN = "font"; + static final String ELEMENT_STYLE_TOKEN = "element"; + static final String ELEMENT_STYLE_SHAPE_TOKEN = "shape"; + static final String ELEMENT_STYLE_BACKGROUND_TOKEN = "background"; + static final String ELEMENT_STYLE_STROKE_TOKEN = "stroke"; + static final String ELEMENT_STYLE_STROKE_WIDTH_TOKEN = "strokeWidth"; + static final String ELEMENT_STYLE_COLOUR_TOKEN = "colour"; + static final String ELEMENT_STYLE_COLOR_TOKEN = "color"; + static final String ELEMENT_STYLE_ICON_TOKEN = "icon"; + static final String ELEMENT_STYLE_ICON_POSITION_TOKEN = "iconPosition"; + static final String ELEMENT_STYLE_OPACITY_TOKEN = "opacity"; + static final String ELEMENT_STYLE_BORDER_TOKEN = "border"; + static final String ELEMENT_STYLE_FONT_SIZE_TOKEN = "fontSize"; + static final String ELEMENT_STYLE_WIDTH_TOKEN = "width"; + static final String ELEMENT_STYLE_HEIGHT_TOKEN = "height"; + static final String ELEMENT_STYLE_METADATA_TOKEN = "metadata"; + static final String ELEMENT_STYLE_DESCRIPTION_TOKEN = "description"; + static final String RELATIONSHIP_STYLE_TOKEN = "relationship"; + static final String RELATIONSHIP_STYLE_THICKNESS_TOKEN = "thickness"; + static final String RELATIONSHIP_STYLE_COLOUR_TOKEN = "colour"; + static final String RELATIONSHIP_STYLE_COLOR_TOKEN = "color"; + static final String RELATIONSHIP_STYLE_DASHED_TOKEN = "dashed"; + static final String RELATIONSHIP_STYLE_OPACITY_TOKEN = "opacity"; + static final String RELATIONSHIP_STYLE_ROUTING_TOKEN = "routing"; + static final String RELATIONSHIP_STYLE_JUMP_TOKEN = "jump"; + static final String RELATIONSHIP_STYLE_LINE_STYLE_TOKEN = "style"; + static final String RELATIONSHIP_STYLE_FONT_SIZE_TOKEN = "fontSize"; + static final String RELATIONSHIP_STYLE_WIDTH_TOKEN = "width"; + static final String RELATIONSHIP_STYLE_POSITION_TOKEN = "position"; + static final String THEME_TOKEN = "theme"; + static final String THEMES_TOKEN = "themes"; + static final String CONFIGURATION_TOKEN = "configuration"; + static final String VISIBILITY_TOKEN = "visibility"; + static final String TERMINOLOGY_TOKEN = "terminology"; + static final String TERMINOLOGY_RELATIONSHIP_TOKEN = "relationship"; + static final String METADATA_SYMBOLS_TOKEN = "metadata"; + static final String USERS_TOKEN = "users"; + static final String THIS_TOKEN = "this"; + + static final String INCLUDE_FILE_TOKEN = "!include"; + static final String DOCS_TOKEN = "!docs"; + static final String ADRS_TOKEN = "!adrs"; + static final String DECISIONS_TOKEN = "!decisions"; + static final String CONSTANT_TOKEN = "!constant"; + static final String CONST_TOKEN = "!const"; + static final String VAR_TOKEN = "!var"; + static final String IDENTIFIERS_TOKEN = "!identifiers"; + static final String IMPLIED_RELATIONSHIPS_TOKEN = "!impliedRelationships"; + + static final String REF_TOKEN = "!ref"; // deprecated + static final String EXTEND_TOKEN = "!extend"; // deprecated + + static final String FIND_ELEMENT_TOKEN = "!element"; + static final String FIND_ELEMENTS_TOKEN = "!elements"; + static final String FIND_RELATIONSHIP_TOKEN = "!relationship"; + static final String FIND_RELATIONSHIPS_TOKEN = "!relationships"; + + static final String PLUGIN_TOKEN = "!plugin"; + static final String SCRIPT_TOKEN = "!script"; + + static final String COMPONENT_FINDER_TOKEN = "!components"; + static final String COMPONENT_FINDER_CLASSES_TOKEN = "classes"; + static final String COMPONENT_FINDER_SOURCE_TOKEN = "source"; + static final String COMPONENT_FINDER_FILTER_TOKEN = "filter"; + static final String COMPONENT_FINDER_STRATEGY_TOKEN = "strategy"; + static final String COMPONENT_FINDER_STRATEGY_TECHNOLOGY_TOKEN = "technology"; + static final String COMPONENT_FINDER_STRATEGY_MATCHER_TOKEN = "matcher"; + static final String COMPONENT_FINDER_STRATEGY_FILTER_TOKEN = "filter"; + static final String COMPONENT_FINDER_STRATEGY_SUPPORTING_TYPES_TOKEN = "supportingTypes"; + static final String COMPONENT_FINDER_STRATEGY_NAME_TOKEN = "name"; + static final String COMPONENT_FINDER_STRATEGY_DESCRIPTION_TOKEN = "description"; + static final String COMPONENT_FINDER_STRATEGY_URL_TOKEN = "url"; + static final String COMPONENT_FINDER_STRATEGY_FOREACH_TOKEN = "forEach"; + + static final String RELATIONSHIP_ARCHETYPE_PREFIX = "--"; + static final String RELATIONSHIP_ARCHETYPE_SUFFIX = "->"; + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/StylesDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/StylesDslContext.java new file mode 100644 index 000000000..a374a54df --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/StylesDslContext.java @@ -0,0 +1,29 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ColorScheme; + +final class StylesDslContext extends DslContext { + + private final ColorScheme colorScheme; + + StylesDslContext() { + colorScheme = null; + } + + StylesDslContext(ColorScheme colorScheme) { + this.colorScheme = colorScheme; + } + + ColorScheme getColorScheme() { + return colorScheme; + } + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.ELEMENT_STYLE_TOKEN, + StructurizrDslTokens.RELATIONSHIP_STYLE_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewDslContext.java new file mode 100644 index 000000000..8fd9e05ae --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewDslContext.java @@ -0,0 +1,11 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemContextView; + +final class SystemContextViewDslContext extends StaticViewDslContext { + + SystemContextViewDslContext(SystemContextView view) { + super(view); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewParser.java new file mode 100644 index 000000000..20235e418 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemContextViewParser.java @@ -0,0 +1,55 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.SystemContextView; + +final class SystemContextViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "systemContext [key] [description] {"; + + private static final int SOFTWARE_SYSTEM_IDENTIFIER_INDEX = 1; + private static final int KEY_INDEX = 2; + private static final int DESCRIPTION_INDEX = 3; + + SystemContextView parse(DslContext context, Tokens tokens) { + // systemContext [key] [description] { + + Workspace workspace = context.getWorkspace(); + SoftwareSystem softwareSystem; + String key = ""; + String description = ""; + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (!tokens.includes(SOFTWARE_SYSTEM_IDENTIFIER_INDEX)) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String softwareSystemIdentifier = tokens.get(SOFTWARE_SYSTEM_IDENTIFIER_INDEX); + Element element = context.getElement(softwareSystemIdentifier); + if (element == null) { + throw new RuntimeException("The software system \"" + softwareSystemIdentifier + "\" does not exist"); + } + if (element instanceof SoftwareSystem) { + softwareSystem = (SoftwareSystem)element; + } else { + throw new RuntimeException("The element \"" + softwareSystemIdentifier + "\" is not a software system"); + } + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + return workspace.getViews().createSystemContextView(softwareSystem, key, description); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewDslContext.java new file mode 100644 index 000000000..1ca6c6cff --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewDslContext.java @@ -0,0 +1,11 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemLandscapeView; + +final class SystemLandscapeViewDslContext extends StaticViewDslContext { + + SystemLandscapeViewDslContext(SystemLandscapeView view) { + super(view); + } + +} diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewParser.java new file mode 100644 index 000000000..94495b111 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/SystemLandscapeViewParser.java @@ -0,0 +1,36 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.view.SystemLandscapeView; + +final class SystemLandscapeViewParser extends AbstractViewParser { + + private static final String GRAMMAR = "systemLandscape [key] [description] {"; + + private static final int KEY_INDEX = 1; + private static final int DESCRIPTION_INDEX = 2; + + SystemLandscapeView parse(DslContext context, Tokens tokens) { + // systemLandscape [key] [description] + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + Workspace workspace = context.getWorkspace(); + String key = ""; + String description = ""; + + if (tokens.includes(KEY_INDEX)) { + key = tokens.get(KEY_INDEX); + validateViewKey(key); + } + + if (tokens.includes(DESCRIPTION_INDEX)) { + description = tokens.get(DESCRIPTION_INDEX); + } + + return workspace.getViews().createSystemLandscapeView(key, description); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyDslContext.java new file mode 100644 index 000000000..32bc07e81 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyDslContext.java @@ -0,0 +1,19 @@ +package com.structurizr.dsl; + +final class TerminologyDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.PERSON_TOKEN, + StructurizrDslTokens.SOFTWARE_SYSTEM_TOKEN, + StructurizrDslTokens.CONTAINER_TOKEN, + StructurizrDslTokens.COMPONENT_TOKEN, + StructurizrDslTokens.DEPLOYMENT_NODE_TOKEN, + StructurizrDslTokens.INFRASTRUCTURE_NODE_TOKEN, + StructurizrDslTokens.TERMINOLOGY_RELATIONSHIP_TOKEN, + StructurizrDslTokens.METADATA_SYMBOLS_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyParser.java new file mode 100644 index 000000000..1fdaab43b --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/TerminologyParser.java @@ -0,0 +1,101 @@ +package com.structurizr.dsl; + +import com.structurizr.view.MetadataSymbols; + +import java.util.*; + +final class TerminologyParser extends AbstractParser { + + private final static int TERM_INDEX = 1; + private final static int SYMBOL_TYPE_INDEX = 1; + + void parsePerson(DslContext context, Tokens tokens) { + // person + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: person "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setPerson(tokens.get(TERM_INDEX)); + } + + void parseSoftwareSystem(DslContext context, Tokens tokens) { + // softwareSystem + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: softwareSystem "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setSoftwareSystem(tokens.get(TERM_INDEX)); + } + + void parseContainer(DslContext context, Tokens tokens) { + // container + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: container "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setContainer(tokens.get(TERM_INDEX)); + } + + void parseComponent(DslContext context, Tokens tokens) { + // component + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: component "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setComponent(tokens.get(TERM_INDEX)); + } + + void parseDeploymentNode(DslContext context, Tokens tokens) { + // deploymentNode + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: deploymentNode "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setDeploymentNode(tokens.get(TERM_INDEX)); + } + + void parseInfrastructureNode(DslContext context, Tokens tokens) { + // infrastructureNode + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: infrastructureNode "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setInfrastructureNode(tokens.get(TERM_INDEX)); + } + + void parseRelationship(DslContext context, Tokens tokens) { + // relationship + if (!tokens.includes(TERM_INDEX)) { + throw new RuntimeException("Expected: relationship "); + } + + context.getWorkspace().getViews().getConfiguration().getTerminology().setRelationship(tokens.get(TERM_INDEX)); + } + + void parseMetadataSymbols(DslContext context, Tokens tokens) { + Map symbols = new LinkedHashMap<>(); + symbols.put("square", MetadataSymbols.SquareBrackets); + symbols.put("round", MetadataSymbols.RoundBrackets); + symbols.put("curly", MetadataSymbols.CurlyBrackets); + symbols.put("angle", MetadataSymbols.AngleBrackets); + symbols.put("double-angle", MetadataSymbols.DoubleAngleBrackets); + symbols.put("none", MetadataSymbols.None); + + String symbolsAsString = String.join("|", symbols.keySet()); + + // metadata + if (!tokens.includes(SYMBOL_TYPE_INDEX)) { + throw new RuntimeException("Expected: metadata <" + symbolsAsString + ">"); + } + + String symbolAsString = tokens.get(SYMBOL_TYPE_INDEX).toLowerCase(); + MetadataSymbols symbol = symbols.get(symbolAsString); + if (symbol != null) { + context.getWorkspace().getViews().getConfiguration().setMetadataSymbols(symbol); + } else { + throw new RuntimeException("The symbol type \"" + symbolAsString + "\" is not valid"); + } + + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ThemeParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ThemeParser.java new file mode 100644 index 000000000..90b5e3e73 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ThemeParser.java @@ -0,0 +1,73 @@ +package com.structurizr.dsl; + +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.Url; +import com.structurizr.view.ThemeUtils; + +import java.io.File; + +final class ThemeParser extends AbstractParser { + + private static final String DEFAULT_THEME_NAME = "default"; + private static final String DEFAULT_THEME_URL = "https://static.structurizr.com/themes/default/theme.json"; + + private final static int FIRST_THEME_INDEX = 1; + + void parseTheme(DslContext context, File dslFile, Tokens tokens) { + // theme + if (tokens.hasMoreThan(FIRST_THEME_INDEX)) { + throw new RuntimeException("Too many tokens, expected: theme "); + } + + if (!tokens.includes(FIRST_THEME_INDEX)) { + throw new RuntimeException("Expected: theme "); + } + + addTheme(context, dslFile, tokens.get(FIRST_THEME_INDEX)); + } + + void parseThemes(DslContext context, File dslFile, Tokens tokens) { + // themes [url|file] ... [url|file] + if (!tokens.includes(FIRST_THEME_INDEX)) { + throw new RuntimeException("Expected: themes [url|file] ... [url|file]"); + } + + for (int i = FIRST_THEME_INDEX; i < tokens.size(); i++) { + addTheme(context, dslFile, tokens.get(i)); + } + } + + private void addTheme(DslContext context, File dslFile, String theme) { + if (DEFAULT_THEME_NAME.equalsIgnoreCase(theme)) { + theme = DEFAULT_THEME_URL; + } + + if (Url.isUrl(theme)) { + // this adds the theme to the list of theme URLs in the workspace + context.getWorkspace().getViews().getConfiguration().addTheme(theme); + } else { + if (context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + context.setDslPortable(false); + + // this inlines the file-based theme into the workspace + File file = new File(dslFile.getParentFile(), theme); + if (file.exists()) { + if (file.isFile()) { + try { + ThemeUtils.inlineTheme(context.getWorkspace(), file); + } catch (Exception e) { + throw new RuntimeException("Error loading theme from " + file.getAbsolutePath() + ": " + e.getMessage()); + } + } else { + throw new RuntimeException(file.getAbsolutePath() + " is not a file"); + } + } else { + throw new RuntimeException(file.getAbsolutePath() + " does not exist"); + } + } else { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "File-based themes are not permitted"); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokenizer.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokenizer.java new file mode 100644 index 000000000..cd371def3 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokenizer.java @@ -0,0 +1,58 @@ +package com.structurizr.dsl; + +import java.util.ArrayList; +import java.util.List; + +class Tokenizer { + + List tokenize(String line) { + List tokens = new ArrayList<>(); + line = line.trim(); + + boolean tokenStarted = false; + boolean quoted = false; + StringBuilder token = new StringBuilder(); + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + + if (!tokenStarted) { + if (c == '"') { + quoted = true; + tokenStarted = true; + token = new StringBuilder(); + } else if (Character.isWhitespace(c)) { + // skip + } else { + quoted = false; + tokenStarted = true; + token = new StringBuilder(); + token.append(c); + } + } else { + if (c == '"' && line.charAt(i-1) == '\\') { + // escaped quote + token.append(c); + } else if (quoted && c == '"') { + // this is the end of the token + tokens.add(token.toString()); + tokenStarted = false; + quoted = false; + } else if (!quoted && Character.isWhitespace(c)) { + tokens.add(token.toString()); + tokenStarted = false; + quoted = false; + } else { + token.append(c); + } + } + } + + if (tokenStarted) { + tokens.add(token.toString()); + } + + return tokens; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokens.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokens.java new file mode 100644 index 000000000..55fe452ba --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/Tokens.java @@ -0,0 +1,45 @@ +package com.structurizr.dsl; + +import java.util.List; + +final class Tokens { + + private List tokens; + + Tokens(List tokens) { + this.tokens = tokens; + } + + String get(int index) { + return tokens.get(index).replaceAll("\\\\\"", "\"").replaceAll("\\\\n", "\n"); + } + + void remove(int index) { + tokens.remove(index); + } + + int size() { + return tokens.size(); + } + + boolean contains(String token) { + return tokens.contains(token.trim()); + } + + Tokens withoutContextStartToken() { + if (tokens.get(tokens.size()-1).equals(DslContext.CONTEXT_START_TOKEN)) { + return new Tokens(tokens.subList(0, tokens.size()-1)); + } else { + return this; + } + } + + boolean includes(int index) { + return tokens.size() - 1 >= index; + } + + boolean hasMoreThan(int index) { + return includes(index + 1); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/UserRoleParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/UserRoleParser.java new file mode 100644 index 000000000..e0db37ef8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/UserRoleParser.java @@ -0,0 +1,39 @@ +package com.structurizr.dsl; + +import com.structurizr.configuration.Role; + +final class UserRoleParser extends AbstractParser { + + private static final String GRAMMAR = " "; + + private final static int USERNAME_INDEX = 0; + private final static int ROLE_INDEX = 1; + + void parse(DslContext context, Tokens tokens) { + // + + if (tokens.hasMoreThan(ROLE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR); + } + + if (tokens.size() != 2) { + throw new RuntimeException("Expected: " + GRAMMAR); + } + + String username = tokens.get(USERNAME_INDEX); + String roleAsString = tokens.get(ROLE_INDEX); + + Role role; + + if (roleAsString.equalsIgnoreCase("write")) { + role = Role.ReadWrite; + } else if (roleAsString.equalsIgnoreCase("read")) { + role = Role.ReadOnly; + } else { + throw new RuntimeException("The role should be \"read\" or \"write\""); + } + + context.getWorkspace().getConfiguration().addUser(username, role); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/UsersDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/UsersDslContext.java new file mode 100644 index 000000000..e8b6a0170 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/UsersDslContext.java @@ -0,0 +1,10 @@ +package com.structurizr.dsl; + +final class UsersDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[0]; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewDslContext.java new file mode 100644 index 000000000..37a5660b8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewDslContext.java @@ -0,0 +1,17 @@ +package com.structurizr.dsl; + +import com.structurizr.view.View; + +abstract class ViewDslContext extends DslContext { + + private final View view; + + ViewDslContext(View view) { + this.view = view; + } + + View getView() { + return view; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewParser.java new file mode 100644 index 000000000..b632cbc87 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewParser.java @@ -0,0 +1,51 @@ +package com.structurizr.dsl; + +import com.structurizr.view.View; + +final class ViewParser extends AbstractParser { + + private static final String TITLE_GRAMMAR = "title "; + private static final String DESCRIPTION_GRAMMAR = "description <description>"; + + private static final int TITLE_INDEX = 1; + private static final int DESCRIPTION_INDEX = 1; + + void parseTitle(ViewDslContext context, Tokens tokens) { + // title <title> + + if (tokens.hasMoreThan(TITLE_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + TITLE_GRAMMAR); + } + + View view = context.getView(); + if (view != null) { + if (tokens.size() == 2) { + String title = tokens.get(TITLE_INDEX); + + view.setTitle(title); + } else { + throw new RuntimeException("Expected: " + TITLE_GRAMMAR); + } + } + } + + void parseDescription(ViewDslContext context, Tokens tokens) { + // description <description> + + if (tokens.hasMoreThan(DESCRIPTION_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + DESCRIPTION_GRAMMAR); + } + + View view = context.getView(); + if (view != null) { + if (tokens.size() == 2) { + String description = tokens.get(DESCRIPTION_INDEX); + + view.setDescription(description); + } else { + throw new RuntimeException("Expected: " + DESCRIPTION_GRAMMAR); + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewsDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewsDslContext.java new file mode 100644 index 000000000..bfa78b285 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/ViewsDslContext.java @@ -0,0 +1,25 @@ +package com.structurizr.dsl; + +final class ViewsDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.SYSTEM_LANDSCAPE_VIEW_TOKEN, + StructurizrDslTokens.SYSTEM_CONTEXT_VIEW_TOKEN, + StructurizrDslTokens.CONTAINER_VIEW_TOKEN, + StructurizrDslTokens.COMPONENT_VIEW_TOKEN, + StructurizrDslTokens.FILTERED_VIEW_TOKEN, + StructurizrDslTokens.DYNAMIC_VIEW_TOKEN, + StructurizrDslTokens.DEPLOYMENT_VIEW_TOKEN, + StructurizrDslTokens.CUSTOM_VIEW_TOKEN, + StructurizrDslTokens.STYLES_TOKEN, + StructurizrDslTokens.THEME_TOKEN, + StructurizrDslTokens.THEMES_TOKEN, + StructurizrDslTokens.BRANDING_TOKEN, + StructurizrDslTokens.TERMINOLOGY_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceDslContext.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceDslContext.java new file mode 100644 index 000000000..74fbbe6f8 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceDslContext.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +final class WorkspaceDslContext extends DslContext { + + @Override + protected String[] getPermittedTokens() { + return new String[] { + StructurizrDslTokens.NAME_TOKEN, + StructurizrDslTokens.DESCRIPTION_TOKEN, + StructurizrDslTokens.PROPERTIES_TOKEN, + StructurizrDslTokens.DOCS_TOKEN, + StructurizrDslTokens.DECISIONS_TOKEN, + StructurizrDslTokens.IDENTIFIERS_TOKEN, + StructurizrDslTokens.IMPLIED_RELATIONSHIPS_TOKEN, + StructurizrDslTokens.MODEL_TOKEN, + StructurizrDslTokens.VIEWS_TOKEN, + StructurizrDslTokens.CONFIGURATION_TOKEN + }; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceParser.java b/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceParser.java new file mode 100644 index 000000000..59ebc4a54 --- /dev/null +++ b/structurizr-dsl/src/main/java/com/structurizr/dsl/WorkspaceParser.java @@ -0,0 +1,167 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.http.RemoteContent; +import com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.util.FeatureNotEnabledException; +import com.structurizr.util.Url; +import com.structurizr.util.WorkspaceUtils; + +import java.io.File; + +final class WorkspaceParser extends AbstractParser { + + private static final String GRAMMAR_STANDALONE = "workspace [name] [description]"; + private static final String GRAMMAR_EXTENDS = "workspace extends <file|url>"; + + private static final String STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME = "structurizr.dsl.identifier"; + + private static final int FIRST_INDEX = 1; + private static final int SECOND_INDEX = 2; + + Workspace parse(DslParserContext context, Tokens tokens) { + // workspace [name] [description] + // workspace extends <file|url> + + Workspace workspace = new Workspace("Name", "Description"); + + if (tokens.hasMoreThan(SECOND_INDEX)) { + throw new RuntimeException("Too many tokens, expected: " + GRAMMAR_STANDALONE + " or " + GRAMMAR_EXTENDS); + } + + if (tokens.includes(FIRST_INDEX)) { + String firstToken = tokens.get(FIRST_INDEX); + + if (StructurizrDslTokens.EXTENDS_TOKEN.equals(firstToken)) { + if (tokens.includes(SECOND_INDEX)) { + String source = tokens.get(SECOND_INDEX); + + try { + if (Url.isHttpsUrl(source) || Url.isHttpUrl(source)) { + if (Url.isHttpsUrl(source) && !context.getFeatures().isEnabled(Features.HTTPS)) { + throw new FeatureNotEnabledException(Features.HTTPS, "Extends via HTTPS are not permitted"); + } + if (Url.isHttpUrl(source) && !context.getFeatures().isEnabled(Features.HTTP)) { + throw new FeatureNotEnabledException(Features.HTTP, "Extends via HTTP are not permitted"); + } + + RemoteContent remoteContent = context.getHttpClient().get(source); + + if (source.toLowerCase().endsWith(".json") || remoteContent.getContentType().startsWith(RemoteContent.CONTENT_TYPE_JSON)) { + String json = remoteContent.getContentAsString(); + workspace = WorkspaceUtils.fromJson(json); + registerIdentifiers(workspace, context); + } else { + String dsl = remoteContent.getContentAsString(); + + StructurizrDslParser structurizrDslParser = createParser(context); + structurizrDslParser.parse(context, dsl); + + workspace = structurizrDslParser.getWorkspace(); + context.getParser().configureFrom(structurizrDslParser); + } + } else { + if (!context.getFeatures().isEnabled(Features.FILE_SYSTEM)) { + throw new FeatureNotEnabledException(Features.FILE_SYSTEM, "Extending a file-based workspace is not permitted"); + } + + if (context.getFile() != null) { + File file = new File(context.getFile().getParent(), source); + if (!file.exists()) { + throw new RuntimeException(file.getCanonicalPath() + " could not be found"); + } + + if (file.isDirectory()) { + throw new RuntimeException(file.getCanonicalPath() + " should be a single file"); + } + + if (source.toLowerCase().endsWith(".json")) { + workspace = WorkspaceUtils.loadWorkspaceFromJson(file); + registerIdentifiers(workspace, context); + } else { + StructurizrDslParser structurizrDslParser = createParser(context); + structurizrDslParser.parse(context, file); + + workspace = structurizrDslParser.getWorkspace(); + context.getParser().configureFrom(structurizrDslParser); + } + + DslUtils.clearDsl(workspace); + context.setDslPortable(false); + } + } + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } else { + throw new RuntimeException("Expected: " + GRAMMAR_EXTENDS); + } + } else { + workspace.setName(firstToken); + + if (tokens.includes(SECOND_INDEX)) { + workspace.setDescription(tokens.get(SECOND_INDEX)); + } + } + } + + workspace.getModel().setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + return workspace; + } + + private StructurizrDslParser createParser(DslParserContext context) { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setFeatures(context.getFeatures()); + parser.setHttpClient(context.getHttpClient()); + + return parser; + } + + private void registerIdentifiers(Workspace workspace, DslParserContext context) { + for (Element element : workspace.getModel().getElements()) { + if (element.getProperties().containsKey(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME)) { + String identifier = element.getProperties().get(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME); + context.identifiersRegister.register(identifier, element); + } + } + + for (Relationship relationship : workspace.getModel().getRelationships()) { + if (relationship.getProperties().containsKey(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME)) { + String identifier = relationship.getProperties().get(STRUCTURIZR_DSL_IDENTIFIER_PROPERTY_NAME); + context.identifiersRegister.register(identifier, relationship); + } + } + } + + void parseName(DslContext context, Tokens tokens) { + // name <name> + if (tokens.hasMoreThan(FIRST_INDEX)) { + throw new RuntimeException("Too many tokens, expected: name <name>"); + } + + if (!tokens.includes(FIRST_INDEX)) { + throw new RuntimeException("Expected: name <name>"); + } + + String name = tokens.get(FIRST_INDEX); + context.getWorkspace().setName(name); + } + + void parseDescription(DslContext context, Tokens tokens) { + // description <description> + if (tokens.hasMoreThan(FIRST_INDEX)) { + throw new RuntimeException("Too many tokens, expected: description <description>"); + } + + if (!tokens.includes(FIRST_INDEX)) { + throw new RuntimeException("Expected: description <description>"); + } + + String description = tokens.get(FIRST_INDEX); + context.getWorkspace().setDescription(description); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractTests.java new file mode 100644 index 000000000..005d8aca8 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractTests.java @@ -0,0 +1,32 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy; +import com.structurizr.model.Model; +import com.structurizr.view.ViewSet; + +import java.util.ArrayList; +import java.util.Arrays; + +abstract class AbstractTests { + + protected Workspace workspace = new Workspace("Name", "Description"); + protected Model model = workspace.getModel(); + protected ViewSet views = workspace.getViews(); + + AbstractTests() { + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + } + + protected ModelDslContext context() { + ModelDslContext context = new ModelDslContext(); + context.setWorkspace(workspace); + + return context; + } + + protected Tokens tokens(String... tokens) { + return new Tokens(new ArrayList<>(Arrays.asList(tokens))); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractViewParserTests.java new file mode 100644 index 000000000..7322b1acc --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/AbstractViewParserTests.java @@ -0,0 +1,34 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class AbstractViewParserTests { + + private final AbstractViewParser parser = new SystemLandscapeViewParser(); + + @Test + void test_validateViewKey() { + parser.validateViewKey("key"); + parser.validateViewKey("key123"); + parser.validateViewKey("Key123"); + parser.validateViewKey("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"); + + try { + parser.validateViewKey("abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789"); + fail(); + } catch (Exception e) { + assertEquals("View keys can only contain the following characters: a-zA-Z0-9_-", e.getMessage()); + } + + try { + parser.validateViewKey("abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789"); + fail(); + } catch (Exception e) { + assertEquals("View keys can only contain the following characters: a-zA-Z0-9_-", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/AutoLayoutParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/AutoLayoutParserTests.java new file mode 100644 index 000000000..ea6e1e68f --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/AutoLayoutParserTests.java @@ -0,0 +1,111 @@ +package com.structurizr.dsl; + +import com.structurizr.view.AutomaticLayout; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AutoLayoutParserTests extends AbstractTests { + + private AutoLayoutParser parser = new AutoLayoutParser(); + + @Test + void test_parse_EnablesAutoLayoutWithSomeDefaults() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getAutomaticLayout()); + parser.parse(context, tokens("autoLayout")); + assertEquals(AutomaticLayout.Implementation.Graphviz, view.getAutomaticLayout().getImplementation()); + assertEquals(AutomaticLayout.RankDirection.TopBottom, view.getAutomaticLayout().getRankDirection()); + assertEquals(300, view.getAutomaticLayout().getRankSeparation()); + assertEquals(300, view.getAutomaticLayout().getNodeSeparation()); + } + + @Test + void test_parse_EnablesAutoLayoutWithAValidRankDirection() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getAutomaticLayout()); + parser.parse(context, tokens("autoLayout", "lr")); + assertEquals(AutomaticLayout.Implementation.Graphviz, view.getAutomaticLayout().getImplementation()); + assertEquals(AutomaticLayout.RankDirection.LeftRight, view.getAutomaticLayout().getRankDirection()); + assertEquals(300, view.getAutomaticLayout().getRankSeparation()); + assertEquals(300, view.getAutomaticLayout().getNodeSeparation()); + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidRankDirectionIsSpecified() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parse(context, tokens("autoLayout", "hello")); + fail(); + } catch (Exception e) { + assertEquals("Valid rank directions are: tb|bt|lr|rl", e.getMessage()); + } + } + + @Test + void test_parse_EnablesAutoLayoutWithAValidRankSeparation() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getAutomaticLayout()); + parser.parse(context, tokens("autoLayout", "tb", "123")); + assertEquals(AutomaticLayout.Implementation.Graphviz, view.getAutomaticLayout().getImplementation()); + assertEquals(AutomaticLayout.RankDirection.TopBottom, view.getAutomaticLayout().getRankDirection()); + assertEquals(123, view.getAutomaticLayout().getRankSeparation()); + assertEquals(300, view.getAutomaticLayout().getNodeSeparation()); + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidRankSeparationIsSpecified() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parse(context, tokens("autoLayout", "tb", "hello")); + fail(); + } catch (Exception e) { + assertEquals("Rank separation must be positive integer in pixels", e.getMessage()); + } + } + + @Test + void test_parse_EnablesAutoLayoutWithAValidNodeSeparation() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getAutomaticLayout()); + parser.parse(context, tokens("autoLayout", "tb", "123", "456")); + assertEquals(AutomaticLayout.Implementation.Graphviz, view.getAutomaticLayout().getImplementation()); + assertEquals(AutomaticLayout.RankDirection.TopBottom, view.getAutomaticLayout().getRankDirection()); + assertEquals(123, view.getAutomaticLayout().getRankSeparation()); + assertEquals(456, view.getAutomaticLayout().getNodeSeparation()); + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidNodeSeparationIsSpecified() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parse(context, tokens("autoLayout", "tb", "300", "hello")); + fail(); + } catch (Exception e) { + assertEquals("Node separation must be positive integer in pixels", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/BrandingParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/BrandingParserTests.java new file mode 100644 index 000000000..1bf47b5a1 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/BrandingParserTests.java @@ -0,0 +1,173 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +class BrandingParserTests extends AbstractTests { + + private BrandingParser parser = new BrandingParser(); + + @Test + void test_parseLogo_ThrowsAnException_WhenThereAreTooManyTokens() { + BrandingDslContext context = new BrandingDslContext(null); + + try { + parser.parseLogo(context, tokens("logo", "path", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: logo <path|url>", e.getMessage()); + } + } + + @Test + void test_parseLogo_ThrowsAnException_WhenNoPathIsSpecified() { + BrandingDslContext context = new BrandingDslContext(null); + + try { + parser.parseLogo(context, tokens("logo")); + fail(); + } catch (Exception e) { + assertEquals("Expected: logo <path|url>", e.getMessage()); + } + } + + @Test + void test_parseLogo_ThrowsAnException_WhenTheLogoDoesNotExist() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.getFeatures().enable(Features.FILE_SYSTEM); + + try { + parser.parseLogo(context, tokens("logo", "hello.png")); + fail(); + } catch (Exception e) { + assertEquals("hello.png does not exist", e.getMessage()); + } + } + + @Test + void test_parseLogo_ThrowsAnException_WhenTheFileIsNotSupported() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.getFeatures().enable(Features.FILE_SYSTEM); + + try { + parser.parseLogo(context, tokens("logo", "src/test/resources/dsl/getting-started.dsl")); + fail(); + } catch (Exception e) { + e.printStackTrace(); + assertTrue(e.getMessage().endsWith("is not a supported image file.")); + } + } + + @Test + void test_parseLogo_SetsTheLogo_WhenTheLogoDoesExist() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + context.getFeatures().enable(Features.FILE_SYSTEM); + + parser.parseLogo(context, tokens("logo", "src/test/resources/dsl/logo.png")); + assertTrue(workspace.getViews().getConfiguration().getBranding().getLogo().startsWith("data:image/png;base64,")); + } + + @Test + void test_parseLogo_SetsTheLogoFromADataUri() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + + parser.parseLogo(context, tokens("logo", "data:image/png;base64,123456789012345678901234567890")); + assertTrue(workspace.getViews().getConfiguration().getBranding().getLogo().startsWith("data:image/png;base64,123456789012345678901234567890")); + } + + @Test + void test_parseLogo_ThrowsAnException_WithAHttpIconAndHttpIsNotEnabled() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTP); + + try { + parser.parseLogo(context, tokens("logo", "http://structurizr.com/logo.png")); + fail(); + } catch (Exception e) { + assertEquals("Icons via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseLogo_SetsTheLogoFromAHttpUrl() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + context.getFeatures().enable(Features.HTTP); + + parser.parseLogo(context, tokens("logo", "http://structurizr.com/logo.png")); + assertEquals("http://structurizr.com/logo.png", workspace.getViews().getConfiguration().getBranding().getLogo()); + } + + @Test + void test_parseLogo_ThrowsAnException_WithAHttpsIconAndHttpsIsNotEnabled() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTPS); + + try { + parser.parseLogo(context, tokens("logo", "https://structurizr.com/logo.png")); + fail(); + } catch (Exception e) { + assertEquals("Icons via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseLogo_SetsTheLogoFromAHttpsUrl() { + BrandingDslContext context = new BrandingDslContext(new File(".")); + context.setWorkspace(workspace); + context.getFeatures().enable(Features.HTTPS); + + parser.parseLogo(context, tokens("logo", "https://structurizr.com/logo.png")); + assertEquals("https://structurizr.com/logo.png", workspace.getViews().getConfiguration().getBranding().getLogo()); + } + + @Test + void test_parseFont_ThrowsAnException_WhenThereAreTooManyTokens() { + BrandingDslContext context = new BrandingDslContext(null); + + try { + parser.parseFont(context, tokens("font", "name", "url", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: font <name> [url]", e.getMessage()); + } + } + + @Test + void test_parseFont_ThrowsAnException_WhenNoNameIsSpecified() { + BrandingDslContext context = new BrandingDslContext(null); + + try { + parser.parseFont(context, tokens("font")); + fail(); + } catch (Exception e) { + assertEquals("Expected: font <name> [url]", e.getMessage()); + } + } + + @Test + void test_parseFont_SetsTheFontName() { + BrandingDslContext context = new BrandingDslContext(null); + context.setWorkspace(workspace); + + parser.parseFont(context, tokens("font", "Times New Roman")); + assertEquals("Times New Roman", workspace.getViews().getConfiguration().getBranding().getFont().getName()); + } + + @Test + void test_parseFont_SetsTheFontUrl() { + BrandingDslContext context = new BrandingDslContext(null); + context.setWorkspace(workspace); + + parser.parseFont(context, tokens("font", "Open Sans", "https://fonts.googleapis.com/css2?family=Open+Sans")); + assertEquals("https://fonts.googleapis.com/css2?family=Open+Sans", workspace.getViews().getConfiguration().getBranding().getFont().getUrl()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderParserTests.java new file mode 100644 index 000000000..ec43b6f57 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderParserTests.java @@ -0,0 +1,59 @@ +package com.structurizr.dsl; + +import com.structurizr.component.ComponentFinderBuilder; +import com.structurizr.component.matcher.NameSuffixTypeMatcher; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ComponentFinderParserTests extends AbstractTests { + + private final ComponentFinderParser parser = new ComponentFinderParser(); + private final ComponentFinderDslContext context = new ComponentFinderDslContext(null, new ContainerDslContext(null)); + + @Test + void test_parseFilter_ThrowsAnException_WhenNoModeAndTypeAreSpecified() { + try { + parser.parseFilter(context, tokens("filter")); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseFilter(context, tokens("filter", "include")); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_ThrowsAnException_WhenInvalidModeIsSpecified() { + try { + parser.parseFilter(context, tokens("filter", "mode", "fqn-regex")); + fail(); + } catch (Exception e) { + assertEquals("Filter mode should be \"include\" or \"exclude\": filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_WhenIncludeFullyQualifiedNameRegexTypeFilterIsUsed() { + parser.parseFilter(context, tokens("filter", "include", "fqn-regex", ".*")); + assertEquals("ComponentFinderBuilder{container=null, typeProviders=[], typeFilter=IncludeFullyQualifiedNameRegexFilter{regex='.*'}, componentFinderStrategies=[]}", context.getComponentFinderBuilder().toString()); + } + + @Test + void test_parseFilter_WhenExcludeFullyQualifiedNameRegexTypeFilterIsUsed() { + parser.parseFilter(context, tokens("filter", "exclude", "fqn-regex", ".*")); + assertEquals("ComponentFinderBuilder{container=null, typeProviders=[], typeFilter=ExcludeFullyQualifiedNameRegexFilter{regex='.*'}, componentFinderStrategies=[]}", context.getComponentFinderBuilder().toString()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderStrategyParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderStrategyParserTests.java new file mode 100644 index 000000000..a136790ee --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentFinderStrategyParserTests.java @@ -0,0 +1,336 @@ +package com.structurizr.dsl; + +import com.structurizr.component.matcher.NameSuffixTypeMatcher; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ComponentFinderStrategyParserTests extends AbstractTests { + + private final ComponentFinderStrategyParser parser = new ComponentFinderStrategyParser(); + private final ComponentFinderStrategyDslContext context = new ComponentFinderStrategyDslContext(new ComponentFinderDslContext(null, new ContainerDslContext(null))); + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooFewTokens() { + try { + parser.parseTechnology(context, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <name>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseTechnology(context, tokens("technology", "name", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <name>", e.getMessage()); + } + } + + @Test + void test_parseTechnology() { + parser.parseTechnology(context, tokens("technology", "name")); + assertEquals("ComponentFinderStrategyBuilder{technology='name', typeMatcher=null, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseMatcher(context, tokens("matcher"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: matcher <annotation|extends|implements|name-suffix|fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheAnnotationTypeMatcherIsUsedAndThereAreTooFewTokens() { + try { + parser.parseMatcher(context, tokens("matcher", "annotation"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: matcher annotation <fqn>", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheAnnotationTypeMatcherIsUsed() { + parser.parseMatcher(context, tokens("matcher", "annotation", "com.example.Component"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=AnnotationTypeMatcher{annotationType='Lcom/example/Component;'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenTheExtendsTypeMatcherIsUsedAndThereAreTooFewTokens() { + try { + parser.parseMatcher(context, tokens("matcher", "extends"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: matcher extends <fqn>", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheExtendsTypeMatcherIsUsed() { + parser.parseMatcher(context, tokens("matcher", "extends", "com.example.Component"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=ExtendsTypeMatcher{className='com.example.Component'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenTheImplementsTypeMatcherIsUsedAndThereAreTooFewTokens() { + try { + parser.parseMatcher(context, tokens("matcher", "implements"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: matcher implements <fqn>", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheImplementsTypeMatcherIsUsed() { + parser.parseMatcher(context, tokens("matcher", "implements", "com.example.Component"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=ImplementsTypeMatcher{interfaceName='com.example.Component'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenTheNameSuffixTypeMatcherIsUsedAndThereAreTooFewTokens() { + try { + parser.parseMatcher(context, tokens("matcher", "name-suffix"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: matcher name-suffix <name>", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheNameSuffixTypeMatcherIsUsed() { + parser.parseMatcher(context, tokens("matcher", "name-suffix", "Component"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=NameSuffixTypeMatcher{suffix='Component'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenTheFullyQualifiedNameRegexTypeMatcherIsUsedAndThereAreTooFewTokens() { + try { + parser.parseMatcher(context, tokens("matcher", "fqn-regex"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: matcher fqn-regex <regex>", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenTheRegexTypeMatcherIsUsed() { + parser.parseMatcher(context, tokens("matcher", "fqn-regex", ".*Component"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=RegexTypeMatcher{regex='.*Component'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenACustomTypeMatcherIsUsedButCannotBeLoaded() { + try { + parser.parseMatcher(context, tokens("matcher", "com.example.CustomTypeMatcher"), new File(".")); + fail(); + } catch (Exception e) { + assertEquals("Type matcher \"com.example.CustomTypeMatcher\" could not be loaded - class java.lang.ClassNotFoundException: com.example.CustomTypeMatcher", e.getMessage()); + } + } + + @Test + void test_parseMatcher_WhenACustomTypeMatcherIsUsedWithoutParameters() { + parser.parseMatcher(context, tokens("matcher", "com.structurizr.dsl.example.CustomTypeMatcher"), new File(".")); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=CustomTypeMatcher{}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseMatcher_WhenACustomTypeMatcherIsUsedWithAParameter() { + parser.parseMatcher(context, tokens("matcher", NameSuffixTypeMatcher.class.getCanonicalName(), "Component"), new File(".")); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=NameSuffixTypeMatcher{suffix='Component'}, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseFilter_ThrowsAnException_WhenNoModeAndTypeAreSpecified() { + try { + parser.parseFilter(context, tokens("filter"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseFilter(context, tokens("filter", "include"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_ThrowsAnException_WhenInvalidModeIsSpecified() { + try { + parser.parseFilter(context, tokens("filter", "mode", "fqn-regex"), null); + fail(); + } catch (Exception e) { + assertEquals("Filter mode should be \"include\" or \"exclude\": filter <include|exclude> <fqn-regex> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseFilter_WhenIncludeFullyQualifiedNameRegexTypeFilterIsUsed() { + parser.parseFilter(context, tokens("filter", "include", "fqn-regex", ".*"), new File(".")); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=IncludeFullyQualifiedNameRegexFilter{regex='.*'}, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseFilter_WhenExcludeFullyQualifiedNameRegexTypeFilterIsUsed() { + parser.parseFilter(context, tokens("filter", "exclude", "fqn-regex", ".*"), new File(".")); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=ExcludeFullyQualifiedNameRegexFilter{regex='.*'}, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseSupportingTypes_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseSupportingTypes(context, tokens("supportingTypes"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: supportingTypes <all-referenced|referenced-in-package|in-package|under-package|implementation-prefix|implementation-suffix|none> [parameters]", e.getMessage()); + } + } + + @Test + void test_parseSupportingTypes_ThrowsAnException_WhenImplementationSuffixIsUsedWithoutASuffix() { + try { + parser.parseSupportingTypes(context, tokens("supportingTypes", "implementation-suffix"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: supportingTypes implementation-suffix <suffix>", e.getMessage()); + } + } + + @Test + void test_parseSupportingTypes_ImplementationSuffix() { + parser.parseSupportingTypes(context, tokens("supportingTypes", "implementation-suffix", "Impl"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=ImplementationWithSuffixSupportingTypesStrategy{suffix='Impl'}, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseSupportingTypes_ThrowsAnException_WhenImplementationPrefixIsUsedWithoutAPrefix() { + try { + parser.parseSupportingTypes(context, tokens("supportingTypes", "implementation-prefix"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: supportingTypes implementation-prefix <prefix>", e.getMessage()); + } + } + + @Test + void test_parseSupportingTypes_ImplementationPrefix() { + parser.parseSupportingTypes(context, tokens("supportingTypes", "implementation-prefix", "Jdbc"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=ImplementationWithPrefixSupportingTypesStrategy{prefix='Jdbc'}, namingStrategy=null, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseName_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseName(context, tokens("name"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: name <type-name|fqn|package>", e.getMessage()); + } + } + + @Test + void test_parseName() { + parser.parseName(context, tokens("name", "type-name"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=null, namingStrategy=TypeNamingStrategy{}, descriptionStrategy=null, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseDescription(context, tokens("description"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: description <first-sentence|truncated>", e.getMessage()); + } + } + + @Test + void test_parseDescription_FirstSentence() { + parser.parseDescription(context, tokens("description", "first-sentence"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=FirstSentenceDescriptionStrategy{}, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseDescription_Truncated_ThrowsAnException_WhenNoMaxLengthIsSpecified() { + try { + parser.parseDescription(context, tokens("description", "truncated"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: description truncated <maxLength>", e.getMessage()); + } + } + + @Test + void test_parseDescription_Truncated_ThrowsAnException_WhenAnInvalidMaxLengthIsSpecified() { + try { + parser.parseDescription(context, tokens("description", "truncated", "invalid"), null); + fail(); + } catch (Exception e) { + assertEquals("Max length must be an integer", e.getMessage()); + } + + try { + parser.parseDescription(context, tokens("description", "truncated", "-1"), null); + fail(); + } catch (Exception e) { + assertEquals("Max length must be greater than 0", e.getMessage()); + } + + try { + parser.parseDescription(context, tokens("description", "truncated", "0"), null); + fail(); + } catch (Exception e) { + assertEquals("Max length must be greater than 0", e.getMessage()); + } + } + + @Test + void test_parseDescription_Truncated() { + parser.parseDescription(context, tokens("description", "truncated", "50"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=TruncatedDescriptionStrategy{maxLength=50}, urlStrategy=null, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + + @Test + void test_parseUrl_ThrowsAnException_WhenNoTypeIsSpecified() { + try { + parser.parseUrl(context, tokens("url"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: url <prefix-src>", e.getMessage()); + } + } + + @Test + void test_parseUrl_Prefix_ThrowsAnException_WhenNoPrefixIsSpecified() { + try { + parser.parseUrl(context, tokens("url", "prefix-src"), null); + fail(); + } catch (Exception e) { + assertEquals("Too few tokens, expected: url prefix-src <prefix>", e.getMessage()); + } + } + + @Test + void test_parseUrl_Prefix() { + parser.parseUrl(context, tokens("url", "prefix-src", "https://example.com"), null); + assertEquals("ComponentFinderStrategyBuilder{technology=null, typeMatcher=null, typeFilter=null, supportingTypesStrategy=null, namingStrategy=null, descriptionStrategy=null, urlStrategy=PrefixUrlStrategy{prefix='https://example.com/'}, componentVisitor=null}", context.getComponentFinderStrategyBuilder().toString()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentParserTests.java new file mode 100644 index 000000000..babac9aab --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentParserTests.java @@ -0,0 +1,148 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ComponentParserTests extends AbstractTests { + + private ComponentParser parser = new ComponentParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new ContainerDslContext(null), tokens("container", "name", "description", "technology", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: component <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(new ContainerDslContext(null), tokens("container"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: component <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAComponent() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parse(context, tokens("component", "Name"), archetype); + + assertEquals(3, model.getElements().size()); + Component component = container.getComponentWithName("Name"); + assertNotNull(component); + assertEquals("", component.getDescription()); + assertEquals("", component.getTechnology()); + assertEquals("Element,Component", component.getTags()); + } + + @Test + void test_parse_CreatesAComponentWithADescription() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parse(context, tokens("component", "Name", "Description"), archetype); + + assertEquals(3, model.getElements().size()); + Component component = container.getComponentWithName("Name"); + assertNotNull(component); + assertEquals("Description", component.getDescription()); + assertEquals("", component.getTechnology()); + assertEquals("Element,Component", component.getTags()); + } + + @Test + void test_parse_CreatesAComponentWithADescriptionAndTechnology() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parse(context, tokens("component", "Name", "Description", "Technology"), archetype); + + assertEquals(3, model.getElements().size()); + Component component = container.getComponentWithName("Name"); + assertNotNull(component); + assertEquals("Description", component.getDescription()); + assertEquals("Technology", component.getTechnology()); + assertEquals("Element,Component", component.getTags()); + } + + @Test + void test_parse_CreatesAComponentWithADescriptionAndTechnologyAndTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parse(context, tokens("component", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(3, model.getElements().size()); + Component component = container.getComponentWithName("Name"); + assertNotNull(component); + assertEquals("Description", component.getDescription()); + assertEquals("Technology", component.getTechnology()); + assertEquals("Element,Component,Tag 1,Tag 2", component.getTags()); + } + + @Test + void test_parse_CreatesAComponentWithADescriptionAndTechnologyAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parse(context, tokens("component", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(3, model.getElements().size()); + Component component = container.getComponentWithName("Name"); + assertNotNull(component); + assertEquals("Description", component.getDescription()); // overridden from archetype + assertEquals("Technology", component.getTechnology()); // overridden from archetype + assertEquals("Element,Component,Default Tag,Tag 1,Tag 2", component.getTags()); + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + Component component = model.addSoftwareSystem("Software System").addContainer("Container").addComponent("Component"); + ComponentDslContext context = new ComponentDslContext(component); + parser.parseTechnology(context, tokens("technology", "technology", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + Component component = model.addSoftwareSystem("Software System").addContainer("Container").addComponent("Component"); + ComponentDslContext context = new ComponentDslContext(component); + parser.parseTechnology(context, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_SetsTheDescription_WhenADescriptionIsSpecified() { + Component component = model.addSoftwareSystem("Software System").addContainer("Container").addComponent("Component"); + ComponentDslContext context = new ComponentDslContext(component); + parser.parseTechnology(context, tokens("technology", "Technology")); + + assertEquals("Technology", component.getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentViewParserTests.java new file mode 100644 index 000000000..aac6950e9 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ComponentViewParserTests.java @@ -0,0 +1,108 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ComponentView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ComponentViewParserTests extends AbstractTests { + + private ComponentViewParser parser = new ComponentViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("component", "container", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: component <container identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheContainerIdentifierIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("component")); + fail(); + } catch (Exception e) { + assertEquals("Expected: component <container identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheContainerIsNotDefined() { + DslContext context = context(); + try { + parser.parse(context, tokens("component", "container", "key")); + fail(); + } catch (Exception e) { + assertEquals("The container \"container\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotAContainer() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("component", "container", "key")); + fail(); + } catch (Exception e) { + assertEquals("The element \"container\" is not a container", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAComponentView() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addSoftwareSystem("Name", "Description").addContainer("Container", "Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("component", "container")); + List<ComponentView> views = new ArrayList<>(context.getWorkspace().getViews().getComponentViews()); + + assertEquals(1, views.size()); + assertEquals("Component-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAComponentViewWithAKey() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addSoftwareSystem("Name", "Description").addContainer("container", "Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("component", "container", "key")); + List<ComponentView> views = new ArrayList<>(context.getWorkspace().getViews().getComponentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAComponentViewWithAKeyAndDescription() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addSoftwareSystem("Name", "Description").addContainer("container", "Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("component", "container", "key", "Description")); + List<ComponentView> views = new ArrayList<>(context.getWorkspace().getViews().getComponentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ConfigurationParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ConfigurationParserTests.java new file mode 100644 index 000000000..157c9bc61 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ConfigurationParserTests.java @@ -0,0 +1,86 @@ +package com.structurizr.dsl; + +import com.structurizr.configuration.Visibility; +import com.structurizr.configuration.WorkspaceScope; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ConfigurationParserTests extends AbstractTests { + + private final ConfigurationParser parser = new ConfigurationParser(); + + @Test + void test_parseScope_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseScope(context(), tokens("scope", "landscape", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: scope <landscape|softwaresystem|none>", e.getMessage()); + } + } + + @Test + void test_parseScope_ThrowsAnException_WhenTheScopeIsMissing() { + try { + parser.parseScope(context(), tokens("scope")); + fail(); + } catch (Exception e) { + assertEquals("Expected: scope <landscape|softwaresystem|none>", e.getMessage()); + } + } + + @Test + void test_parseScope_ThrowsAnException_WhenTheScopeIsNotValid() { + try { + parser.parseScope(context(), tokens("scope", "container")); + fail(); + } catch (Exception e) { + assertEquals("The scope \"container\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseScope_SetsTheScope() { + parser.parseScope(context(), tokens("scope", "softwaresystem")); + assertEquals(WorkspaceScope.SoftwareSystem, workspace.getConfiguration().getScope()); + } + + @Test + void test_parseVisibility_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseVisibility(context(), tokens("visibility", "public", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: visibility <private|public>", e.getMessage()); + } + } + + @Test + void test_parseVisibility_ThrowsAnException_WhenTheVisibilityIsMissing() { + try { + parser.parseVisibility(context(), tokens("visibility")); + fail(); + } catch (Exception e) { + assertEquals("Expected: visibility <private|public>", e.getMessage()); + } + } + + @Test + void test_parseVisibility_ThrowsAnException_WhenTheVisibilityIsNotValid() { + try { + parser.parseVisibility(context(), tokens("visibility", "shared")); + fail(); + } catch (Exception e) { + assertEquals("The visibility \"shared\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseVisibility_SetsTheVisibility() { + parser.parseVisibility(context(), tokens("visibility", "public")); + assertEquals(Visibility.Public, workspace.getConfiguration().getVisibility()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerInstanceParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerInstanceParserTests.java new file mode 100644 index 000000000..98dde6ece --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerInstanceParserTests.java @@ -0,0 +1,153 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ContainerInstanceParserTests extends AbstractTests { + + private final ContainerInstanceParser parser = new ContainerInstanceParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("containerInstance", "identifier", "deploymentGroups", "tags", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: containerInstance <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheIdentifierIsNotSpecified() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("containerInstance")); + fail(); + } catch (Exception e) { + assertEquals("Expected: containerInstance <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("containerInstance", "container")); + fail(); + } catch (Exception e) { + assertEquals("The container \"container\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotAContainer() { + DeploymentNodeDslContext context = new DeploymentNodeDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("containerInstance", "container")); + fail(); + } catch (Exception e) { + assertEquals("The container \"container\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAContainerInstanceInTheDefaultDeploymentGroup() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("containerInstance", "container")); + + assertEquals(4, model.getElements().size()); + assertEquals(1, deploymentNode.getContainerInstances().size()); + ContainerInstance containerInstance = deploymentNode.getContainerInstances().iterator().next(); + assertSame(container, containerInstance.getContainer()); + assertEquals("Container Instance", containerInstance.getTags()); + assertEquals("Live", containerInstance.getEnvironment()); + assertEquals(1, containerInstance.getDeploymentGroups().size()); + assertEquals("Default", containerInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesAContainerInstanceInTheDefaultDeploymentGroupWithTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("containerInstance", "container", "", "Tag 1, Tag 2")); + + assertEquals(4, model.getElements().size()); + assertEquals(1, deploymentNode.getContainerInstances().size()); + ContainerInstance containerInstance = deploymentNode.getContainerInstances().iterator().next(); + assertSame(container, containerInstance.getContainer()); + assertEquals("Container Instance,Tag 1,Tag 2", containerInstance.getTags()); + assertEquals("Live", containerInstance.getEnvironment()); + assertEquals(1, containerInstance.getDeploymentGroups().size()); + assertEquals("Default", containerInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesAContainerInstanceInTheSpecifiedDeploymentGroup() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + elements.register("live", new DeploymentEnvironment("Live")); + elements.register("group", new DeploymentGroup(new DeploymentEnvironment("Live"), "Group")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("containerInstance", "container", "group")); + + assertEquals(4, model.getElements().size()); + assertEquals(1, deploymentNode.getContainerInstances().size()); + ContainerInstance containerInstance = deploymentNode.getContainerInstances().iterator().next(); + assertSame(container, containerInstance.getContainer()); + assertEquals("Container Instance", containerInstance.getTags()); + assertEquals("Live", containerInstance.getEnvironment()); + assertEquals(1, containerInstance.getDeploymentGroups().size()); + assertEquals("Group", containerInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesAContainerInstanceInTheSpecifiedDeploymentGroupWithTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + elements.register("live", new DeploymentEnvironment("Live")); + elements.register("group", new DeploymentGroup(new DeploymentEnvironment("Live"), "Group")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("containerInstance", "container", "group", "Tag 1, Tag 2")); + + assertEquals(4, model.getElements().size()); + assertEquals(1, deploymentNode.getContainerInstances().size()); + ContainerInstance containerInstance = deploymentNode.getContainerInstances().iterator().next(); + assertSame(container, containerInstance.getContainer()); + assertEquals("Container Instance,Tag 1,Tag 2", containerInstance.getTags()); + assertEquals("Live", containerInstance.getEnvironment()); + assertEquals(1, containerInstance.getDeploymentGroups().size()); + assertEquals("Group", containerInstance.getDeploymentGroups().iterator().next()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerParserTests.java new file mode 100644 index 000000000..3215df02c --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerParserTests.java @@ -0,0 +1,142 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ContainerParserTests extends AbstractTests { + + private ContainerParser parser = new ContainerParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new SoftwareSystemDslContext(null), tokens("container", "name", "description", "technology", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: container <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(new SoftwareSystemDslContext(null), tokens("container"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: container <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAContainer() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parse(context, tokens("container", "Name"), archetype); + + assertEquals(2, model.getElements().size()); + Container container = softwareSystem.getContainerWithName("Name"); + assertNotNull(container); + assertEquals("", container.getDescription()); + assertEquals("", container.getTechnology()); + assertEquals("Element,Container", container.getTags()); + } + + @Test + void test_parse_CreatesAContainerWithADescription() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parse(context, tokens("container", "Name", "Description"), archetype); + + assertEquals(2, model.getElements().size()); + Container container = softwareSystem.getContainerWithName("Name"); + assertNotNull(container); + assertEquals("Description", container.getDescription()); + assertEquals("", container.getTechnology()); + assertEquals("Element,Container", container.getTags()); + } + + @Test + void test_parse_CreatesAContainerWithADescriptionAndTechnology() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parse(context, tokens("container", "Name", "Description", "Technology"), archetype); + + assertEquals(2, model.getElements().size()); + Container container = softwareSystem.getContainerWithName("Name"); + assertNotNull(container); + assertEquals("Description", container.getDescription()); + assertEquals("Technology", container.getTechnology()); + assertEquals("Element,Container", container.getTags()); + } + + @Test + void test_parse_CreatesAContainerWithADescriptionAndTechnologyAndTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parse(context, tokens("container", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(2, model.getElements().size()); + Container container = softwareSystem.getContainerWithName("Name"); + assertNotNull(container); + assertEquals("Description", container.getDescription()); + assertEquals("Technology", container.getTechnology()); + assertEquals("Element,Container,Tag 1,Tag 2", container.getTags()); + } + + @Test + void test_parse_CreatesAContainerWithADescriptionAndTechnologyAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parse(context, tokens("container", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(2, model.getElements().size()); + Container container = softwareSystem.getContainerWithName("Name"); + assertNotNull(container); + assertEquals("Description", container.getDescription()); // overridden from archetype + assertEquals("Technology", container.getTechnology()); // overridden from archetype + assertEquals("Element,Container,Default Tag,Tag 1,Tag 2", container.getTags()); + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + Container container = model.addSoftwareSystem("Software System").addContainer("Container"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parseTechnology(context, tokens("technology", "technology", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + Container container = model.addSoftwareSystem("Software System").addContainer("Container"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parseTechnology(context, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_SetsTheDescription_WhenADescriptionIsSpecified() { + Container container = model.addSoftwareSystem("Software System").addContainer("Container"); + ContainerDslContext context = new ContainerDslContext(container); + parser.parseTechnology(context, tokens("technology", "Technology")); + + assertEquals("Technology", container.getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerViewParserTests.java new file mode 100644 index 000000000..5d24c2a9d --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ContainerViewParserTests.java @@ -0,0 +1,108 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ContainerView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ContainerViewParserTests extends AbstractTests { + + private ContainerViewParser parser = new ContainerViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("container", "identifier", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: container <software system identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSoftwareSystemIdentifierIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("container")); + fail(); + } catch (Exception e) { + assertEquals("Expected: container <software system identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSoftwareSystemIsNotDefined() { + DslContext context = context(); + try { + parser.parse(context, tokens("container", "softwareSystem", "key")); + fail(); + } catch (Exception e) { + assertEquals("The software system \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystem() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("container", "softwareSystem", "key")); + fail(); + } catch (Exception e) { + assertEquals("The element \"softwareSystem\" is not a software system", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAContainerView() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("container", "softwareSystem")); + List<ContainerView> views = new ArrayList<>(context.getWorkspace().getViews().getContainerViews()); + + assertEquals(1, views.size()); + assertEquals("Container-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAContainerViewWithAKey() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("container", "softwareSystem", "key")); + List<ContainerView> views = new ArrayList<>(context.getWorkspace().getViews().getContainerViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAContainerViewWithAKeyAndDescription() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("container", "softwareSystem", "key", "Description")); + List<ContainerView> views = new ArrayList<>(context.getWorkspace().getViews().getContainerViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/CookbookTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/CookbookTests.java new file mode 100644 index 000000000..f5c666ecf --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/CookbookTests.java @@ -0,0 +1,33 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +class CookbookTests extends AbstractTests { + + @Test + void test() throws Exception { + File cookbookDirectory = new File("docs/cookbook"); + parseDslFiles(cookbookDirectory); + } + + private void parseDslFiles(File directory) throws Exception { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + parseDslFiles(file); + } else if (file.getName().startsWith("example-") && file.getName().endsWith(".dsl")) { + parseDslFile(file); + } + } + } + } + + private void parseDslFile(File file) throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(file); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomElementParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomElementParserTests.java new file mode 100644 index 000000000..27d48b963 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomElementParserTests.java @@ -0,0 +1,96 @@ +package com.structurizr.dsl; + +import com.structurizr.model.CustomElement; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomElementParserTests extends AbstractTests { + + private CustomElementParser parser = new CustomElementParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("element", "name", "metadata", "description", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: element <name> [metadata] [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(context(), tokens("element"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: element <name> [metadata] [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesACustomElement() { + parser.parse(context(), tokens("element", "Name"), archetype); + + assertEquals(1, model.getElements().size()); + CustomElement element = model.getCustomElementWithName("Name"); + assertNotNull(element); + assertEquals("", element.getDescription()); + assertEquals("Element", element.getTags()); + } + + @Test + void test_parse_CreatesACustomElementWithMetadata() { + parser.parse(context(), tokens("element", "Name", "Box"), archetype); + + assertEquals(1, model.getElements().size()); + CustomElement element = model.getCustomElementWithName("Name"); + assertNotNull(element); + assertEquals("Box", element.getMetadata()); + assertEquals("", element.getDescription()); + assertEquals("Element", element.getTags()); + } + + @Test + void test_parse_CreatesACustomElementWithMetadataAndDescription() { + parser.parse(context(), tokens("element", "Name", "Box", "Description"), archetype); + + assertEquals(1, model.getElements().size()); + CustomElement element = model.getCustomElementWithName("Name"); + assertNotNull(element); + assertEquals("Box", element.getMetadata()); + assertEquals("Description", element.getDescription()); + assertEquals("Element", element.getTags()); + } + + @Test + void test_parse_CreatesACustomElementWithMetadataAndDescriptionAndTags() { + parser.parse(context(), tokens("element", "Name", "Box", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + CustomElement element = model.getCustomElementWithName("Name"); + assertNotNull(element); + assertEquals("Box", element.getMetadata()); + assertEquals("Description", element.getDescription()); + assertEquals("Element,Tag 1,Tag 2", element.getTags()); + } + + @Test + void test_parse_CreatesACustomElementWithMetadataAndDescriptionAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.addTags("Default Tag"); + + parser.parse(context(), tokens("element", "Name", "Box", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + CustomElement element = model.getCustomElementWithName("Name"); + assertNotNull(element); + assertEquals("Box", element.getMetadata()); + assertEquals("Description", element.getDescription()); // overridden from archetype + assertEquals("Element,Default Tag,Tag 1,Tag 2", element.getTags()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewAnimationStepParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewAnimationStepParserTests.java new file mode 100644 index 000000000..b1cd19d46 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewAnimationStepParserTests.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +import com.structurizr.view.CustomView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class CustomViewAnimationStepParserTests extends AbstractTests { + + private CustomViewAnimationStepParser parser = new CustomViewAnimationStepParser(); + + @Test + void test_parseExplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((CustomViewDslContext)null, tokens("animationStep")); + fail(); + } catch (Exception e) { + assertEquals("Expected: animationStep <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parseImplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((CustomViewAnimationDslContext) null, tokens()); + fail(); + } catch (Exception e) { + assertEquals("Expected: <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + CustomView view = workspace.getViews().createCustomView("key", "Title", "Description"); + + CustomViewAnimationDslContext context = new CustomViewAnimationDslContext(view); + IdentifiersRegister map = new IdentifiersRegister(); + context.setIdentifierRegister(map); + + try { + parser.parse(context, tokens("e")); + fail(); + } catch (Exception e) { + assertEquals("The element/relationship \"e\" does not exist", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewContentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewContentParserTests.java new file mode 100644 index 000000000..25e616b5d --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewContentParserTests.java @@ -0,0 +1,357 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.CustomView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomViewContentParserTests extends AbstractTests { + + private CustomViewContentParser parser = new CustomViewContentParser(); + + @Test + void test_parseInclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + try { + parser.parseInclude(new CustomViewDslContext(null), tokens("include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: include <*|identifier> [*|identifier...]", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + try { + parser.parseInclude(new CustomViewDslContext(null), tokens("include", "box")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"box\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddAStaticStructureElementToACustomView() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", softwareSystem); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "element")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"element\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddADeploymentElementToACustomView() { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", deploymentNode); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "element")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"element\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseInclude_AddsAllCustomElementsToA_WhenTheWildcardIsSpecified() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*")); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsTheSpecifiedPeopleAndSoftwareSystemsToASystemLandscapeView() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + CustomElement box3 = model.addCustomElement("Box 3"); + box1.uses(box2, ""); + box2.uses(box3, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + elements.register("box3", box3); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "box1", "box2")); + + assertEquals(2, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(box1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(box2))); + assertEquals(1, view.getRelationships().size()); + } + + @Test + void test_parseInclude_IncludesTheSpecifiedRelationship_WhenARelationshipExpressionIsSpecified() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + Relationship relationship = box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + view.add(box1); + view.add(box2); + view.remove(relationship); + assertEquals(2, view.getElements().size()); + assertEquals(0, view.getRelationships().size()); + + parser.parseInclude(context, tokens("include", "relationship.source==box1 && relationship.destination==box2")); + assertEquals(1, view.getRelationships().size()); + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseExclude(context, tokens("include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: exclude <identifier> [identifier...]", iae.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseExclude(context, tokens("exclude", "box")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"box\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheSpecifiedElementsFromAView() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "box2")); + + assertEquals(1, view.getElements().size()); + assertTrue(view.getElements().stream().noneMatch(ev -> ev.getElement().equals(box2))); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExplicitIdentifierIsSpecified() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + Relationship relationship = box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister identifersRegister = new IdentifiersRegister(); + identifersRegister.register("rel", relationship); + context.setIdentifierRegister(identifersRegister); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "rel")); + + assertEquals(2, view.getElements().size()); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipSourceElementDoesNotExistInTheModel() { + try { + CustomView view = views.createCustomView("key", "Title", "Description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "relationship.source==box1 && relationship.destination==box2")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"box1\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipDestinationElementDoesNotExistInTheModel() { + try { + CustomElement box1 = model.addCustomElement("Box 1"); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==box1 && relationship.source==box2")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"box2\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSourceAndDestination() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.source==box1 && relationship.destination==box2")); + + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSource() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.source==box1")); + + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithADestination() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.destination==box2")); + + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesAllRelationshipsFromAView_WhenAnExpressionIsSpecifiedWithAWildcard() { + CustomElement box1 = model.addCustomElement("Box 1"); + CustomElement box2 = model.addCustomElement("Box 2"); + box1.uses(box2, ""); + + CustomView view = views.createCustomView("key", "Title", "Description"); + view.add(box1); + view.add(box2); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("box1", box1); + elements.register("box2", box2); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship==*")); + + assertEquals(0, view.getRelationships().size()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewParserTests.java new file mode 100644 index 000000000..1b6c644ce --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/CustomViewParserTests.java @@ -0,0 +1,75 @@ +package com.structurizr.dsl; + +import com.structurizr.view.CustomView; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class CustomViewParserTests extends AbstractTests { + + private CustomViewParser parser = new CustomViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("custom", "key", "title", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: custom [key] [title] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_CreatesACustomView() { + DslContext context = context(); + parser.parse(context, tokens("custom")); + List<CustomView> views = new ArrayList<>(context.getWorkspace().getViews().getCustomViews()); + + assertEquals(1, views.size()); + assertEquals("Custom-001", views.get(0).getKey()); + assertEquals("", views.get(0).getTitle()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesACustomViewWithAKey() { + DslContext context = context(); + parser.parse(context, tokens("custom", "key")); + List<CustomView> views = new ArrayList<>(context.getWorkspace().getViews().getCustomViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getTitle()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesACustomViewWithAKeyAndTitle() { + DslContext context = context(); + parser.parse(context, tokens("custom", "key", "Title")); + List<CustomView> views = new ArrayList<>(context.getWorkspace().getViews().getCustomViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Title", views.get(0).getTitle()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesACustomViewWithAKeyAndTitleAndDescription() { + DslContext context = context(); + parser.parse(context, tokens("custom", "key", "Title", "Description")); + List<CustomView> views = new ArrayList<>(context.getWorkspace().getViews().getCustomViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Title", views.get(0).getTitle()); + assertEquals("Description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DecisionsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DecisionsParserTests.java new file mode 100644 index 000000000..e1c913609 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DecisionsParserTests.java @@ -0,0 +1,22 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DecisionsParserTests extends AbstractTests { + + private final DecisionsParser parser = new DecisionsParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new WorkspaceDslContext(), null, tokens("decisions", "path", "fqn", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !decisions <path> <type|fqn>", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DefaultViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DefaultViewParserTests.java new file mode 100644 index 000000000..df7eeeffa --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DefaultViewParserTests.java @@ -0,0 +1,24 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DefaultViewParserTests extends AbstractTests { + + private final DefaultViewParser parser = new DefaultViewParser(); + + @Test + void test_parse_SetsTheDefaultView() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(context.getWorkspace().getViews().getConfiguration().getDefaultView()); + parser.parse(context); + assertEquals("key", context.getWorkspace().getViews().getConfiguration().getDefaultView()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentEnvironmentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentEnvironmentParserTests.java new file mode 100644 index 000000000..ebb1cb16c --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentEnvironmentParserTests.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DeploymentEnvironmentParserTests extends AbstractTests { + + private DeploymentEnvironmentParser parser = new DeploymentEnvironmentParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(tokens("deploymentEnvironment", "name", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: deploymentEnvironment <name> {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsMissing() { + try { + parser.parse(tokens("deploymentEnvironment")); + fail(); + } catch (Exception e) { + assertEquals("Expected: deploymentEnvironment <name> {", e.getMessage()); + } + } + + @Test + void test_parse() { + String environment = parser.parse(tokens("deploymentEnvironment", "Live")); + assertEquals("Live", environment); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentGroupParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentGroupParserTests.java new file mode 100644 index 000000000..71ff1e2ed --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentGroupParserTests.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DeploymentGroupParserTests extends AbstractTests { + + private DeploymentGroupParser parser = new DeploymentGroupParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(tokens("deploymentGroup", "name", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: deploymentGroup <name>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsMissing() { + try { + parser.parse(tokens("deploymentGroup")); + fail(); + } catch (Exception e) { + assertEquals("Expected: deploymentGroup <name>", e.getMessage()); + } + } + + @Test + void test_parse() { + String service1 = parser.parse(tokens("deploymentGroup", "Service 1")); + assertEquals("Service 1", service1); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentNodeParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentNodeParserTests.java new file mode 100644 index 000000000..f5694274e --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentNodeParserTests.java @@ -0,0 +1,266 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.model.DeploymentNode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DeploymentNodeParserTests extends AbstractTests { + + private DeploymentNodeParser parser = new DeploymentNodeParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new DeploymentEnvironmentDslContext("env"), tokens("deploymentNode", "name", "description", "technology", "tags", "instances", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: deploymentNode <name> [description] [technology] [tags] [instances] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(new DeploymentEnvironmentDslContext("env"), tokens("deploymentNode"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: deploymentNode <name> [description] [technology] [tags] [instances] {", e.getMessage()); + } + } + + @Test + void test_parse_CreatesADeploymentNode() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("", deploymentNode.getDescription()); + assertEquals("", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node", deploymentNode.getTags()); + assertEquals("1", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentNodeWithADescription() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); + assertEquals("", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node", deploymentNode.getTags()); + assertEquals("1", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentNodeWithADescriptionAndTechnology() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); + assertEquals("Technology", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node", deploymentNode.getTags()); + assertEquals("1", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentNodeWithADescriptionAndTechnologyAndTags() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); + assertEquals("Technology", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node,Tag 1,Tag 2", deploymentNode.getTags()); + assertEquals("1", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentNodeWithADescriptionAndTechnologyAndTagsAndInstances() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology", "Tag 1, Tag 2", "8"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); + assertEquals("Technology", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node,Tag 1,Tag 2", deploymentNode.getTags()); + assertEquals("8", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentNodeWithADescriptionAndTechnologyAndTagsAndInstancesBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology", "Tag 1, Tag 2", "8"), archetype); + + assertEquals(1, model.getElements().size()); + DeploymentNode deploymentNode = model.getDeploymentNodeWithName("Name", "Live"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); // overridden from archetype + assertEquals("Technology", deploymentNode.getTechnology()); // overridden from archetype + assertEquals("Element,Deployment Node,Default Tag,Tag 1,Tag 2", deploymentNode.getTags()); + assertEquals("8", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_ThrowsAnException_WhenTheNumberOfInstancesIsNotValid() { + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("Live"); + context.setWorkspace(workspace); + + try { + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology", "Tag 1, Tag 2", "abc"), archetype); + System.out.println(model.getDeploymentNodes().iterator().next().getInstances()); + fail(); + } catch (Exception e) { + assertEquals("Number of instances must be a positive integer or a range.", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAChildDeploymentNode() { + DeploymentNode parent = model.addDeploymentNode("Live", "Parent", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(parent); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name"), archetype); + + assertEquals(2, model.getElements().size()); + DeploymentNode deploymentNode = parent.getDeploymentNodeWithName("Name"); + assertNotNull(deploymentNode); + assertEquals("", deploymentNode.getDescription()); + assertEquals("", deploymentNode.getTechnology()); + assertEquals("Element,Deployment Node", deploymentNode.getTags()); + assertEquals("1", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parse_CreatesAChildDeploymentNodeWithADescriptionAndTechnologyAndTagsAndInstancesBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + DeploymentNode parent = model.addDeploymentNode("Live", "Parent", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(parent); + context.setWorkspace(workspace); + parser.parse(context, tokens("deploymentNode", "Name", "Description", "Technology", "Tag 1, Tag 2", "8"), archetype); + + assertEquals(2, model.getElements().size()); + DeploymentNode deploymentNode = parent.getDeploymentNodeWithName("Name"); + assertNotNull(deploymentNode); + assertEquals("Description", deploymentNode.getDescription()); // overridden from archetype + assertEquals("Technology", deploymentNode.getTechnology()); // overridden from archetype + assertEquals("Element,Deployment Node,Default Tag,Tag 1,Tag 2", deploymentNode.getTags()); + assertEquals("8", deploymentNode.getInstances()); + assertEquals("Live", deploymentNode.getEnvironment()); + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseTechnology(context, tokens("technology", "technology", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseTechnology(context, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_SetsTheTechnology_WhenADescriptionIsSpecified() { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseTechnology(context, tokens("technology", "Technology")); + + assertEquals("Technology", deploymentNode.getTechnology()); + } + + @Test + void test_parseInstances_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseInstances(context, tokens("instances", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: instances <number|range>", e.getMessage()); + } + } + + @Test + void test_parseInstances_ThrowsAnException_WhenNoNumberIsSpecified() { + try { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseInstances(context, tokens("instances")); + fail(); + } catch (Exception e) { + assertEquals("Expected: instances <number|range>", e.getMessage()); + } + } + + @Test + void test_parseInstances_ThrowsAnException_WhenAnInvalidNumberIsSpecified() { + try { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseInstances(context, tokens("instances", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Number of instances must be a positive integer or a range.", e.getMessage()); + } + } + + @Test + void test_parseInstances_SetsTheInstances_WhenANumberIsSpecified() { + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + parser.parseInstances(context, tokens("instances", "123")); + + assertEquals("123", deploymentNode.getInstances()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewAnimationStepParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewAnimationStepParserTests.java new file mode 100644 index 000000000..a11bbf992 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewAnimationStepParserTests.java @@ -0,0 +1,69 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.view.DeploymentView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DeploymentViewAnimationStepParserTests extends AbstractTests { + + private DeploymentViewAnimationStepParser parser = new DeploymentViewAnimationStepParser(); + + @Test + void test_parseExplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((DeploymentViewDslContext)null, tokens("animationStep")); + fail(); + } catch (Exception e) { + assertEquals("Expected: animationStep <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parseImplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((DeploymentViewAnimationDslContext)null, tokens()); + fail(); + } catch (Exception e) { + assertEquals("Expected: <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + DeploymentView view = workspace.getViews().createDeploymentView("key", "Description"); + + DeploymentViewAnimationDslContext context = new DeploymentViewAnimationDslContext(view); + IdentifiersRegister map = new IdentifiersRegister(); + context.setIdentifierRegister(map); + + try { + parser.parse(context, tokens("dn")); + fail(); + } catch (Exception e) { + assertEquals("The element/relationship \"dn\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoAnimatableElementsAreFound() { + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node"); + DeploymentView view = workspace.getViews().createDeploymentView("key", "Description"); + view.add(deploymentNode); + + DeploymentViewAnimationDslContext context = new DeploymentViewAnimationDslContext(view); + IdentifiersRegister map = new IdentifiersRegister(); + map.register("dn", deploymentNode); + context.setIdentifierRegister(map); + + try { + parser.parse(context, tokens("dn")); + fail(); + } catch (Exception e) { + assertEquals("No software system instances, container instances, or infrastructure nodes were found", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewContentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewContentParserTests.java new file mode 100644 index 000000000..4bf5d0f30 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewContentParserTests.java @@ -0,0 +1,576 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.DeploymentView; +import com.structurizr.view.RelationshipView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DeploymentViewContentParserTests extends AbstractTests { + + private DeploymentViewContentParser parser = new DeploymentViewContentParser(); + + @Test + void test_parseInclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + try { + parser.parseInclude(new DeploymentViewDslContext(null), tokens("include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: include <*|identifier|expression> [*|identifier|expression...]", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + try { + parser.parseInclude(new DeploymentViewDslContext(null), tokens("include", "user")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"user\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseInclude_AddsAllDeploymentNodesAndChildrenInTheDeploymentEnvironment_WhenTheWildcardIsSpecifiedAndTheViewHasNoSoftwareSystemScope() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + Container c1 = ss1.addContainer("C1", "Description", "Technology"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Container c2 = ss2.addContainer("C2", "Description", "Technology"); + + DeploymentNode dev1 = model.addDeploymentNode("Dev", "Dev 1", "Description", "Technology"); + DeploymentNode dev2 = dev1.addDeploymentNode("Dev 2", "Description", "Technology"); + InfrastructureNode dev3 = dev2.addInfrastructureNode("Dev 3", "Description", "Technology"); + ContainerInstance dev4 = dev2.add(c1); + ContainerInstance dev5 = dev2.add(c2); + + DeploymentNode live1 = model.addDeploymentNode("Live", "Live 1", "Description", "Technology"); + DeploymentNode live2 = live1.addDeploymentNode("Live 2", "Description", "Technology"); + InfrastructureNode live3 = live2.addInfrastructureNode("Live 3", "Description", "Technology"); + ContainerInstance live4 = live2.add(c1); + ContainerInstance live5 = live2.add(c2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*")); + + assertEquals(5, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live2))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live3))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live4))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live5))); + } + + @Test + void test_parseInclude_AddsAllDeploymentNodesAndChildrenInTheDeploymentEnvironment_WhenTheWildcardIsSpecifiedAndTheViewHasASoftwareSystemScope() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + Container c1 = ss1.addContainer("C1", "Description", "Technology"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Container c2 = ss2.addContainer("C2", "Description", "Technology"); + + DeploymentNode dev1 = model.addDeploymentNode("Dev", "Dev 1", "Description", "Technology"); + DeploymentNode dev2 = dev1.addDeploymentNode("Dev 2", "Description", "Technology"); + InfrastructureNode dev3 = dev2.addInfrastructureNode("Dev 3", "Description", "Technology"); + ContainerInstance dev4 = dev2.add(c1); + ContainerInstance dev5 = dev2.add(c2); + + DeploymentNode live1 = model.addDeploymentNode("Live", "Live 1", "Description", "Technology"); + DeploymentNode live2 = live1.addDeploymentNode("Live 2", "Description", "Technology"); + InfrastructureNode live3 = live2.addInfrastructureNode("Live 3", "Description", "Technology"); + ContainerInstance live4 = live2.add(c1); + ContainerInstance live5 = live2.add(c2); + + DeploymentView view = views.createDeploymentView(ss1, "key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*")); + + assertEquals(4, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live2))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live3))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live4))); + } + + @Test + void test_parseInclude_AddsTheSpecifiedElements() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + Container c1 = ss1.addContainer("C1", "Description", "Technology"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Container c2 = ss2.addContainer("C2", "Description", "Technology"); + CustomElement box1 = model.addCustomElement("Box 1"); + + DeploymentNode dev1 = model.addDeploymentNode("Dev", "Dev 1", "Description", "Technology"); + DeploymentNode dev2 = dev1.addDeploymentNode("Dev 2", "Description", "Technology"); + InfrastructureNode dev3 = dev2.addInfrastructureNode("Dev 3", "Description", "Technology"); + ContainerInstance dev4 = dev2.add(c1); + ContainerInstance dev5 = dev2.add(c2); + + DeploymentNode live1 = model.addDeploymentNode("Live", "Live 1", "Description", "Technology"); + DeploymentNode live2 = live1.addDeploymentNode("Live 2", "Description", "Technology"); + InfrastructureNode live3 = live2.addInfrastructureNode("Live 3", "Description", "Technology"); + ContainerInstance live4 = live2.add(c1); + ContainerInstance live5 = live2.add(c2); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", live1); + elements.register("box1", box1); + + DeploymentView view = views.createDeploymentView(ss1, "key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "element", "box1")); + + assertEquals(5, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live2))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live3))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(live4))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(box1))); + } + + @Test + void test_parseInclude_AddsTheElement_WhenTheElementIsAnInfrastructureNode() { + DeploymentNode dn = model.addDeploymentNode("Live", "DN", "Description", "Technology"); + InfrastructureNode in = dn.addInfrastructureNode("IN", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", in); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "element")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(in)); + } + + @Test + void test_parseInclude_AddsTheElement_WhenTheElementIsASoftwareSystemInstance() { + DeploymentNode dn = model.addDeploymentNode("Live", "DN", "Description", "Technology"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + SoftwareSystemInstance softwareSystemInstance = dn.add(softwareSystem); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", softwareSystemInstance); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "element")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(softwareSystemInstance)); + } + + @Test + void test_parseInclude_AddsTheElement_WhenTheElementIsAContainerInstance() { + DeploymentNode dn = model.addDeploymentNode("Live", "DN", "Description", "Technology"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + ContainerInstance containerInstance = dn.add(container); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", containerInstance); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "element")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(containerInstance)); + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + try { + parser.parseExclude(new DeploymentViewDslContext(null), tokens("exclude")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: exclude <identifier|expression> [identifier|expression...]", iae.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + try { + parser.parseExclude(new DeploymentViewDslContext(null), tokens("exclude", "user")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"user\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheSpecifiedElement() { + DeploymentNode dn = model.addDeploymentNode("Live", "DN", "Description", "Technology"); + InfrastructureNode in = dn.addInfrastructureNode("IN", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("element", in); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.addAllDeploymentNodes(); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(dn))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(in))); + + parser.parseExclude(context, tokens("exclude", "element")); + + assertEquals(1, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(dn))); + } + + @Test + void test_parseExclude_ExcludesReplicatedVersionsOfTheSpecifiedRelationship() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister identifersRegister = new IdentifiersRegister(); + identifersRegister.register("rel", rel); + context.setIdentifierRegister(identifersRegister); + + view.addDefaultElements(); + assertEquals(1, view.getRelationships().stream().map(RelationshipView::getRelationship).filter(r -> r.getLinkedRelationshipId().equals(rel.getId())).count()); + + parser.parseExclude(context, tokens("exclude", "rel")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipSourceElementDoesNotExistInTheModel() { + try { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss2", ss2); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==ss1 && relationship.destination==ss2")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"ss1\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipDestinationElementDoesNotExistInTheModel() { + try { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.add(dn); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss1", ss1); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==ss1 && relationship.destination==ss2")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"ss2\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSourceAndDestination() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.add(dn); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss1", ss1); + elements.register("ss2", ss2); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==ss1 && relationship.destination==ss2")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSource() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.add(dn); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss1", ss1); + elements.register("ss2", ss2); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==ss1")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithADestination() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.add(dn); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss1", ss1); + elements.register("ss2", ss2); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.destination==ss2")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesAllRelationshipsFromAView_WhenAnExpressionIsSpecifiedWithAWildcard() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + Relationship rel = ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + dn.add(ss1); + dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + view.add(dn); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("ss1", ss1); + elements.register("ss2", ss2); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship==*")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsAllDeploymentNodesWithTheSpecifiedTag() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + DeploymentNode dn1 = dn.addDeploymentNode("DN 1"); + dn1.addTags("Tag 1"); + SoftwareSystemInstance ss1Instance = dn1.add(ss1); + DeploymentNode dn2 = dn.addDeploymentNode("DN 2"); + SoftwareSystemInstance ss2Instance = dn2.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(3, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(dn1)); + assertNotNull(view.getElementView(ss1Instance)); + } + + @Test + void test_parseInclude_AddsAllInfrastructureNodesWithTheSpecifiedTag() { + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + InfrastructureNode in1 = dn.addInfrastructureNode("Infrastructure Node 1"); + in1.addTags("Tag 1"); + InfrastructureNode in2 = dn.addInfrastructureNode("Infrastructure Node 2"); + in2.addTags("Tag 2"); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(in1)); + } + + @Test + void test_parseInclude_AddsAllInstancesOfSoftwareSystemsWithTheSpecifiedTag() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + ss1.addTags("Tag 1"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + ss2.addTags("Tag 2"); + ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + SoftwareSystemInstance ss1Instance = dn.add(ss1); + SoftwareSystemInstance ss2Instance = dn.add(ss2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(ss1Instance)); + } + + @Test + void test_parseInclude_AddsAllSoftwareSystemInstancesWithTheSpecifiedTag() { + SoftwareSystem ss1 = model.addSoftwareSystem("SS1", "Description"); + SoftwareSystem ss2 = model.addSoftwareSystem("SS2", "Description"); + ss1.uses(ss2, "Uses"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + SoftwareSystemInstance ss1Instance = dn.add(ss1); + ss1Instance.addTags("Tag 1"); + SoftwareSystemInstance ss2Instance = dn.add(ss2); + ss2Instance.addTags("Tag 2"); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(ss1Instance)); + } + + @Test + void test_parseInclude_AddsAllInstancesOfContainersWithTheSpecifiedTag() { + SoftwareSystem ss = model.addSoftwareSystem("SS", "Description"); + Container c1 = ss.addContainer("Container 1"); + c1.addTags("Tag 1"); + Container c2 = ss.addContainer("Container 2"); + c2.addTags("Tag 2"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + ContainerInstance c1Instance = dn.add(c1); + ContainerInstance c2Instance = dn.add(c2); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(c1Instance)); + } + + @Test + void test_parseInclude_AddsAllContainerInstancesWithTheSpecifiedTag() { + SoftwareSystem ss = model.addSoftwareSystem("SS", "Description"); + Container c1 = ss.addContainer("Container 1"); + Container c2 = ss.addContainer("Container 2"); + + DeploymentNode dn = model.addDeploymentNode("Live", "Live", "Description", "Technology"); + ContainerInstance c1Instance = dn.add(c1); + c1Instance.addTags("Tag 1"); + ContainerInstance c2Instance = dn.add(c2); + c2Instance.addTags("Tag 2"); + + DeploymentView view = views.createDeploymentView("key", "Description"); + view.setEnvironment("Live"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(dn)); + assertNotNull(view.getElementView(c1Instance)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewExpressionParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewExpressionParserTests.java new file mode 100644 index 000000000..95eef0d04 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewExpressionParserTests.java @@ -0,0 +1,273 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class DeploymentViewExpressionParserTests extends AbstractTests { + + private DeploymentViewExpressionParser parser = new DeploymentViewExpressionParser(); + + @Test + void test_parseExpression_ThrowsAnException_WhenElementTypeIsNotSupported() { + try { + parser.parseExpression("element.type==Component", null); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element type of \"Component\" is not valid for this view", iae.getMessage()); + } + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsDeploymentNode() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==DeploymentNode", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(deploymentNode)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsInfrastructureNode() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==InfrastructureNode", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(infrastructureNode)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsSoftwareSystem() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==SoftwareSystem", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(softwareSystem)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsSoftwareSystemInstance() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==SoftwareSystemInstance", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(softwareSystemInstance)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsContainer() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==Container", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsContainerInstance() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==ContainerInstance", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(containerInstance)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementHasTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Infrastructure Node", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(infrastructureNode)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementDoesNotHaveTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag!=Infrastructure Node", context); + assertEquals(7, elements.size()); + assertFalse(elements.contains(infrastructureNode)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenBooleanAndUsed() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Element && element.type==Container", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenBooleanOrUsed() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.add(softwareSystem); + ContainerInstance containerInstance = deploymentNode.add(container); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Software System Instance || element.type==ContainerInstance", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(softwareSystemInstance)); + assertTrue(elements.contains(containerInstance)); + } + + @Test + void test_parseExpression_ReturnsSoftwareSystemInstanceDependencies_WhenASoftwareSystemExpressionIsUsed() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + a.uses(b, ""); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance softwareSystemInstanceA = deploymentNode.add(a); + SoftwareSystemInstance softwareSystemInstanceB = deploymentNode.add(b); + infrastructureNode.uses(softwareSystemInstanceA, "", ""); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister identifiersRegister = new IdentifiersRegister(); + identifiersRegister.register("a", a); + context.setIdentifierRegister(identifiersRegister); + + Set<ModelItem> elements = parser.parseExpression("->a->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(infrastructureNode)); + assertTrue(elements.contains(softwareSystemInstanceA)); + assertTrue(elements.contains(softwareSystemInstanceB)); + } + + @Test + void test_parseExpression_ReturnsContainerInstanceDependencies_WhenAContainerExpressionIsUsed() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container1 = softwareSystem.addContainer("Container 1"); + Container container2 = softwareSystem.addContainer("Container 2"); + container1.uses(container2, ""); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance containerInstance1 = deploymentNode.add(container1); + ContainerInstance containerInstance2 = deploymentNode.add(container2); + infrastructureNode.uses(containerInstance1, "", ""); + + DeploymentViewDslContext context = new DeploymentViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister identifiersRegister = new IdentifiersRegister(); + identifiersRegister.register("c1", container1); + context.setIdentifierRegister(identifiersRegister); + + Set<ModelItem> elements = parser.parseExpression("->c1->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(infrastructureNode)); + assertTrue(elements.contains(containerInstance1)); + assertTrue(elements.contains(containerInstance2)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewParserTests.java new file mode 100644 index 000000000..4d3e1ccf8 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DeploymentViewParserTests.java @@ -0,0 +1,209 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DeploymentView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DeploymentViewParserTests extends AbstractTests { + + private DeploymentViewParser parser = new DeploymentViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("deployment", "identifier", "environment", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: deployment <*|software system identifier> <environment> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIdentifierIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("deployment")); + fail(); + } catch (Exception e) { + assertEquals("Expected: deployment <*|software system identifier> <environment> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDeploymentEnvironmentIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("deployment", "*")); + fail(); + } catch (Exception e) { + assertEquals("Expected: deployment <*|software system identifier> <environment> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheEnvironmentDoesNotExist() { + DslContext context = context(); + + try { + parser.parse(context, tokens("deployment", "softwareSystem", "Live", "key")); + fail(); + } catch (Exception e) { + assertEquals("The environment \"Live\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotDefined() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + try { + parser.parse(context, tokens("deployment", "softwareSystem", "Live", "key")); + fail(); + } catch (Exception e) { + assertEquals("The software system \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystem() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("deployment", "softwareSystem", "Live", "key")); + fail(); + } catch (Exception e) { + assertEquals("The element \"softwareSystem\" is not a software system", e.getMessage()); + } + } + + @Test + void test_parse_CreatesADeploymentViewWithNoScope() { + context().getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + parser.parse(context(), tokens("deployment", "*", "Live")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("Deployment-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertNull(views.get(0).getSoftwareSystem()); + } + + @Test + void test_parse_CreatesADeploymentViewWithNoScopeAndKey_ViaEnvironmentName() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + parser.parse(context, tokens("deployment", "*", "Live", "key")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertNull(views.get(0).getSoftwareSystem()); + assertEquals("Live", views.get(0).getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentViewWithNoScopeAndKey_ViaEnvironmentReference() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("env", new DeploymentEnvironment("Live")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("deployment", "*", "env", "key")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertNull(views.get(0).getSoftwareSystem()); + assertEquals("Live", views.get(0).getEnvironment()); + } + + @Test + void test_parse_CreatesADeploymentViewWithNoScopeAndKeyAndDescription() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + parser.parse(context, tokens("deployment", "*", "Live", "key", "Description")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + assertNull(views.get(0).getSoftwareSystem()); + } + + @Test + void test_parse_CreatesADeploymentViewWithSoftwareSystemScope() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("deployment", "softwareSystem", "Live")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("Deployment-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getSoftwareSystem()); + } + + @Test + void test_parse_CreatesADeploymentViewWithSoftwareSystemScopeAndKey() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("deployment", "softwareSystem", "Live", "key")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getSoftwareSystem()); + } + + @Test + void test_parse_CreatesADeploymentViewWithSoftwareSystemScopeAndKeyAndDescription() { + DslContext context = context(); + context.getWorkspace().getModel().addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("deployment", "softwareSystem", "Live", "key", "Description")); + List<DeploymentView> views = new ArrayList<>(this.views.getDeploymentViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getSoftwareSystem()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DocsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DocsParserTests.java new file mode 100644 index 000000000..50660421e --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DocsParserTests.java @@ -0,0 +1,22 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DocsParserTests extends AbstractTests { + + private DocsParser parser = new DocsParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new WorkspaceDslContext(), null, tokens("docs", "path", "fqn", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !docs <path> <fqn>", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DslTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DslTests.java new file mode 100644 index 000000000..55e03d01e --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DslTests.java @@ -0,0 +1,1946 @@ +package com.structurizr.dsl; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Section; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class DslTests extends AbstractTests { + + @Test + void test_test() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getFeatures().configure(Features.FILE_SYSTEM, true); + parser.parse(new File("src/test/resources/dsl/test.dsl")); + + assertFalse(parser.getWorkspace().isEmpty()); + } + + @Test + void test_utf8() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/utf8.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + + assertEquals(1, model.getPeople().size()); + Person user = model.getPersonWithName("你好 Usér \uD83D\uDE42"); + assertNotNull(user); + } + + @Test + void test_gettingstarted() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/getting-started.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + ViewSet views = workspace.getViews(); + + assertEquals(1, model.getPeople().size()); + Person user = model.getPersonWithName("User"); + + assertEquals(1, workspace.getModel().getSoftwareSystems().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Software System"); + + assertEquals(1, workspace.getModel().getRelationships().size()); + Relationship relationship = user.getRelationships().iterator().next(); + assertEquals("Uses", relationship.getDescription()); + assertSame(softwareSystem, relationship.getDestination()); + + assertEquals(1, views.getViews().size()); + assertEquals(1, views.getSystemContextViews().size()); + SystemContextView view = views.getSystemContextViews().iterator().next(); + assertEquals("SystemContext-001", view.getKey()); + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + } + + @Test + void test_aws() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/amazon-web-services.dsl")); + + Workspace workspace = parser.getWorkspace(); + + assertEquals(13, workspace.getModel().getElements().size()); + assertEquals(0, workspace.getModel().getPeople().size()); + assertEquals(1, workspace.getModel().getSoftwareSystems().size()); + assertEquals(2, workspace.getModel().getSoftwareSystemWithName("Spring PetClinic").getContainers().size()); + assertEquals(1, workspace.getModel().getDeploymentNodes().size()); + assertEquals(6, workspace.getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).count()); + + assertEquals(4, workspace.getModel().getRelationships().size()); + + assertEquals(0, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(0, workspace.getViews().getSystemContextViews().size()); + assertEquals(0, workspace.getViews().getContainerViews().size()); + assertEquals(0, workspace.getViews().getComponentViews().size()); + assertEquals(0, workspace.getViews().getDynamicViews().size()); + assertEquals(1, workspace.getViews().getDeploymentViews().size()); + + DeploymentView deploymentView = workspace.getViews().getDeploymentViews().iterator().next(); + assertEquals(10, deploymentView.getElements().size()); + assertEquals(3, deploymentView.getRelationships().size()); + assertEquals(4, deploymentView.getAnimations().size()); + + assertEquals(3, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + } + + @Test + void test_awsLocal() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/amazon-web-services-local.dsl")); + + Workspace workspace = parser.getWorkspace(); + + assertEquals(13, workspace.getModel().getElements().size()); + assertEquals(0, workspace.getModel().getPeople().size()); + assertEquals(1, workspace.getModel().getSoftwareSystems().size()); + assertEquals(2, workspace.getModel().getSoftwareSystemWithName("Spring PetClinic").getContainers().size()); + assertEquals(1, workspace.getModel().getDeploymentNodes().size()); + assertEquals(6, workspace.getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).count()); + + assertEquals(4, workspace.getModel().getRelationships().size()); + + assertEquals(0, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(0, workspace.getViews().getSystemContextViews().size()); + assertEquals(0, workspace.getViews().getContainerViews().size()); + assertEquals(0, workspace.getViews().getComponentViews().size()); + assertEquals(0, workspace.getViews().getDynamicViews().size()); + assertEquals(1, workspace.getViews().getDeploymentViews().size()); + + DeploymentView deploymentView = workspace.getViews().getDeploymentViews().iterator().next(); + assertEquals(10, deploymentView.getElements().size()); + assertEquals(3, deploymentView.getRelationships().size()); + assertEquals(4, deploymentView.getAnimations().size()); + + assertEquals(3, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + } + + @Test + void test_bigbankplc() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/big-bank-plc.dsl")); + + Workspace workspace = parser.getWorkspace(); + + assertEquals(51, workspace.getModel().getElements().size()); + assertEquals(3, workspace.getModel().getPeople().size()); + assertEquals(4, workspace.getModel().getSoftwareSystems().size()); + assertEquals(5, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getContainers().size()); + assertEquals(6, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getContainerWithName("API Application").getComponents().size()); + assertEquals(5, workspace.getModel().getDeploymentNodes().size()); + assertEquals(21, workspace.getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).count()); + assertEquals(10, workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).count()); + assertEquals(0, workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).count()); + + assertEquals(42, workspace.getModel().getRelationships().size()); + + assertEquals(1, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(1, workspace.getViews().getSystemContextViews().size()); + assertEquals(1, workspace.getViews().getContainerViews().size()); + assertEquals(1, workspace.getViews().getComponentViews().size()); + assertEquals(1, workspace.getViews().getDynamicViews().size()); + assertEquals(2, workspace.getViews().getDeploymentViews().size()); + + assertEquals(7, workspace.getViews().getSystemLandscapeViews().iterator().next().getElements().size()); + assertEquals(9, workspace.getViews().getSystemLandscapeViews().iterator().next().getRelationships().size()); + + assertEquals(4, workspace.getViews().getSystemContextViews().iterator().next().getElements().size()); + assertEquals(4, workspace.getViews().getSystemContextViews().iterator().next().getRelationships().size()); + + assertEquals(8, workspace.getViews().getContainerViews().iterator().next().getElements().size()); + assertEquals(10, workspace.getViews().getContainerViews().iterator().next().getRelationships().size()); + + assertEquals(11, workspace.getViews().getComponentViews().iterator().next().getElements().size()); + assertEquals(13, workspace.getViews().getComponentViews().iterator().next().getRelationships().size()); + + assertEquals(4, workspace.getViews().getDynamicViews().iterator().next().getElements().size()); + assertEquals(6, workspace.getViews().getDynamicViews().iterator().next().getRelationships().size()); + + assertEquals(13, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("DevelopmentDeployment")).findFirst().get().getElements().size()); + assertEquals(4, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("DevelopmentDeployment")).findFirst().get().getRelationships().size()); + + assertEquals(20, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("LiveDeployment")).findFirst().get().getElements().size()); + assertEquals(7, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("LiveDeployment")).findFirst().get().getRelationships().size()); + + assertEquals(11, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + + assertEquals(0, workspace.getDocumentation().getSections().size()); + assertEquals(0, workspace.getDocumentation().getDecisions().size()); + } + + @Test + void test_bigbankplc_systemlandscape() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/big-bank-plc/system-landscape.dsl")); + + Workspace workspace = parser.getWorkspace(); + + assertEquals(7, workspace.getModel().getElements().size()); + assertEquals(3, workspace.getModel().getPeople().size()); + assertEquals(4, workspace.getModel().getSoftwareSystems().size()); + + assertEquals(9, workspace.getModel().getRelationships().size()); + + assertEquals(1, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(0, workspace.getViews().getSystemContextViews().size()); + assertEquals(0, workspace.getViews().getContainerViews().size()); + assertEquals(0, workspace.getViews().getComponentViews().size()); + assertEquals(0, workspace.getViews().getDynamicViews().size()); + assertEquals(0, workspace.getViews().getDeploymentViews().size()); + + assertEquals(7, workspace.getViews().getSystemLandscapeViews().iterator().next().getElements().size()); + assertEquals(9, workspace.getViews().getSystemLandscapeViews().iterator().next().getRelationships().size()); + + assertEquals(4, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + } + + @Test + void test_bigbankplc_internetbankingsystem() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/big-bank-plc/internet-banking-system.dsl")); + + Workspace workspace = parser.getWorkspace(); + + assertEquals(51, workspace.getModel().getElements().size()); + assertEquals(3, workspace.getModel().getPeople().size()); + assertEquals(4, workspace.getModel().getSoftwareSystems().size()); + assertEquals(5, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getContainers().size()); + assertEquals(6, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getContainerWithName("API Application").getComponents().size()); + assertEquals(5, workspace.getModel().getDeploymentNodes().size()); + assertEquals(21, workspace.getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).count()); + assertEquals(2, workspace.getModel().getElements().stream().filter(e -> e instanceof SoftwareSystemInstance).count()); + assertEquals(10, workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).count()); + assertEquals(0, workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).count()); + + assertEquals(42, workspace.getModel().getRelationships().size()); + + assertEquals(0, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(1, workspace.getViews().getSystemContextViews().size()); + assertEquals(1, workspace.getViews().getContainerViews().size()); + assertEquals(1, workspace.getViews().getComponentViews().size()); + assertEquals(1, workspace.getViews().getDynamicViews().size()); + assertEquals(2, workspace.getViews().getDeploymentViews().size()); + + assertEquals(4, workspace.getViews().getSystemContextViews().iterator().next().getElements().size()); + assertEquals(4, workspace.getViews().getSystemContextViews().iterator().next().getRelationships().size()); + + assertEquals(8, workspace.getViews().getContainerViews().iterator().next().getElements().size()); + assertEquals(10, workspace.getViews().getContainerViews().iterator().next().getRelationships().size()); + + assertEquals(11, workspace.getViews().getComponentViews().iterator().next().getElements().size()); + assertEquals(13, workspace.getViews().getComponentViews().iterator().next().getRelationships().size()); + + assertEquals(4, workspace.getViews().getDynamicViews().iterator().next().getElements().size()); + assertEquals(6, workspace.getViews().getDynamicViews().iterator().next().getRelationships().size()); + + assertEquals(13, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("DevelopmentDeployment")).findFirst().get().getElements().size()); + assertEquals(4, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("DevelopmentDeployment")).findFirst().get().getRelationships().size()); + + assertEquals(20, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("LiveDeployment")).findFirst().get().getElements().size()); + assertEquals(7, workspace.getViews().getDeploymentViews().stream().filter(v -> v.getKey().equals("LiveDeployment")).findFirst().get().getRelationships().size()); + + assertEquals(11, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(0, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + + assertEquals(4, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getDocumentation().getSections().size()); + assertEquals(1, workspace.getModel().getSoftwareSystemWithName("Internet Banking System").getDocumentation().getDecisions().size()); + } + + @Test + void test_frs() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/financial-risk-system.dsl")); + + + Workspace workspace = parser.getWorkspace(); + + assertEquals(9, workspace.getModel().getElements().size()); + assertEquals(2, workspace.getModel().getPeople().size()); + assertEquals(7, workspace.getModel().getSoftwareSystems().size()); + assertEquals(0, workspace.getModel().getDeploymentNodes().size()); + assertEquals(0, workspace.getModel().getElements().stream().filter(e -> e instanceof DeploymentNode).count()); + assertEquals(0, workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).count()); + assertEquals(0, workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).count()); + + assertEquals(9, workspace.getModel().getRelationships().size()); + + assertEquals(0, workspace.getViews().getSystemLandscapeViews().size()); + assertEquals(1, workspace.getViews().getSystemContextViews().size()); + assertEquals(0, workspace.getViews().getContainerViews().size()); + assertEquals(0, workspace.getViews().getComponentViews().size()); + assertEquals(0, workspace.getViews().getDynamicViews().size()); + assertEquals(0, workspace.getViews().getDeploymentViews().size()); + + assertEquals(9, workspace.getViews().getSystemContextViews().iterator().next().getElements().size()); + assertEquals(9, workspace.getViews().getSystemContextViews().iterator().next().getRelationships().size()); + + assertEquals(5, workspace.getViews().getConfiguration().getStyles().getElements().size()); + assertEquals(4, workspace.getViews().getConfiguration().getStyles().getRelationships().size()); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + } + + @Test + void test_includeLocalFile() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/include-file.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + + assertEquals(1, model.getSoftwareSystems().size()); + assertNotNull(model.getSoftwareSystemWithName("Software System")); + + assertEquals("", DslUtils.getDsl(workspace)); + } + + @Test + void test_includeLocalDirectory() throws Exception { + File hiddenFile = new File("src/test/resources/dsl/include/model/software-system/.DS_Store"); + if (hiddenFile.exists()) { + hiddenFile.delete(); + } + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/include-directory.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + + assertEquals(3, workspace.getModel().getSoftwareSystems().size()); + SoftwareSystem softwareSystem1 = model.getSoftwareSystemWithName("Software System 1"); + assertNotNull(softwareSystem1); + assertEquals(1, softwareSystem1.getDocumentation().getSections().size()); + + SoftwareSystem softwareSystem2 = model.getSoftwareSystemWithName("Software System 2"); + assertNotNull(softwareSystem2); + assertEquals(1, softwareSystem2.getDocumentation().getSections().size()); + + SoftwareSystem softwareSystem3 = model.getSoftwareSystemWithName("Software System 3"); + assertNotNull(softwareSystem3); + assertEquals(1, softwareSystem3.getDocumentation().getSections().size()); + + assertEquals("", DslUtils.getDsl(workspace)); + } + + @Test + void test_includeLocalDirectory_WhenThereAreHiddenFiles() throws Exception { + File hiddenFile = new File("src/test/resources/dsl/include/model/software-system/.DS_Store"); + if (hiddenFile.exists()) { + hiddenFile.delete(); + } + Files.writeString(hiddenFile.toPath(), "hello world"); + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/include-directory.dsl")); + } + + @Test + @Tag("IntegrationTest") + void test_includeUrl() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getHttpClient().allow(".*"); + parser.parse(new File("src/test/resources/dsl/include-url.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + + assertEquals(1, model.getSoftwareSystems().size()); + assertNotNull(model.getSoftwareSystemWithName("Software System")); + + assertEquals(""" + workspace { + + model { + !include https://raw.githubusercontent.com/structurizr/java/refs/heads/master/structurizr-dsl/src/test/resources/dsl/include/model.dsl + } + + }""", DslUtils.getDsl(workspace)); + } + + @Test + @Tag("IntegrationTest") + void test_extendWorkspaceFromJsonFile() throws Exception { + String dslFile = "src/test/resources/dsl/extend/extend-workspace-from-json-file.dsl"; + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File(dslFile)); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + assertEquals(CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.class, model.getImpliedRelationshipsStrategy().getClass()); + + assertEquals(1, model.getPeople().size()); + Person user = model.getPersonWithName("User"); + + assertEquals(3, workspace.getModel().getSoftwareSystems().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Software System 1"); + assertTrue(user.hasEfferentRelationshipWith(softwareSystem, "Uses")); + + assertEquals(2, softwareSystem.getContainers().size()); + assertNotNull(softwareSystem.getContainers().stream().filter(c -> c.getName().equals("Web Application 1")).findFirst()); + assertNotNull(softwareSystem.getContainers().stream().filter(c -> c.getName().equals("Web Application 2")).findFirst()); + + assertEquals("", DslUtils.getDsl(workspace)); + } + + @Test + @Tag("IntegrationTest") + void test_extendWorkspaceFromJsonUrl() throws Exception { + String dslFile = "src/test/resources/dsl/extend/extend-workspace-from-json-url.dsl"; + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getHttpClient().allow(".*"); + parser.parse(new File(dslFile)); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + assertEquals(CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.class, model.getImpliedRelationshipsStrategy().getClass()); + + assertEquals(1, model.getPeople().size()); + Person user = model.getPersonWithName("User"); + + assertEquals(3, workspace.getModel().getSoftwareSystems().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Software System 1"); + assertTrue(user.hasEfferentRelationshipWith(softwareSystem, "Uses")); + + assertEquals(2, softwareSystem.getContainers().size()); + assertNotNull(softwareSystem.getContainers().stream().filter(c -> c.getName().equals("Web Application 1")).findFirst()); + assertNotNull(softwareSystem.getContainers().stream().filter(c -> c.getName().equals("Web Application 2")).findFirst()); + + assertEquals(""" + workspace extends https://raw.githubusercontent.com/structurizr/dsl/master/src/test/dsl/extend/workspace.json { + + model { + // !element with DSL identifier + !element softwareSystem1 { + webapp1 = container "Web Application 1" + } + + // !element with canonical name + !element "SoftwareSystem://Software System 1" { + webapp2 = container "Web Application 2" + } + + user -> softwareSystem1 "Uses" + softwareSystem3.webapp -> softwareSystem3.db + } + + }""", DslUtils.getDsl(workspace)); + } + + @Test + void test_extendWorkspaceFromJsonFile_WhenRunningInRestrictedMode() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + + File dslFile = new File("src/test/resources/dsl/extend/extend-workspace-from-json-file.dsl"); + + try { + // this will fail, because the model import will be ignored + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Extending a file-based workspace is not permitted (feature structurizr.feature.dsl.filesystem is not enabled) at line 1 of " + dslFile.getAbsolutePath() + ": workspace extends workspace.json {", e.getMessage()); + } + } + + @ParameterizedTest + @Tag("IntegrationTest") + @ValueSource(strings = { "src/test/resources/dsl/extend/extend-workspace-from-dsl-file.dsl", "src/test/resources/dsl/extend/extend-workspace-from-dsl-url.dsl" }) + void test_extendWorkspaceFromDsl(String dslFile) throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getHttpClient().allow(".*"); + parser.parse(new File(dslFile)); + + Workspace workspace = parser.getWorkspace(); + assertEquals(IdentifierScope.Hierarchical, parser.getIdentifierScope()); + + Model model = workspace.getModel(); + assertEquals(CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy.class, model.getImpliedRelationshipsStrategy().getClass()); + + assertEquals(1, model.getPeople().size()); + Person user = model.getPersonWithName("User"); + + assertEquals(3, workspace.getModel().getSoftwareSystems().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Software System 1"); + assertTrue(user.hasEfferentRelationshipWith(softwareSystem, "Uses")); + + assertEquals(1, softwareSystem.getContainers().size()); + assertEquals("Web Application", softwareSystem.getContainers().iterator().next().getName()); + } + + @Test + void test_extendWorkspaceFromDslFile_WhenRunningInRestrictedMode() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + + File dslFile = new File("src/test/resources/dsl/extend/extend-workspace-from-dsl-file.dsl"); + try { + // this will fail, because the model import will be ignored + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Extending a file-based workspace is not permitted (feature structurizr.feature.dsl.filesystem is not enabled) at line 1 of " + dslFile.getAbsolutePath() +": workspace extends workspace.dsl {", e.getMessage()); + } + } + + @Test + void test_extendWorkspaceFromDslFiles() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/extend/4.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + ViewSet views = workspace.getViews(); + + assertEquals(3, model.getPeople().size()); + assertEquals(1, views.getViews().size()); + } + + @Test + void test_findElement() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/find-element.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getElementWithCanonicalName("InfrastructureNode://Live/Amazon Web Services/New deployment node/New infrastructure node")); + assertNotNull(parser.getWorkspace().getModel().getElementWithCanonicalName("InfrastructureNode://Live/Amazon Web Services/US-East-1/New deployment node 1/New infrastructure node 1")); + assertNotNull(parser.getWorkspace().getModel().getElementWithCanonicalName("InfrastructureNode://Live/Amazon Web Services/US-East-1/New deployment node 2/New infrastructure node 2")); + } + + @Test + void test_findElement_Hierarchical() throws Exception { + File dslFile = new File("src/test/resources/dsl/find-element-hierarchical.dsl"); + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + + Component component = parser.getWorkspace().getModel().getSoftwareSystemWithName("A").getContainerWithName("B").getComponentWithName("C"); + assertEquals("Value1", component.getProperties().get("Name1")); + assertEquals("Value2", component.getProperties().get("Name2")); + assertEquals("Value3", component.getProperties().get("Name3")); + } + + @Test + void test_findElements_InFlatGroup() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/find-elements-in-flat-group.dsl")); + + Person user = parser.getWorkspace().getModel().getPersonWithName("User"); + assertTrue(user.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("A"), "Uses")); + assertTrue(user.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("B"), "Uses")); + assertTrue(user.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("C"), "Uses")); + } + + @Test + void test_findElements_InNestedGroup() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/find-elements-in-nested-group.dsl")); + + Person user1 = parser.getWorkspace().getModel().getPersonWithName("User 1"); + assertTrue(user1.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("A"), "Uses")); + assertTrue(user1.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("B"), "Uses")); + assertTrue(user1.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("C"), "Uses")); + + Person user2 = parser.getWorkspace().getModel().getPersonWithName("User 2"); + assertTrue(user2.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("A"), "Uses")); + assertFalse(user2.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("B"), "Uses")); + assertFalse(user2.hasEfferentRelationshipWith(parser.getWorkspace().getModel().getSoftwareSystemWithName("C"), "Uses")); + } + + @Test + void test_parallel1() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/parallel1.dsl")); + + assertFalse(parser.getWorkspace().isEmpty()); + DynamicView view = parser.getWorkspace().getViews().getDynamicViews().iterator().next(); + List<RelationshipView> relationships = new ArrayList<>(view.getRelationships()); + assertEquals(4, relationships.size()); + assertEquals("1", relationships.get(0).getOrder()); + assertEquals("2", relationships.get(1).getOrder()); + assertEquals("3", relationships.get(2).getOrder()); + assertEquals("3", relationships.get(3).getOrder()); + } + + @Test + void test_parallel2() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/parallel2.dsl")); + + assertFalse(parser.getWorkspace().isEmpty()); + DynamicView view = parser.getWorkspace().getViews().getDynamicViews().iterator().next(); + List<RelationshipView> relationships = new ArrayList<>(view.getRelationships()); + assertEquals(4, relationships.size()); + assertEquals("1", relationships.get(0).getOrder()); + assertEquals("2", relationships.get(1).getOrder()); + assertEquals("2", relationships.get(2).getOrder()); + assertEquals("3", relationships.get(3).getOrder()); + } + + @Test + void test_parallel3() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/parallel3.dsl")); + + assertFalse(parser.getWorkspace().isEmpty()); + DynamicView view = parser.getWorkspace().getViews().getDynamicViews().iterator().next(); + List<RelationshipView> relationships = new ArrayList<>(view.getRelationships()); + assertEquals(4, relationships.size()); + assertEquals("1", relationships.get(0).getOrder()); + assertEquals("2", relationships.get(1).getOrder()); + assertEquals("2", relationships.get(2).getOrder()); + assertEquals("3", relationships.get(3).getOrder()); + } + + @Test + void test_groups() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/groups.dsl")); + + ContainerView containerView = parser.getWorkspace().getViews().getContainerViews().iterator().next(); + assertEquals(4, containerView.getElements().size()); + + DeploymentView deploymentView = parser.getWorkspace().getViews().getDeploymentViews().iterator().next(); + assertEquals(6, deploymentView.getElements().size()); + } + + @Test + void test_nested_groups() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/groups-nested.dsl")); + + SoftwareSystem a = parser.getWorkspace().getModel().getSoftwareSystemWithName("A"); + assertEquals("Organisation/Department A", a.getGroup()); + + Container aApi = a.getContainerWithName("A API"); + assertEquals("Capability 1/Service A", aApi.getGroup()); + + Component aApiEndpoint = aApi.getComponentWithName("API Endpoint"); + assertEquals("a-api.jar/API Layer", aApiEndpoint.getGroup()); + + Component aApiRepository = aApi.getComponentWithName("Repository"); + assertEquals("a-api.jar/Data Layer", aApiRepository.getGroup()); + + Container aDatabase = a.getContainerWithName("A Database"); + assertEquals("Capability 1/Service A", aDatabase.getGroup()); + + Container bApi = a.getContainerWithName("B API"); + assertEquals("Capability 1/Service B", bApi.getGroup()); + + Container bDatabase = a.getContainerWithName("B Database"); + assertEquals("Capability 1/Service B", bDatabase.getGroup()); + + SoftwareSystem b = parser.getWorkspace().getModel().getSoftwareSystemWithName("B"); + assertEquals("Organisation/Department B", b.getGroup()); + + SoftwareSystem c = parser.getWorkspace().getModel().getSoftwareSystemWithName("C"); + assertEquals("Organisation", c.getGroup()); + } + + @Test + void test_hierarchicalIdentifiers() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/hierarchical-identifiers.dsl")); + + Workspace workspace = parser.getWorkspace(); + assertEquals(0, workspace.getModel().getSoftwareSystemWithName("B").getRelationships().size()); + } + + @Test + void test_hierarchicalIdentifiersWhenUnassigned() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/hierarchical-identifiers-when-unassigned.dsl")); + + Workspace workspace = parser.getWorkspace(); + IdentifiersRegister identifiersRegister = parser.getIdentifiersRegister(); + + assertEquals(6, identifiersRegister.getElementIdentifiers().size()); + for (String identifier : identifiersRegister.getElementIdentifiers()) { + assertFalse(identifier.startsWith("null")); + } + } + + @Test + void test_hierarchicalIdentifiersAndDeploymentNodes() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-1.dsl")); + } + + @Test + void test_hierarchicalIdentifiersAndDeploymentNodes_WhenSoftwareSystemNameClashes() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-2.dsl")); + } + + @Test + void test_hierarchicalIdentifiersAndDeploymentNodes_WhenSoftwareContainerClashes() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-3.dsl")); + } + + @Test + void test_plugin_ThrowsAnException_WhenPluginsAreNotEnabled() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getFeatures().disable(Features.PLUGINS); + parser.parse(new File("src/test/resources/dsl/plugin-without-parameters.dsl")); + fail(); + } catch (Exception e) { + System.out.println(e.getMessage()); + assertTrue(e.getMessage().startsWith("!plugin is not permitted (feature structurizr.feature.dsl.plugins is not enabled)")); + } + } + + @Test + void test_pluginWithoutParameters() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/plugin-without-parameters.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Java")); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_pluginWithParameters() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/plugin-with-parameters.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Java")); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_script_ThrowsAnException_WhenScriptsAreNotEnabled() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getFeatures().disable(Features.SCRIPTS); + parser.parse(new File("src/test/resources/dsl/script-external.dsl")); + fail(); + } catch (Exception e) { + System.out.println(e.getMessage()); + assertTrue(e.getMessage().startsWith("!script is not permitted (feature structurizr.feature.dsl.scripts is not enabled)")); + } + } + + @Test + void test_externalScript() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/script-external.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Groovy")); + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Kotlin")); + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Ruby")); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_externalScriptWithParameters() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/script-external-with-parameters.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Groovy")); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_inlineScript() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/script-inline.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Groovy")); + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Kotlin")); + assertNotNull(parser.getWorkspace().getModel().getPersonWithName("Ruby")); + + assertTrue(parser.getWorkspace().getModel().getPersonWithName("User").hasTag("Groovy")); + assertTrue(parser.getWorkspace().getModel().getPersonWithName("User").getRelationships().iterator().next().hasTag("Groovy")); + assertEquals("Groovy", parser.getWorkspace().getViews().getSystemLandscapeViews().iterator().next().getDescription()); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_docs() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/docs/workspace.dsl")); + + SoftwareSystem softwareSystem = parser.getWorkspace().getModel().getSoftwareSystemWithName("Software System"); + Container container = softwareSystem.getContainerWithName("Container"); + Component component = container.getComponentWithName("Component"); + + Collection<Section> sections = parser.getWorkspace().getDocumentation().getSections(); + assertEquals(1, sections.size()); + assertEquals(""" + ## Workspace + + Content...""", sections.iterator().next().getContent()); + + sections = softwareSystem.getDocumentation().getSections(); + assertEquals(1, sections.size()); + assertEquals(""" + ## Software System + + Content...""", sections.iterator().next().getContent()); + + sections = container.getDocumentation().getSections(); + assertEquals(1, sections.size()); + assertEquals(""" + ## Container + + Content...""", sections.iterator().next().getContent()); + + sections = component.getDocumentation().getSections(); + assertEquals(1, sections.size()); + assertEquals(""" + ## Component + + Content...""", sections.iterator().next().getContent()); + + assertEquals(1, component.getDocumentation().getSections().size()); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_docs_ThrowsAnException_WhenTheParserIsInRestrictedMode() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + parser.parse(new File("src/test/resources/dsl/docs/workspace.dsl")); + fail(); + } catch (Exception e) { + System.out.println(e.getMessage()); + assertTrue(e.getMessage().startsWith("!docs is not permitted (feature structurizr.feature.dsl.documentation is not enabled)")); + } + } + + @Test + void test_decisions() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/decisions/workspace.dsl")); + + SoftwareSystem softwareSystem = parser.getWorkspace().getModel().getSoftwareSystemWithName("Software System"); + Container container = softwareSystem.getContainerWithName("Container"); + Component component = container.getComponentWithName("Component"); + + // adrtools decisions + assertEquals(10, parser.getWorkspace().getDocumentation().getDecisions().size()); + assertEquals(10, softwareSystem.getDocumentation().getDecisions().size()); + + // madr decisions + assertEquals(19, container.getDocumentation().getDecisions().size()); + + // log4brains decisions + assertEquals(4, component.getDocumentation().getDecisions().size()); + + // check source isn't retained + assertEquals("", DslUtils.getDsl(parser.getWorkspace())); + } + + @Test + void test_decisions_ThrowsAnException_WhenTheParserIsInRestrictedMode() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + parser.parse(new File("src/test/resources/dsl/decisions/workspace.dsl")); + fail(); + } catch (Exception e) { + System.out.println(e.getMessage()); + assertTrue(e.getMessage().startsWith("!adrs is not permitted (feature structurizr.feature.dsl.decisions is not enabled)")); + } + } + + @Test + void test_this() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/this.dsl")); + } + + @Test + void test_workspaceWithControlCharacters() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/workspace-with-bom.dsl")); + } + + @Test + void test_excludeRelationships() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/exclude-relationships.dsl")); + } + + @Test + void test_unexpectedTokensBeforeWorkspace() { + File dslFile = new File("src/test/resources/dsl/unexpected-tokens-before-workspace.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Unexpected tokens (expected: workspace) at line 1 of " + dslFile.getAbsolutePath() + ": hello world", e.getMessage()); + } + } + + @Test + void test_unexpectedTokensAfterWorkspace() { + File dslFile = new File("src/test/resources/dsl/unexpected-tokens-after-workspace.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Unexpected tokens at line 4 of " + dslFile.getAbsolutePath() + ": hello world", e.getMessage()); + } + } + + @Test + void test_unexpectedTokensInWorkspace() { + File dslFile = new File("src/test/resources/dsl/unexpected-tokens-in-workspace.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Unexpected tokens (expected: name, description, properties, !docs, !decisions, !identifiers, !impliedRelationships, model, views, configuration) at line 3 of " + dslFile.getAbsolutePath() + ": softwareSystem \"Name\"", e.getMessage()); + } + } + + @Test + void test_urlNotPermittedInGroup() { + File dslFile = new File("src/test/resources/dsl/group-url.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Unexpected tokens (expected: !docs, !decisions, group, container, description, tags, url, properties, perspectives, ->) at line 6 of " + dslFile.getAbsolutePath() + ": url \"https://example.com\"", e.getMessage()); + } + } + + @Test + void test_multipleWorkspaceTokens_ThrowsAnException() { + File dslFile = new File("src/test/resources/dsl/multiple-workspace-tokens.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Multiple workspaces are not permitted in a DSL definition at line 9 of " + dslFile.getAbsolutePath() + ": workspace {", e.getMessage()); + } + } + + @Test + void test_multipleModelTokens_ThrowsAnException() { + File dslFile = new File("src/test/resources/dsl/multiple-model-tokens.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Multiple models are not permitted in a DSL definition at line 7 of " + dslFile.getAbsolutePath() + ": model {", e.getMessage()); + } + } + + @Test + void test_multipleViewTokens_ThrowsAnException() { + File dslFile = new File("src/test/resources/dsl/multiple-view-tokens.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Multiple view sets are not permitted in a DSL definition at line 13 of " + dslFile.getAbsolutePath() + ": views {", e.getMessage()); + } + } + + @Test + void test_dynamicViewWithExplicitRelationships() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/dynamic-view-with-explicit-relationships.dsl")); + } + + @Test + void test_dynamicViewWithCustomElements() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/dynamic-view-with-custom-elements.dsl")); + } + + @Test + void test_dynamicViewWithExplicitOrdering() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/dynamic-view-with-explicit-ordering.dsl")); + DynamicView view = parser.getWorkspace().getViews().getDynamicViews().iterator().next(); + Set<RelationshipView> relationships = view.getRelationships(); + Iterator<RelationshipView> it = relationships.iterator(); + assertEquals("2", it.next().getOrder()); + assertEquals("3", it.next().getOrder()); + } + + @Test + void test_workspaceProperties() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/workspace-properties.dsl")); + + assertEquals("false", parser.getWorkspace().getProperties().get("structurizr.dslEditor")); + } + + @Test + void test_viewsWithoutKeys() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/views-without-keys.dsl")); + + assertTrue(parser.getWorkspace().getViews().getSystemLandscapeViews().stream().anyMatch(view -> view.getKey().equals("SystemLandscape-001"))); + assertTrue(parser.getWorkspace().getViews().getSystemLandscapeViews().stream().anyMatch(view -> view.getKey().equals("SystemLandscape-002"))); + assertTrue(parser.getWorkspace().getViews().getSystemLandscapeViews().stream().anyMatch(view -> view.getKey().equals("SystemLandscape-003"))); + } + + @Test + void test_identifiers() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/identifiers.dsl")); + + Workspace workspace = parser.getWorkspace(); + Model model = workspace.getModel(); + + Person user = model.getPersonWithName("User"); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Software System"); + Container container = softwareSystem.getContainerWithName("Container"); + Relationship relationship = user.getEfferentRelationshipWith(container); + Relationship impliedRelationship = user.getEfferentRelationshipWith(softwareSystem); + + IdentifiersRegister register = parser.getIdentifiersRegister(); + assertEquals("user", register.findIdentifier(user)); + assertEquals("softwareSystem", register.findIdentifier(softwareSystem)); + assertEquals("softwareSystem.container", register.findIdentifier(container)); + assertEquals("rel", register.findIdentifier(relationship)); + + assertSame(user, register.getElement("user")); + assertSame(softwareSystem, register.getElement("softwareSystem")); + assertSame(softwareSystem, register.getElement("softwaresystem")); + assertSame(container, register.getElement("softwareSystem.container")); + assertSame(container, register.getElement("softwaresystem.container")); + assertSame(relationship, register.getRelationship("rel")); + + assertEquals("user", user.getProperties().get("structurizr.dsl.identifier")); + assertEquals("softwareSystem", softwareSystem.getProperties().get("structurizr.dsl.identifier")); + assertEquals("softwareSystem.container", container.getProperties().get("structurizr.dsl.identifier")); + assertEquals("rel", relationship.getProperties().get("structurizr.dsl.identifier")); + assertNull(impliedRelationship.getProperties().get("structurizr.dsl.identifier")); + } + + @Test + void test_relationshipWithoutIdentifier() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/relationship-without-identifier.dsl")); + + Workspace workspace = parser.getWorkspace(); + IdentifiersRegister register = parser.getIdentifiersRegister(); + assertEquals(1, workspace.getModel().getRelationships().size()); + Relationship relationship = workspace.getModel().getRelationships().iterator().next(); + + assertTrue(register.findIdentifier(relationship).matches("[\\w]{8}-[\\w]{4}-[\\w]{4}-[\\w]{4}-[\\w]{12}")); + assertNull(relationship.getProperties().get("structurizr.dsl.identifier")); // identifier is not included in model + } + + @Test + void test_imageViews_ViaFiles() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getFeatures().configure(Features.FILE_SYSTEM, true); + parser.parse(new File("src/test/resources/dsl/image-views/workspace-via-file.dsl")); + + Workspace workspace = parser.getWorkspace(); + assertEquals(5, workspace.getViews().getImageViews().size()); + + ImageView plantumlView = (ImageView)workspace.getViews().getViewWithKey("plantuml"); + assertEquals("diagram.puml", plantumlView.getTitle()); + assertEquals("http://localhost:7777/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", plantumlView.getContent()); + assertEquals("image/svg+xml", plantumlView.getContentType()); + + ImageView mermaidView = (ImageView)workspace.getViews().getViewWithKey("mermaid"); + assertEquals("diagram.mmd", mermaidView.getTitle()); + assertEquals("http://localhost:8888/svg/Zmxvd2NoYXJ0IFRECiAgICBTdGFydCAtLT4gU3RvcA==", mermaidView.getContent()); + assertEquals("image/svg+xml", mermaidView.getContentType()); + + ImageView krokiView = (ImageView)workspace.getViews().getViewWithKey("kroki"); + assertEquals("diagram.dot", krokiView.getTitle()); + assertEquals("http://localhost:9999/graphviz/png/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", krokiView.getContent()); + assertEquals("image/png", krokiView.getContentType()); + + ImageView pngView = (ImageView)workspace.getViews().getViewWithKey("png"); + assertEquals("image.png", pngView.getTitle()); + assertEquals("data:image/png;base64,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", pngView.getContent()); + assertEquals("image/png", pngView.getContentType()); + + ImageView svgView = (ImageView)workspace.getViews().getViewWithKey("svg"); + assertEquals("image.svg", svgView.getTitle()); + assertEquals("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXMtYXNjaWkiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBjb250ZW50U3R5bGVUeXBlPSJ0ZXh0L2NzcyIgaGVpZ2h0PSIxMjBweCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIgc3R5bGU9IndpZHRoOjExM3B4O2hlaWdodDoxMjBweDtiYWNrZ3JvdW5kOiNGRkZGRkY7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxMTMgMTIwIiB3aWR0aD0iMTEzcHgiIHpvb21BbmRQYW49Im1hZ25pZnkiPjxkZWZzLz48Zz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjAuNTtzdHJva2UtZGFzaGFycmF5OjUuMCw1LjA7IiB4MT0iMjYiIHgyPSIyNiIgeTE9IjM2LjI5NjkiIHkyPSI4NS40Mjk3Ii8+PGxpbmUgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7c3Ryb2tlLWRhc2hhcnJheTo1LjAsNS4wOyIgeDE9IjgyIiB4Mj0iODIiIHkxPSIzNi4yOTY5IiB5Mj0iODUuNDI5NyIvPjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDMiIHg9IjUiIHk9IjUiLz48dGV4dCBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgbGVuZ3RoQWRqdXN0PSJzcGFjaW5nIiB0ZXh0TGVuZ3RoPSIyOSIgeD0iMTIiIHk9IjI0Ljk5NTEiPkJvYjwvdGV4dD48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQzIiB4PSI1IiB5PSI4NC40Mjk3Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMjkiIHg9IjEyIiB5PSIxMDQuNDI0OCI+Qm9iPC90ZXh0PjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDkiIHg9IjU4IiB5PSI1Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMzUiIHg9IjY1IiB5PSIyNC45OTUxIj5BbGljZTwvdGV4dD48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQ5IiB4PSI1OCIgeT0iODQuNDI5NyIvPjx0ZXh0IGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0IiBsZW5ndGhBZGp1c3Q9InNwYWNpbmciIHRleHRMZW5ndGg9IjM1IiB4PSI2NSIgeT0iMTA0LjQyNDgiPkFsaWNlPC90ZXh0Pjxwb2x5Z29uIGZpbGw9IiMxODE4MTgiIHBvaW50cz0iNzAuNSw2My40Mjk3LDgwLjUsNjcuNDI5Nyw3MC41LDcxLjQyOTcsNzQuNSw2Ny40Mjk3IiBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjEuMDsiLz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjEuMDsiIHgxPSIyNi41IiB4Mj0iNzYuNSIgeTE9IjY3LjQyOTciIHkyPSI2Ny40Mjk3Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTMiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMzAiIHg9IjMzLjUiIHk9IjYyLjM2MzgiPmhlbGxvPC90ZXh0PjwhLS1TUkM9W1N5ZkZLajJyS3QzQ29LbkVMUjFJbzRaRG9TYTcwMDAwXS0tPjwvZz48L3N2Zz4=", svgView.getContent()); + assertEquals("image/svg+xml", svgView.getContentType()); + + // check that source isn't retained + assertEquals("", DslUtils.getDsl(workspace)); + } + + @Test + @Tag("IntegrationTest") + void test_imageViews_ViaUrls() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.getHttpClient().allow(".*"); + parser.parse(new File("src/test/resources/dsl/image-views/workspace-via-url.dsl")); + + Workspace workspace = parser.getWorkspace(); + assertEquals(5, workspace.getViews().getImageViews().size()); + + ImageView plantumlView = (ImageView)workspace.getViews().getViewWithKey("plantuml"); + assertEquals("diagram.puml", plantumlView.getTitle()); + assertEquals("http://localhost:7777/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", plantumlView.getContent()); + assertEquals("image/svg+xml", plantumlView.getContentType()); + + ImageView mermaidView = (ImageView)workspace.getViews().getViewWithKey("mermaid"); + assertEquals("diagram.mmd", mermaidView.getTitle()); + assertEquals("http://localhost:8888/svg/Zmxvd2NoYXJ0IFRECiAgICBTdGFydCAtLT4gU3RvcA==", mermaidView.getContent()); + assertEquals("image/svg+xml", mermaidView.getContentType()); + + ImageView krokiView = (ImageView)workspace.getViews().getViewWithKey("kroki"); + assertEquals("diagram.dot", krokiView.getTitle()); + assertEquals("http://localhost:9999/graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", krokiView.getContent()); + assertEquals("image/svg+xml", krokiView.getContentType()); + + ImageView pngView = (ImageView)workspace.getViews().getViewWithKey("png"); + assertEquals("image.png", pngView.getTitle()); + assertEquals("https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.png", pngView.getContent()); + assertEquals("image/png", pngView.getContentType()); + + ImageView svgView = (ImageView)workspace.getViews().getViewWithKey("svg"); + assertEquals("image.svg", svgView.getTitle()); + assertEquals("https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.svg", svgView.getContent()); + assertEquals("image/svg+xml", svgView.getContentType()); + + // check that source is retained + assertEquals(""" + workspace { + + views { + properties { + "plantuml.url" "http://localhost:7777" + "plantuml.format" "svg" + "mermaid.url" "http://localhost:8888" + "mermaid.format" "svg" + "mermaid.compress" "false" + "kroki.url" "http://localhost:9999" + "kroki.format" "svg" + } + + image * "plantuml" { + plantuml https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.puml + } + + image * "mermaid" { + mermaid https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.mmd + } + + image * "kroki" { + kroki graphviz https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.dot + } + + image * "png" { + image https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.png + } + + image * "svg" { + image https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.svg + } + } + + }""", DslUtils.getDsl(workspace)); + } + + @Test + void test_imageViews_ViaSource() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/image-views/workspace-via-source.dsl")); + + Workspace workspace = parser.getWorkspace(); + assertEquals(3, workspace.getViews().getImageViews().size()); + + ImageView plantumlView = (ImageView)workspace.getViews().getViewWithKey("plantuml"); + assertNull(plantumlView.getTitle()); + assertEquals("http://localhost:7777/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", plantumlView.getContent()); + assertEquals("image/svg+xml", plantumlView.getContentType()); + + ImageView mermaidView = (ImageView)workspace.getViews().getViewWithKey("mermaid"); + assertNull(mermaidView.getTitle()); + assertEquals("http://localhost:8888/svg/Zmxvd2NoYXJ0IFRECiAgICBTdGFydCAtLT4gU3RvcA==", mermaidView.getContent()); + assertEquals("image/svg+xml", mermaidView.getContentType()); + + ImageView krokiView = (ImageView)workspace.getViews().getViewWithKey("kroki"); + assertNull(krokiView.getTitle()); + assertEquals("http://localhost:9999/graphviz/png/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", krokiView.getContent()); + assertEquals("image/png", krokiView.getContentType()); + } + + @Test + void test_EmptyDeploymentEnvironment() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/deployment-environment-empty.dsl")); + + assertEquals(1, parser.getWorkspace().getModel().getDeploymentNodes().size()); + } + + @Test + void test_MultiLineSupport() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/multi-line.dsl")); + + assertNotNull(parser.getWorkspace().getModel().getSoftwareSystemWithName("Software System")); + } + + @Test + void test_MultiLineWithError() { + File dslFile = new File("src/test/resources/dsl/multi-line-with-error.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + // check that the error message includes the original line number + assertEquals("Unexpected tokens (expected: !docs, !decisions, group, container, description, tags, url, properties, perspectives, ->) at line 8 of " + dslFile.getAbsolutePath() + ": component \"Component\" // components not permitted inside software systems", e.getMessage()); + } + } + + @Test + void test_RelationshipAlreadyExists() { + File dslFile = new File("src/test/resources/dsl/relationship-already-exists.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("A relationship between \"SoftwareSystem://B\" and \"SoftwareSystem://A\" already exists at line 10 of " + dslFile.getAbsolutePath() + ": b -> a", e.getMessage()); + } + } + + @Test + void test_ExcludeImpliedRelationship() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/exclude-implied-relationship.dsl")); + + // check the system landscape view doesn't include any relationships + assertEquals(0, parser.getWorkspace().getViews().getSystemLandscapeViews().iterator().next().getRelationships().size()); + } + + @Test + void test_IncludeImpliedRelationship() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/include-implied-relationship.dsl")); + + // check the system landscape view includes a relationship + assertEquals(1, parser.getWorkspace().getViews().getSystemLandscapeViews().iterator().next().getRelationships().size()); + } + + @Test + void test_GroupWithoutBrace() throws Exception { + File dslFile = new File("src/test/resources/dsl/group-without-brace.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Expected: group <name> { at line 4 of " + dslFile.getAbsolutePath() + ": group \"Name\"", e.getMessage()); + } + } + + @Test + void test_ISO8859Encoding() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setCharacterEncoding(StandardCharsets.ISO_8859_1); + parser.parse(new File("src/test/resources/dsl/iso-8859.dsl")); + assertNotNull(parser.getWorkspace().getModel().getSoftwareSystemWithName("Namé")); + } + + @Test + void test_ScriptInDynamicView() throws Exception { + File dslFile = new File("src/test/resources/dsl/script-in-dynamic-view.dsl"); + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + } + + @Test + void test_Enterprise() { + File dslFile = new File("src/test/resources/dsl/enterprise.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("The enterprise keyword was previously deprecated, and has now been removed - please use group instead (https://docs.structurizr.com/dsl/language#group) at line 4 of " + dslFile.getAbsolutePath() + ": enterprise \"Name\" {", e.getMessage()); + } + } + + @Test + void test_Constant() { + File dslFile = new File("src/test/resources/dsl/constant.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("!constant was previously deprecated, and has now been removed - please use !const or !var instead at line 3 of " + dslFile.getAbsolutePath() + ": !constant NAME VALUE", e.getMessage()); + } + } + + @Test + void test_ConstantsAndVariablesFromWorkspaceExtension() throws Exception { + File dslFile = new File("src/test/resources/dsl/constants-and-variables-from-workspace-extension-child.dsl"); + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + + SoftwareSystem softwareSystem = parser.getWorkspace().getModel().getSoftwareSystemWithName("Name"); + assertNotNull(softwareSystem); + assertEquals("Description", softwareSystem.getDescription()); + } + + @Test + void test_UnbalancedCurlyBraces() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(""" + workspace { + model { + person "User" + } + """); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("Unexpected end of DSL content - are one or more closing curly braces missing?", e.getMessage()); + } + } + + @Test + void test_Const() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(""" + workspace { + !const name value1 + !const name value2 + } + """); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("A constant/variable \"name\" already exists at line 3: !const name value2", e.getMessage()); + } + } + + @Test + void test_Var_CannotOverrideConst() { + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(""" + workspace { + !const name value1 + !var name value2 + } + """); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("A constant \"name\" already exists at line 3: !var name value2", e.getMessage()); + } + } + + @Test + void springPetClinic() throws Exception { + String springPetClinicHome = System.getenv().getOrDefault("SPRING_PETCLINIC_HOME", ""); + System.out.println(springPetClinicHome); + if (!StringUtils.isNullOrEmpty(springPetClinicHome)) { + System.out.println("Running Spring PetClinic example..."); + + try { + File workspaceFile = new File("src/test/resources/dsl/spring-petclinic/workspace.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + parser.parse(workspaceFile); + fail(); + } catch (Exception e) { + System.out.println(e.getMessage()); + assertTrue(e.getMessage().startsWith("!components is not permitted (feature structurizr.feature.dsl.componentfinder is not enabled)")); + } + + File workspaceFile = new File("src/test/resources/dsl/spring-petclinic/workspace.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(workspaceFile); + + Person clinicEmployee = (Person)parser.getIdentifiersRegister().getElement("clinicEmployee"); + + Container webApplication = (Container)parser.getIdentifiersRegister().getElement("springPetClinic.webApplication"); + Container relationalDatabaseSchema = (Container)parser.getIdentifiersRegister().getElement("springPetClinic.relationalDatabaseSchema"); + + assertEquals(7, webApplication.getComponents().size()); + + Component welcomeController = webApplication.getComponentWithName("Welcome Controller"); + assertNotNull(welcomeController); + assertEquals("org.springframework.samples.petclinic.system.WelcomeController", welcomeController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/system/WelcomeController.java", welcomeController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/system/WelcomeController.java", welcomeController.getUrl()); + assertSame(welcomeController, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.welcomecontroller")); + assertEquals("Web Controllers", welcomeController.getGroup()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(welcomeController)); + + Component ownerController = webApplication.getComponentWithName("Owner Controller"); + assertNotNull(ownerController); + assertEquals("org.springframework.samples.petclinic.owner.OwnerController", ownerController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/OwnerController.java", ownerController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java", ownerController.getUrl()); + assertSame(ownerController, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.ownerController")); + assertEquals("Web Controllers", ownerController.getGroup()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(ownerController)); + + Component petController = webApplication.getComponentWithName("Pet Controller"); + assertNotNull(petController); + assertEquals("org.springframework.samples.petclinic.owner.PetController", petController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/PetController.java", petController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/PetController.java", petController.getUrl()); + assertSame(petController, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.petcontroller")); + assertEquals("Web Controllers", petController.getGroup()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(petController)); + + Component vetController = webApplication.getComponentWithName("Vet Controller"); + assertNotNull(vetController); + assertEquals("org.springframework.samples.petclinic.vet.VetController", vetController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/vet/VetController.java", vetController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/vet/VetController.java", vetController.getUrl()); + assertSame(vetController, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.vetcontroller")); + assertEquals("Web Controllers", vetController.getGroup()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(vetController)); + + Component visitController = webApplication.getComponentWithName("Visit Controller"); + assertNotNull(visitController); + assertEquals("org.springframework.samples.petclinic.owner.VisitController", visitController.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/VisitController.java", visitController.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java", visitController.getUrl()); + assertSame(visitController, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.visitcontroller")); + assertEquals("Web Controllers", visitController.getGroup()); + assertTrue(clinicEmployee.hasEfferentRelationshipWith(visitController)); + + Component ownerRepository = webApplication.getComponentWithName("Owner Repository"); + assertNotNull(ownerRepository); + assertEquals("Repository class for Owner domain objects All method names are compliant with Spring Data naming conventions so this interface can easily be extended for Spring Data.", ownerRepository.getDescription()); + assertEquals("org.springframework.samples.petclinic.owner.OwnerRepository", ownerRepository.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/owner/OwnerRepository.java", ownerRepository.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java", ownerRepository.getUrl()); + assertSame(ownerRepository, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.ownerrepository")); + assertEquals("Data Repositories", ownerRepository.getGroup()); + assertTrue(ownerRepository.hasEfferentRelationshipWith(relationalDatabaseSchema, "Reads from and writes to")); + + Component vetRepository = webApplication.getComponentWithName("Vet Repository"); + assertNotNull(vetRepository); + assertEquals("Repository class for Vet domain objects All method names are compliant with Spring Data naming conventions so this interface can easily be extended for Spring Data.", vetRepository.getDescription()); + assertEquals("org.springframework.samples.petclinic.vet.VetRepository", vetRepository.getProperties().get("component.type")); + assertEquals("org/springframework/samples/petclinic/vet/VetRepository.java", vetRepository.getProperties().get("component.src")); + assertEquals("https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java", vetRepository.getUrl()); + assertSame(vetRepository, parser.getIdentifiersRegister().getElement("springPetClinic.webApplication.vetrepository")); + assertEquals("Data Repositories", vetRepository.getGroup()); + assertTrue(vetRepository.hasEfferentRelationshipWith(relationalDatabaseSchema, "Reads from and writes to")); + + assertTrue(welcomeController.getRelationships().isEmpty()); + assertNotNull(petController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(visitController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(ownerController.getEfferentRelationshipWith(ownerRepository)); + assertNotNull(vetController.getEfferentRelationshipWith(vetRepository)); + + // checks that source isn't retained + assertEquals("", DslUtils.getDsl(workspace)); + + } else { + System.out.println("Skipping Spring PetClinic example..."); + } + } + + @Test + void test_bulkOperations() throws Exception { + File dslFile = new File("src/test/resources/dsl/bulk-operations.dsl"); + + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(dslFile); + } + + @Test + void test_ImageView_WhenParserIsInRestrictedMode() { + File dslFile = new File("src/test/resources/dsl/image-view.dsl"); + + try { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.setRestricted(true); + parser.parse(dslFile); + fail(); + } catch (StructurizrDslParserException e) { + assertEquals("image <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled) at line 5 of " + dslFile.getAbsolutePath() + ": image image.png", e.getMessage()); + } + } + + @Test + void test_sourceIsRetained() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/source-parent.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + assertEquals(""" +workspace { + + model { + a = softwareSystem "A" + } + +}""", DslUtils.getDsl(workspace)); + + // source not retained because workspace extends a file-based resource + File childDslFile = new File("src/test/resources/dsl/source-child.dsl"); + parser = new StructurizrDslParser(); + parser.parse(childDslFile); + workspace = parser.getWorkspace(); + assertEquals("", DslUtils.getDsl(workspace)); + } + + @Test + void test_sourceIsNotRetained() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/source-not-retained.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + assertNull(workspace.getProperties().get(DslUtils.STRUCTURIZR_DSL_PROPERTY_NAME)); + } + + @Test + void test_archetypes() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + Container customerApi = workspace.getModel().getSoftwareSystemWithName("X").getContainerWithName("Customer API"); + assertTrue(customerApi.getTagsAsSet().contains("Application")); + assertTrue(customerApi.getTagsAsSet().contains("Spring Boot")); + assertEquals("Spring Boot", customerApi.getTechnology()); + + Relationship relationship = workspace.getModel().getSoftwareSystemWithName("A").getEfferentRelationshipWith(workspace.getModel().getSoftwareSystemWithName("X")); + assertEquals("HTTPS", relationship.getTechnology()); + } + + @Test + void test_archetypesForCustomElements() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes-for-custom-elements.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + CustomElement b = workspace.getModel().getCustomElementWithName("B"); + assertEquals("Hardware System", b.getMetadata()); + assertTrue(b.getTagsAsSet().contains("Hardware System")); + } + + @Test + void test_archetypesForDefaults() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes-for-defaults.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + SoftwareSystem a = workspace.getModel().getSoftwareSystemWithName("A"); + assertEquals("Default Description", a.getDescription()); + assertTrue(a.hasTag("Default Tag")); + assertTrue(a.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (a.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + + SoftwareSystem b = workspace.getModel().getSoftwareSystemWithName("B"); + assertEquals("Default Description", b.getDescription()); + assertTrue(b.hasTag("Default Tag")); + assertTrue(b.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (b.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + + Relationship r = a.getEfferentRelationshipWith(b); + assertEquals("Default Description", r.getDescription()); + assertEquals("Default Technology", r.getTechnology()); + assertTrue(r.hasTag("Default Tag")); + assertTrue(r.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (r.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + } + + @Test + void test_archetypesFromWorkspaceExtension() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes-from-workspace-extension-child.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + SoftwareSystem a = workspace.getModel().getSoftwareSystemWithName("A"); + assertEquals("Default Description", a.getDescription()); + assertTrue(a.hasTag("Default Tag")); + assertTrue(a.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (a.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + + SoftwareSystem b = workspace.getModel().getSoftwareSystemWithName("B"); + assertEquals("Default Description", b.getDescription()); + assertTrue(b.hasTag("Default Tag")); + assertTrue(b.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (b.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + + Relationship r = a.getEfferentRelationshipWith(b); + assertEquals("Default Description", r.getDescription()); + assertEquals("Default Technology", r.getTechnology()); + assertTrue(r.hasTag("Default Tag")); + assertTrue(r.hasProperty("Default Property Name", "Default Property Value")); + assertEquals("Default Perspective Description", (r.getPerspectives().stream().filter(p -> p.getName().equals("Default Perspective Name")).findFirst().get().getDescription())); + } + + @Test + void test_archetypesForExtension() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes-for-extension.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + SoftwareSystem a = workspace.getModel().getSoftwareSystemWithName("A"); + assertEquals("Description of A.", a.getDescription()); + assertTrue(a.hasTag("Default Tag")); + + SoftwareSystem b = workspace.getModel().getSoftwareSystemWithName("B"); + assertEquals("Description of B.", b.getDescription()); + assertTrue(b.hasTag("Default Tag")); + assertTrue(b.hasTag("External Software System")); + + Relationship r = a.getEfferentRelationshipWith(b); + assertEquals("Makes API calls to", r.getDescription()); + assertEquals("HTTPS", r.getTechnology()); + assertTrue(r.hasTag("Default Tag")); + assertTrue(r.hasTag("Synchronous")); + assertTrue(r.hasTag("HTTPS")); + } + + @Test + void test_archetypesForImplicitRelationships() throws Exception { + File parentDslFile = new File("src/test/resources/dsl/archetypes-for-implicit-relationships.dsl"); + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(parentDslFile); + Workspace workspace = parser.getWorkspace(); + + SoftwareSystem a = workspace.getModel().getSoftwareSystemWithName("A"); + SoftwareSystem b = workspace.getModel().getSoftwareSystemWithName("B"); + + Relationship r = b.getEfferentRelationshipWith(a); + assertEquals("Makes API calls to", r.getDescription()); + assertEquals("HTTPS", r.getTechnology()); + assertTrue(r.hasTag("HTTPS")); + } + + @Test + void test_textBlock() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/text-block.dsl")); + + workspace = parser.getWorkspace(); + + ImageView view = (ImageView)workspace.getViews().getViewWithKey("image"); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuUAoAIwfp4cruohApozHgEPI00AdnEJizAByqhmKv_oS_28h1UKqCB3cgkMoqOUgvqhEIImkLl2jT0RHN0wfUIb0ym00", view.getContent()); + + assertEquals(""" + workspace { + + views { + properties { + "plantuml.url" "https://plantuml.com/plantuml" + } + + !const SOURCE ""\" + class MyClass + ""\" + + !var STYLES ""\" + <style> + root { + BackgroundColor: #ffffff; + } + </style> + ""\" + + image * "image" { + plantuml ""\" + @startuml + + ${STYLES} + + ${SOURCE} + @enduml + ""\" + } + } + + }""", DslUtils.getDsl(workspace)); + } + + @Test + void test_customViewAnimation() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/custom-view-animation.dsl")); + + Workspace workspace = parser.getWorkspace(); + CustomElement a = workspace.getModel().getCustomElementWithName("A"); + CustomElement b = workspace.getModel().getCustomElementWithName("B"); + + for (CustomView view : workspace.getViews().getCustomViews()) { + assertEquals(2, view.getAnimations().size()); + + // step 1 + assertTrue(view.getAnimations().get(0).getElements().contains(a.getId())); + + // step 2 + assertTrue(view.getAnimations().get(1).getElements().contains(b.getId())); + } + } + + @Test + void test_staticViewAnimation() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/static-view-animation.dsl")); + + Workspace workspace = parser.getWorkspace(); + SoftwareSystem a = workspace.getModel().getSoftwareSystemWithName("A"); + SoftwareSystem b = workspace.getModel().getSoftwareSystemWithName("B"); + + for (SystemLandscapeView view : workspace.getViews().getSystemLandscapeViews()) { + assertEquals(2, view.getAnimations().size()); + + // step 1 + assertTrue(view.getAnimations().get(0).getElements().contains(a.getId())); + + // step 2 + assertTrue(view.getAnimations().get(1).getElements().contains(b.getId())); + } + } + + @Test + void test_deploymentViewAnimation() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/deployment-view-animation.dsl")); + + Workspace workspace = parser.getWorkspace(); + Container webapp = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("Web Application"); + Container db = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("Database Schema"); + ContainerInstance webappInstance = workspace.getModel().getDeploymentNodeWithName("Deployment Node", "Live").getContainerInstances().stream().filter(ci -> ci.getContainer().equals(webapp)).findFirst().get(); + ContainerInstance dbInstance = workspace.getModel().getDeploymentNodeWithName("Deployment Node", "Live").getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + for (DeploymentView deploymentView : workspace.getViews().getDeploymentViews()) { + assertEquals(2, deploymentView.getAnimations().size()); + + // step 1 + assertTrue(deploymentView.getAnimations().get(0).getElements().contains(webappInstance.getId())); + + // step 2 + assertTrue(deploymentView.getAnimations().get(1).getElements().contains(dbInstance.getId())); + } + } + + @Test + void test_deploymentGroups_Flat() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/deployment-groups-flat.dsl")); + + Workspace workspace = parser.getWorkspace(); + + Container api = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("API"); + Container db = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("DB"); + + DeploymentNode server1 = workspace.getModel().getDeploymentNodeWithName("Server 1", "WithoutDeploymentGroups"); + ContainerInstance apiInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + ContainerInstance dbInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + DeploymentNode server2 = workspace.getModel().getDeploymentNodeWithName("Server 2", "WithoutDeploymentGroups"); + ContainerInstance apiInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + ContainerInstance dbInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance1)); + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance1)); + + server1 = workspace.getModel().getDeploymentNodeWithName("Server 1", "WithDeploymentGroups"); + apiInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + dbInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + server2 = workspace.getModel().getDeploymentNodeWithName("Server 2", "WithDeploymentGroups"); + apiInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + dbInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance1)); + assertFalse(apiInstance1.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance2)); + assertFalse(apiInstance2.hasEfferentRelationshipWith(dbInstance1)); + } + + @Test + void test_deploymentGroups_Hierarchical() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/deployment-groups-hierarchical.dsl")); + + Workspace workspace = parser.getWorkspace(); + + Container api = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("API"); + Container db = workspace.getModel().getSoftwareSystemWithName("Software System").getContainerWithName("DB"); + + DeploymentNode server1 = workspace.getModel().getDeploymentNodeWithName("Server 1", "WithoutDeploymentGroups"); + ContainerInstance apiInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + ContainerInstance dbInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + DeploymentNode server2 = workspace.getModel().getDeploymentNodeWithName("Server 2", "WithoutDeploymentGroups"); + ContainerInstance apiInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + ContainerInstance dbInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance1)); + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance1)); + + server1 = workspace.getModel().getDeploymentNodeWithName("Server 1", "WithDeploymentGroups"); + apiInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + dbInstance1 = server1.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + server2 = workspace.getModel().getDeploymentNodeWithName("Server 2", "WithDeploymentGroups"); + apiInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(api)).findFirst().get(); + dbInstance2 = server2.getContainerInstances().stream().filter(ci -> ci.getContainer().equals(db)).findFirst().get(); + + assertTrue(apiInstance1.hasEfferentRelationshipWith(dbInstance1)); + assertFalse(apiInstance1.hasEfferentRelationshipWith(dbInstance2)); + assertTrue(apiInstance2.hasEfferentRelationshipWith(dbInstance2)); + assertFalse(apiInstance2.hasEfferentRelationshipWith(dbInstance1)); + } + + @Test + void test_colorSchemes() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/color-schemes.dsl")); + + Workspace workspace = parser.getWorkspace(); + + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().getElementStyle("Element"); + assertEquals(Shape.RoundedBox, elementStyle.getShape()); + + elementStyle = workspace.getViews().getConfiguration().getStyles().getElementStyle("Element", ColorScheme.Light); + assertEquals("#000000", elementStyle.getColor()); + + elementStyle = workspace.getViews().getConfiguration().getStyles().getElementStyle("Element", ColorScheme.Dark); + assertEquals("#ffffff", elementStyle.getColor()); + + RelationshipStyle relationshipStyle = workspace.getViews().getConfiguration().getStyles().getRelationshipStyle("Relationship"); + assertEquals(LineStyle.Solid, relationshipStyle.getStyle()); + + relationshipStyle = workspace.getViews().getConfiguration().getStyles().getRelationshipStyle("Relationship", ColorScheme.Light); + assertEquals("#000000", relationshipStyle.getColor()); + + relationshipStyle = workspace.getViews().getConfiguration().getStyles().getRelationshipStyle("Relationship", ColorScheme.Dark); + assertEquals("#ffffff", relationshipStyle.getColor()); + } + + @Test + void test_noRelationship() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/dsl/no-relationship.dsl")); + + Workspace workspace = parser.getWorkspace(); + + Container ui = (Container)parser.getIdentifiersRegister().getElement("ss.ui"); + Container backend = (Container)parser.getIdentifiersRegister().getElement("ss.backend"); + + // environment One: ui -> backend + ContainerInstance uiInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("One") && ci.getContainer().equals(ui)).findFirst().get(); + ContainerInstance backendInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("One") && ci.getContainer().equals(backend)).findFirst().get(); + + assertNotNull(uiInstance); + assertNotNull(backendInstance); + assertTrue(uiInstance.hasEfferentRelationshipWith(backendInstance)); + + // environment Two: ui -> load balancer -> backend + uiInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("Two") && ci.getContainer().equals(ui)).findFirst().get(); + backendInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("Two") && ci.getContainer().equals(backend)).findFirst().get(); + InfrastructureNode loadBalancer = workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).map(e -> (InfrastructureNode)e).filter(ci -> ci.getEnvironment().equals("Two")).findFirst().get(); + + assertNotNull(uiInstance); + assertNotNull(backendInstance); + assertFalse(uiInstance.hasEfferentRelationshipWith(backendInstance)); + + assertEquals("Makes API requests to", uiInstance.getEfferentRelationshipWith(loadBalancer).getDescription()); + assertEquals("JSON/HTTPS", uiInstance.getEfferentRelationshipWith(loadBalancer).getTechnology()); + + assertEquals("Forwards API requests to", loadBalancer.getEfferentRelationshipWith(backendInstance).getDescription()); + assertEquals("", loadBalancer.getEfferentRelationshipWith(backendInstance).getTechnology()); + + // environment Three: ui -> load balancer -> backend + uiInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("Three") && ci.getContainer().equals(ui)).findFirst().get(); + backendInstance = workspace.getModel().getElements().stream().filter(e -> e instanceof ContainerInstance).map(e -> (ContainerInstance)e).filter(ci -> ci.getEnvironment().equals("Three") && ci.getContainer().equals(backend)).findFirst().get(); + loadBalancer = workspace.getModel().getElements().stream().filter(e -> e instanceof InfrastructureNode).map(e -> (InfrastructureNode)e).filter(ci -> ci.getEnvironment().equals("Three")).findFirst().get(); + + assertNotNull(uiInstance); + assertNotNull(backendInstance); + assertFalse(uiInstance.hasEfferentRelationshipWith(backendInstance)); + + assertEquals("Makes API requests to", uiInstance.getEfferentRelationshipWith(loadBalancer).getDescription()); + assertEquals("JSON/HTTPS", uiInstance.getEfferentRelationshipWith(loadBalancer).getTechnology()); + + assertEquals("Forwards API requests to", loadBalancer.getEfferentRelationshipWith(backendInstance).getDescription()); + assertEquals("", loadBalancer.getEfferentRelationshipWith(backendInstance).getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewContentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewContentParserTests.java new file mode 100644 index 000000000..a94047a89 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewContentParserTests.java @@ -0,0 +1,187 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Person; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DynamicView; +import com.structurizr.view.RelationshipView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DynamicViewContentParserTests extends AbstractTests { + + private DynamicViewContentParser parser = new DynamicViewContentParser(); + + @Test + void test_parseRelationship_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseRelationship(new DynamicViewDslContext(null), tokens("source", "->", "destination", "description", "technology", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: [order:] <identifier> -> <identifier> [description] [technology]", e.getMessage()); + } + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenTheDestinationIdentifierIsMissing() { + try { + parser.parseRelationship(new DynamicViewDslContext(null), tokens("source", "->")); + fail(); + } catch (Exception e) { + assertEquals("Expected: [order:] <identifier> -> <identifier> [description] [technology]", e.getMessage()); + } + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenTheSourceElementIsNotDefined() { + DynamicViewDslContext context = new DynamicViewDslContext(null); + + try { + parser.parseRelationship(context, tokens("source", "->", "destination")); + fail(); + } catch (Exception e) { + assertEquals("The source element \"source\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenTheSourceElementIsNotAStaticStructureElement() { + DynamicViewDslContext context = new DynamicViewDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", model.addDeploymentNode("Deployment Node")); + context.setIdentifierRegister(elements); + + try { + parser.parseRelationship(context, tokens("source", "->", "destination")); + fail(); + } catch (Exception e) { + assertEquals("The source element \"source\" should be a static structure or custom element", e.getMessage()); + } + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenTheDestinationElementIsNotDefined() { + DynamicViewDslContext context = new DynamicViewDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", model.addPerson("User", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parseRelationship(context, tokens("source", "->", "destination")); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"destination\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenTheDestinationElementIsNotAStaticStructureElement() { + DynamicViewDslContext context = new DynamicViewDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", model.addPerson("User", "Description")); + elements.register("destination", model.addDeploymentNode("Deployment Node")); + context.setIdentifierRegister(elements); + + try { + parser.parseRelationship(context, tokens("source", "->", "destination")); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"destination\" should be a static structure or custom element", e.getMessage()); + } + } + + @Test + void test_parseRelationship_AddsTheRelationshipToTheView_WhenItAlreadyExistsInTheModel() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + DynamicView view = views.createDynamicView("key", "Description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parseRelationship(context, tokens("source", "->", "destination")); + + assertEquals(1, view.getRelationships().size()); + RelationshipView rv = view.getRelationships().iterator().next(); + assertSame(user, rv.getRelationship().getSource()); + assertSame(softwareSystem, rv.getRelationship().getDestination()); + assertEquals("", rv.getDescription()); + assertEquals("1", rv.getOrder()); + } + + @Test + void test_parseRelationship_AddsTheRelationshipToTheViewWithAnOverriddenDescription_WhenItAlreadyExistsInTheModel() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + DynamicView view = views.createDynamicView("key", "Description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parseRelationship(context, tokens("source", "->", "destination", "Does something with")); + + assertEquals(1, view.getRelationships().size()); + RelationshipView rv = view.getRelationships().iterator().next(); + assertSame(user, rv.getRelationship().getSource()); + assertSame(softwareSystem, rv.getRelationship().getDestination()); + assertEquals("Does something with", rv.getDescription()); + assertEquals("1", rv.getOrder()); + } + + @Test + void test_parseRelationship_AddsTheRelationshipWithTheSpecifiedTechnologyToTheView_WhenItAlreadyExistsInTheModel() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Relationship r1 = user.uses(softwareSystem, "Uses 1", "Tech 1"); + Relationship r2 = user.uses(softwareSystem, "Uses 2", "Tech 2"); + DynamicView view = views.createDynamicView("key", "Description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parseRelationship(context, tokens("source", "->", "destination", "Description", "Tech 2")); + + assertEquals(1, view.getRelationships().size()); + RelationshipView rv = view.getRelationships().iterator().next(); + assertSame(r2, rv.getRelationship()); + assertSame(user, rv.getRelationship().getSource()); + assertSame(softwareSystem, rv.getRelationship().getDestination()); + assertEquals("Description", rv.getDescription()); + assertEquals("1", rv.getOrder()); + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenItDoesNotAlreadyExistInTheModel() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DynamicView view = views.createDynamicView("key", "Description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + try { + parser.parseRelationship(context, tokens("source", "->", "destination", "Uses")); + fail(); + } catch (Exception e) { + assertEquals("A relationship between User and Software System does not exist in model.", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewParserTests.java new file mode 100644 index 000000000..f367ff064 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewParserTests.java @@ -0,0 +1,197 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DynamicView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DynamicViewParserTests extends AbstractTests { + + private DynamicViewParser parser = new DynamicViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("dynamic", "identifier", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: dynamic <*|software system identifier|container identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIdentifierIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("dynamic")); + fail(); + } catch (Exception e) { + assertEquals("Expected: dynamic <*|software system identifier|container identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_GeneratesAKey_WhenTheKeyIsMissing() { + DslContext context = context(); + DynamicView view = parser.parse(context, tokens("dynamic", "*")); + + assertEquals("Dynamic-001", view.getKey()); + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotDefined() { + DslContext context = context(); + try { + parser.parse(context, tokens("dynamic", "softwareSystem", "key")); + fail(); + } catch (Exception e) { + assertEquals("The software system or container \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystemOrContainer() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("person", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("dynamic", "person", "key")); + fail(); + } catch (Exception e) { + assertEquals("The element \"person\" is not a software system or container", e.getMessage()); + } + } + + @Test + void test_parse_CreatesADynamicViewWithNoScope() { + parser.parse(context(), tokens("dynamic", "*", "key")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertNull(views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithNoScopeAndADescription() { + parser.parse(context(), tokens("dynamic", "*", "key", "Description")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + assertNull(views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithSoftwareSystemScope() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "softwareSystem")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("Dynamic-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithSoftwareSystemScopeAndKey() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "softwareSystem", "key")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithSoftwareSystemScopeAndKeyAndDescription() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "softwareSystem", "key", "Description")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + assertSame(softwareSystem, views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithContainerScope() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + Container container = model.addSoftwareSystem("Name", "Description").addContainer("Container", "Description", "Technology"); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "container")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("Dynamic-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(container, views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithContainerScopeAndKey() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + Container container = model.addSoftwareSystem("Name", "Description").addContainer("Container", "Description", "Technology"); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "container", "key")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + assertSame(container, views.get(0).getElement()); + } + + @Test + void test_parse_CreatesADynamicViewWithContainerScopeAndKeyAndDescription() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + Container container = model.addSoftwareSystem("Name", "Description").addContainer("Container", "Description", "Technology"); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("dynamic", "container", "key", "Description")); + List<DynamicView> views = new ArrayList<>(this.views.getDynamicViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + assertSame(container, views.get(0).getElement()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewRelationshipParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewRelationshipParserTests.java new file mode 100644 index 000000000..2bd6cf066 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/DynamicViewRelationshipParserTests.java @@ -0,0 +1,52 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.DynamicView; +import com.structurizr.view.RelationshipView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class DynamicViewRelationshipParserTests extends AbstractTests { + + private final DynamicViewRelationshipParser parser = new DynamicViewRelationshipParser(); + + @Test + void test_parseUrl_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + DynamicViewRelationshipContext context = new DynamicViewRelationshipContext(null); + parser.parseUrl(context, tokens("url", "url", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: url <url>", e.getMessage()); + } + } + + @Test + void test_parseUrl_ThrowsAnException_WhenNoUrlIsSpecified() { + try { + DynamicViewRelationshipContext context = new DynamicViewRelationshipContext(null); + parser.parseUrl(context, tokens("url")); + fail(); + } catch (Exception e) { + assertEquals("Expected: url <url>", e.getMessage()); + } + } + + @Test + void test_parseUrl_SetsTheUrl_WhenAUrlIsSpecified() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship r = a.uses(b, "Uses"); + + DynamicView dynamicView = workspace.getViews().createDynamicView("key", "Description"); + RelationshipView rv = dynamicView.add(r); + DynamicViewRelationshipContext context = new DynamicViewRelationshipContext(rv); + parser.parseUrl(context, tokens("url", "http://example.com")); + + assertEquals("http://example.com", rv.getUrl()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementStyleParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementStyleParserTests.java new file mode 100644 index 000000000..65baa3c09 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementStyleParserTests.java @@ -0,0 +1,641 @@ +package com.structurizr.dsl; + +import com.structurizr.view.Border; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.IconPosition; +import com.structurizr.view.Shape; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +class ElementStyleParserTests extends AbstractTests { + + private final ElementStyleParser parser = new ElementStyleParser(); + private ElementStyle elementStyle; + + private ElementStyleDslContext elementStyleDslContext() { + elementStyle = workspace.getViews().getConfiguration().getStyles().addElementStyle("Tag"); + ElementStyleDslContext context = new ElementStyleDslContext(elementStyle, new File(".")); + context.setWorkspace(workspace); + + return context; + } + + private StylesDslContext stylesDslContext() { + StylesDslContext context = new StylesDslContext(); + context.setWorkspace(workspace); + + return context; + } + + @Test + void test_parseElementStyle_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseElementStyle(stylesDslContext(), tokens("element", "tag", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: element <tag> {", e.getMessage()); + } + } + + @Test + void test_parseElementStyle_ThrowsAnException_WhenTheTagIsMissing() { + try { + parser.parseElementStyle(stylesDslContext(), tokens("element")); + fail(); + } catch (Exception e) { + assertEquals("Expected: element <tag> {", e.getMessage()); + } + } + + @Test + void test_parseElementStyle_ThrowsAnException_WhenTheTagIsEmpty() { + try { + parser.parseElementStyle(stylesDslContext(), tokens("element", "")); + fail(); + } catch (Exception e) { + assertEquals("A tag must be specified", e.getMessage()); + } + } + + @Test + void test_parseElementStyle_CreatesAnElementStyle() { + parser.parseElementStyle(stylesDslContext(), tokens("element", "Element")); + + ElementStyle style = workspace.getViews().getConfiguration().getStyles().getElements().stream().filter(es -> "Element".equals(es.getTag())).findFirst().get(); + assertNotNull(style); + } + + @Test + void test_parseElementStyle_FindsAnExistingElementStyle() { + ElementStyle style = workspace.getViews().getConfiguration().getStyles().addElementStyle("Tag"); + assertSame(style, parser.parseElementStyle(stylesDslContext(), tokens("element", "Tag"))); + } + + @Test + void test_parseShape_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseShape(elementStyleDslContext(), tokens("shape", "shape", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: shape <Box|RoundedBox|Circle|Ellipse|Hexagon|Diamond|Cylinder|Bucket|Pipe|Person|Robot|Folder|WebBrowser|Window|Terminal|Shell|MobileDevicePortrait|MobileDeviceLandscape|Component>", e.getMessage()); + } + } + + @Test + void test_parseShape_ThrowsAnException_WhenTheShapeIsMissing() { + try { + parser.parseShape(elementStyleDslContext(), tokens("shape")); + fail(); + } catch (Exception e) { + assertEquals("Expected: shape <Box|RoundedBox|Circle|Ellipse|Hexagon|Diamond|Cylinder|Bucket|Pipe|Person|Robot|Folder|WebBrowser|Window|Terminal|Shell|MobileDevicePortrait|MobileDeviceLandscape|Component>", e.getMessage()); + } + } + + @Test + void test_parseShape_ThrowsAnException_WhenTheShapeIsNotValid() { + try { + parser.parseShape(elementStyleDslContext(), tokens("shape", "square")); + fail(); + } catch (Exception e) { + assertEquals("The shape \"square\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseShape_SetsTheShape() { + parser.parseShape(elementStyleDslContext(), tokens("shape", "roundedbox")); + assertEquals(Shape.RoundedBox, elementStyle.getShape()); + } + + @Test + void test_parseBackground_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseBackground(elementStyleDslContext(), tokens("background", "hex", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: background <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseBackground_ThrowsAnException_WhenTheBackgroundIsMissing() { + try { + parser.parseBackground(elementStyleDslContext(), tokens("background")); + fail(); + } catch (Exception e) { + assertEquals("Expected: background <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseBackground_SetsTheBackgroundWhenUsingAHexColourCode() { + parser.parseBackground(elementStyleDslContext(), tokens("background", "#ff0000")); + assertEquals("#ff0000", elementStyle.getBackground()); + } + + @Test + void test_parseBackground_SetsTheBackgroundWhenUsingAColourName() { + parser.parseBackground(elementStyleDslContext(), tokens("background", "yellow")); + assertEquals("#ffff00", elementStyle.getBackground()); + } + + @Test + void test_parseStroke_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseStroke(elementStyleDslContext(), tokens("stroke", "hex", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: stroke <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseStroke_ThrowsAnException_WhenTheStrokeIsMissing() { + try { + parser.parseStroke(elementStyleDslContext(), tokens("stroke")); + fail(); + } catch (Exception e) { + assertEquals("Expected: stroke <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseStroke_SetsTheStrokeWhenUsingAHexColourCode() { + parser.parseStroke(elementStyleDslContext(), tokens("stroke", "yellow")); + assertEquals("#ffff00", elementStyle.getStroke()); + } + + @Test + void test_parseStrokeWidth_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseStrokeWidth(elementStyleDslContext(), tokens("strokeWidth", "4", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: strokeWidth <1-10>", e.getMessage()); + } + } + + @Test + void test_parseStrokeWidth_ThrowsAnException_WhenTheStrokeWidthIsMissing() { + try { + parser.parseStrokeWidth(elementStyleDslContext(), tokens("strokeWidth")); + fail(); + } catch (Exception e) { + assertEquals("Expected: strokeWidth <1-10>", e.getMessage()); + } + } + + @Test + void test_parseStrokeWidth_ThrowsAnException_WhenTheStrokeWidthIsNotANumber() { + try { + parser.parseStrokeWidth(elementStyleDslContext(), tokens("strokeWidth", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Stroke width must be an integer between 1 and 10", e.getMessage()); + } + } + + @Test + void test_parseStrokeWidth_SetsTheStrokeWidth() { + parser.parseStrokeWidth(elementStyleDslContext(), tokens("strokeWidth", "4")); + assertEquals(4, elementStyle.getStrokeWidth()); + } + + @Test + void test_parseColour_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseColour(elementStyleDslContext(), tokens("colour", "hex", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: colour <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseColour_ThrowsAnException_WhenTheColourIsMissing() { + try { + parser.parseColour(elementStyleDslContext(), tokens("colour")); + fail(); + } catch (Exception e) { + assertEquals("Expected: colour <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseColour_SetsTheColourWhenUsingAHexColourCode() { + parser.parseColour(elementStyleDslContext(), tokens("colour", "#ff0000")); + assertEquals("#ff0000", elementStyle.getColor()); + } + + @Test + void test_parseColour_SetsTheColourWhenUsingColourName() { + parser.parseColour(elementStyleDslContext(), tokens("colour", "yellow")); + assertEquals("#ffff00", elementStyle.getColor()); + } + + @Test + void test_parseBorder_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseBorder(elementStyleDslContext(), tokens("border", "style", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: border <solid|dashed|dotted>", e.getMessage()); + } + } + + @Test + void test_parseBorder_ThrowsAnException_WhenTheBorderIsMissing() { + try { + parser.parseBorder(elementStyleDslContext(), tokens("border")); + fail(); + } catch (Exception e) { + assertEquals("Expected: border <solid|dashed|dotted>", e.getMessage()); + } + } + + @Test + void test_parseBorder_ThrowsAnException_WhenTheBorderIsNotValid() { + try { + parser.parseBorder(elementStyleDslContext(), tokens("border", "rounded")); + fail(); + } catch (Exception e) { + assertEquals("The border \"rounded\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseBorder_SetsTheBorder() { + parser.parseBorder(elementStyleDslContext(), tokens("border", "dotted")); + assertEquals(Border.Dotted, elementStyle.getBorder()); + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseOpacity(elementStyleDslContext(), tokens("opacity", "percentage", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: opacity <0-100>", e.getMessage()); + } + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenTheOpacityIsMissing() { + try { + parser.parseOpacity(elementStyleDslContext(), tokens("opacity")); + fail(); + } catch (Exception e) { + assertEquals("Expected: opacity <0-100>", e.getMessage()); + } + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenTheOpacityIsNotValid() { + try { + parser.parseOpacity(elementStyleDslContext(), tokens("opacity", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Opacity must be an integer between 0 and 100", e.getMessage()); + } + } + + @Test + void test_parseOpacity_SetsTheOpacity() { + parser.parseOpacity(elementStyleDslContext(), tokens("opacity", "75")); + assertEquals(75, elementStyle.getOpacity()); + } + + @Test + void test_parseWidth_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseWidth(elementStyleDslContext(), tokens("width", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: width <number>", e.getMessage()); + } + } + + @Test + void test_parseWidth_ThrowsAnException_WhenTheWidthIsMissing() { + try { + parser.parseWidth(elementStyleDslContext(), tokens("width")); + fail(); + } catch (Exception e) { + assertEquals("Expected: width <number>", e.getMessage()); + } + } + + @Test + void test_parseWidth_ThrowsAnException_WhenTheWidthIsNotValid() { + try { + parser.parseWidth(elementStyleDslContext(), tokens("width", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Width must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseWidth_SetsTheWidth() { + parser.parseWidth(elementStyleDslContext(), tokens("width", "75")); + assertEquals(75, elementStyle.getWidth()); + } + + @Test + void test_parseHeight_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseHeight(elementStyleDslContext(), tokens("height", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: height <number>", e.getMessage()); + } + } + + @Test + void test_parseHeight_ThrowsAnException_WhenTheHeightIsMissing() { + try { + parser.parseHeight(elementStyleDslContext(), tokens("height")); + fail(); + } catch (Exception e) { + assertEquals("Expected: height <number>", e.getMessage()); + } + } + + @Test + void test_parseHeight_ThrowsAnException_WhenTheHeightIsNotValid() { + try { + parser.parseHeight(elementStyleDslContext(), tokens("height", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Height must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseHeight_SetsTheHeight() { + parser.parseHeight(elementStyleDslContext(), tokens("height", "75")); + assertEquals(75, elementStyle.getHeight()); + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseFontSize(elementStyleDslContext(), tokens("fontSize", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: fontSize <number>", e.getMessage()); + } + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenTheFontSizeIsMissing() { + try { + parser.parseFontSize(elementStyleDslContext(), tokens("fontSize")); + fail(); + } catch (Exception e) { + assertEquals("Expected: fontSize <number>", e.getMessage()); + } + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenTheFontSizeIsNotValid() { + try { + parser.parseFontSize(elementStyleDslContext(), tokens("fontSize", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Font size must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseFontSize_SetsTheFontSize() { + parser.parseFontSize(elementStyleDslContext(), tokens("fontSize", "75")); + assertEquals(75, elementStyle.getFontSize()); + } + + @Test + void test_parseMetadata_ThrowsAnException_WhenTheMetadataIsMissing() { + try { + parser.parseMetadata(elementStyleDslContext(), tokens("metadata")); + fail(); + } catch (Exception e) { + assertEquals("Expected: metadata <true|false>", e.getMessage()); + } + } + + @Test + void test_parseMetadata_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseMetadata(elementStyleDslContext(), tokens("metadata", "boolean", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: metadata <true|false>", e.getMessage()); + } + } + + @Test + void test_parseMetadata_ThrowsAnException_WhenTheMetadataIsNotValid() { + try { + parser.parseMetadata(elementStyleDslContext(), tokens("metadata", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Metadata must be true or false", e.getMessage()); + } + } + + @Test + void test_parseMetadata_SetsTheMetadata() { + ElementStyleDslContext context = elementStyleDslContext(); + parser.parseMetadata(context, tokens("metadata", "false")); + assertEquals(false, elementStyle.getMetadata()); + + parser.parseMetadata(context, tokens("metadata", "true")); + assertEquals(true, elementStyle.getMetadata()); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseDescription(elementStyleDslContext(), tokens("description", "boolean", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: description <true|false>", e.getMessage()); + } + } + + @Test + void test_parseDescription_ThrowsAnException_WhenTheDescriptionIsMissing() { + try { + parser.parseDescription(elementStyleDslContext(), tokens("description")); + fail(); + } catch (Exception e) { + assertEquals("Expected: description <true|false>", e.getMessage()); + } + } + + @Test + void test_parseDescription_ThrowsAnException_WhenTheDescriptionIsNotValid() { + try { + parser.parseDescription(elementStyleDslContext(), tokens("description", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Description must be true or false", e.getMessage()); + } + } + + @Test + void test_parseDescription_SetsTheDescription() { + ElementStyleDslContext context = elementStyleDslContext(); + parser.parseDescription(context, tokens("description", "false")); + assertEquals(false, elementStyle.getDescription()); + + parser.parseDescription(context, tokens("description", "true")); + assertEquals(true, elementStyle.getDescription()); + } + + @Test + void test_parseIcon_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseIcon(elementStyleDslContext(), tokens("icon", "file", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: icon <file|url>", e.getMessage()); + } + } + + @Test + void test_parseIcon_ThrowsAnException_WhenTheIconIsMissing() { + try { + parser.parseIcon(elementStyleDslContext(), tokens("icon")); + fail(); + } catch (Exception e) { + assertEquals("Expected: icon <file|url>", e.getMessage()); + } + } + + @Test + void test_parseIcon_ThrowsAnException_WhenTheIconDoesNotExist() { + try { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().enable(Features.FILE_SYSTEM); + + parser.parseIcon(context, tokens("icon", "hello.png")); + fail(); + } catch (Exception e) { + assertEquals("hello.png does not exist", e.getMessage()); + } + } + + @Test + void test_parseIcon_SetsTheIconFromADataUri() { + parser.parseIcon(elementStyleDslContext(), tokens("icon", "data:image/png;base64,123456789012345678901234567890")); + assertTrue(elementStyle.getIcon().startsWith("data:image/png;base64,123456789012345678901234567890")); + } + + @Test + void test_parseIcon_ThrowsAnException_WithAHttpIconAndHttpIsNotEnabled() { + try { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().disable(Features.HTTP); + + parser.parseIcon(context, tokens("icon", "http://structurizr.com/logo.png")); + fail(); + } catch (Exception e) { + assertEquals("Icons via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseIcon_SetsTheIconFromAHttpUrl() { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().enable(Features.HTTP); + + parser.parseIcon(context, tokens("icon", "http://structurizr.com/logo.png")); + assertEquals("http://structurizr.com/logo.png", elementStyle.getIcon()); + } + + @Test + void test_parseIcon_ThrowsAnException_WithAHttpsIconAndHttpsIsNotEnabled() { + try { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().disable(Features.HTTPS); + + parser.parseIcon(context, tokens("icon", "https://structurizr.com/logo.png")); + fail(); + } catch (Exception e) { + assertEquals("Icons via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseIcon_SetsTheIconFromAHttpsUrl() { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().enable(Features.HTTPS); + + parser.parseIcon(context, tokens("icon", "https://structurizr.com/logo.png")); + assertEquals("https://structurizr.com/logo.png", elementStyle.getIcon()); + } + + @Test + void test_parseIcon_SetsTheIconFromAFile() { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().enable(Features.FILE_SYSTEM); + + parser.parseIcon(context, tokens("icon", "src/test/resources/dsl/logo.png")); + System.out.println(elementStyle.getIcon()); + assertTrue(elementStyle.getIcon().startsWith("data:image/png;base64,")); + } + + @Test + void test_parseIcon_ThrowsAnException_WhenFileSystemAccessIsNotEnabled() { + try { + ElementStyleDslContext context = elementStyleDslContext(); + context.getFeatures().disable(Features.FILE_SYSTEM); + + parser.parseIcon(context, tokens("icon", "src/test/resources/dsl/logo.png")); + fail(); + } catch (Exception e) { + assertEquals("!icon <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseIconPosition_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseIconPosition(elementStyleDslContext(), tokens("iconPosition", "top", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: iconPosition <Top|Bottom|Left>", e.getMessage()); + } + } + + @Test + void test_parseIconPosition_ThrowsAnException_WhenTheShapeIsMissing() { + try { + parser.parseIconPosition(elementStyleDslContext(), tokens("iconPosition")); + fail(); + } catch (Exception e) { + assertEquals("Expected: iconPosition <Top|Bottom|Left>", e.getMessage()); + } + } + + @Test + void test_parseIconPosition_ThrowsAnException_WhenTheShapeIsNotValid() { + try { + parser.parseIconPosition(elementStyleDslContext(), tokens("iconPosition", "right")); + fail(); + } catch (Exception e) { + assertEquals("The icon position \"right\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseIconPosition_SetsTheIconPosition() { + parser.parseIconPosition(elementStyleDslContext(), tokens("iconPosition", "top")); + assertEquals(IconPosition.Top, elementStyle.getIconPosition()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementsParserTests.java new file mode 100644 index 000000000..1c1836204 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ElementsParserTests.java @@ -0,0 +1,70 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ElementsParserTests extends AbstractTests { + + private final ElementsParser parser = new ElementsParser(); + + @Test + void test_parseTechnology_ThrowsAnException_WhenNoTechnologyIsSpecified() { + try { + parser.parseTechnology(null, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + + ElementsDslContext context = new ElementsDslContext(null, Set.of(softwareSystem, container, component, deploymentNode, infrastructureNode)); + + parser.parseTechnology(context, tokens("technology", "Technology")); + assertEquals("Technology", container.getTechnology()); + assertEquals("Technology", component.getTechnology()); + assertEquals("Technology", deploymentNode.getTechnology()); + assertEquals("Technology", infrastructureNode.getTechnology()); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + parser.parseDescription(null, tokens("description")); + fail(); + } catch (Exception e) { + assertEquals("Expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + InfrastructureNode infrastructureNode = deploymentNode.addInfrastructureNode("Infrastructure Node"); + + ElementsDslContext context = new ElementsDslContext(null, Set.of(softwareSystem, container, component, deploymentNode, infrastructureNode)); + + parser.parseDescription(context, tokens("description", "Description")); + assertEquals("Description", softwareSystem.getDescription()); + assertEquals("Description", container.getDescription()); + assertEquals("Description", component.getDescription()); + assertEquals("Description", deploymentNode.getDescription()); + assertEquals("Description", infrastructureNode.getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ExplicitRelationshipParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExplicitRelationshipParserTests.java new file mode 100644 index 000000000..ac4b7cead --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExplicitRelationshipParserTests.java @@ -0,0 +1,448 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class ExplicitRelationshipParserTests extends AbstractTests { + + private final ExplicitRelationshipParser parser = new ExplicitRelationshipParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("source", "->", "destination", "description", "technology", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: <identifier> -> <identifier> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationIdentifierIsMissing() { + try { + parser.parse(context(), tokens("source", "->"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: <identifier> -> <identifier> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSourceElementIsNotDefined() { + try { + parser.parse(context(), tokens("source", "->", "destination"), archetype); + fail(); + } catch (Exception e) { + assertEquals("The source element \"source\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationElementIsNotDefined() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", model.addPerson("User", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("source", "->", "destination"), archetype); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"destination\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_AddsTheRelationship() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescription() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination", "Uses"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnology() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination", "Uses", "HTTP"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnologyAndTags() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("Relationship,Tag 1,Tag 2", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnologyAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); // overridden from archetype + assertEquals("HTTP", r.getTechnology()); // overridden from archetype + assertEquals("Relationship,Default Tag,Tag 1,Tag 2", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipAndImplicitRelationshipsWithADescriptionAndTechnologyAndTags() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DslContext context = context(); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + elements.register("destination", container); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + assertEquals(2, model.getRelationships().size()); + + // this is the relationship that was created + Relationship r = user.getEfferentRelationshipWith(container); + assertSame(user, r.getSource()); + assertSame(container, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("Relationship,Tag 1,Tag 2", r.getTags()); + + // and this is an implied relationship + r = user.getEfferentRelationshipWith(softwareSystem); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationship_WithASourceOfThis() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + GroupableElementDslContext context = new PersonDslContext(user); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("this", "->", "destination"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationship_WithADestinationOfThis() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + GroupableElementDslContext context = new SoftwareSystemDslContext(softwareSystem); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("source", user); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("source", "->", "this"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipToAllSoftwareSystemInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(softwareSystem); + devDeploymentNode.add(softwareSystem); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance liveSoftwareSystemInstance1 = liveDeploymentNode.add(softwareSystem); + SoftwareSystemInstance liveSoftwareSystemInstance2 = liveDeploymentNode.add(softwareSystem); + + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("live"); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwareSystem", softwareSystem); + elements.register("liveInfrastructureNode", liveInfrastructureNode); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("liveInfrastructureNode", "->", "softwareSystem"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveSoftwareSystemInstance1)); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveSoftwareSystemInstance2)); + } + + @Test + void test_parse_AddsTheRelationshipFromAllSoftwareSystemInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(softwareSystem); + devDeploymentNode.add(softwareSystem); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance liveSoftwareSystemInstance1 = liveDeploymentNode.add(softwareSystem); + SoftwareSystemInstance liveSoftwareSystemInstance2 = liveDeploymentNode.add(softwareSystem); + + DeploymentEnvironmentDslContext context = new DeploymentEnvironmentDslContext("live"); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwareSystem", softwareSystem); + elements.register("liveInfrastructureNode", liveInfrastructureNode); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("softwareSystem", "->", "liveInfrastructureNode"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveSoftwareSystemInstance1.hasEfferentRelationshipWith(liveInfrastructureNode)); + assertTrue(liveSoftwareSystemInstance2.hasEfferentRelationshipWith(liveInfrastructureNode)); + } + + @Test + void test_parse_AddsTheRelationshipToAllContainerInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(container); + devDeploymentNode.add(container); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance liveContainerInstance1 = liveDeploymentNode.add(container); + ContainerInstance liveContainerInstance2 = liveDeploymentNode.add(container); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(liveDeploymentNode); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + elements.register("liveInfrastructureNode", liveInfrastructureNode); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("liveInfrastructureNode", "->", "container"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveContainerInstance1)); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveContainerInstance2)); + } + + @Test + void test_parse_AddsTheRelationshipFromAllContainerInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(container); + devDeploymentNode.add(container); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance liveContainerInstance1 = liveDeploymentNode.add(container); + ContainerInstance liveContainerInstance2 = liveDeploymentNode.add(container); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(liveDeploymentNode); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + elements.register("liveInfrastructureNode", liveInfrastructureNode); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("container", "->", "liveInfrastructureNode"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveContainerInstance1.hasEfferentRelationshipWith(liveInfrastructureNode)); + assertTrue(liveContainerInstance2.hasEfferentRelationshipWith(liveInfrastructureNode)); + } + + @Test + void test_parse_AddsAViaRelationshipUsingTheDescriptionAndTechnologyOfTheRemovedRelationship() { + SoftwareSystem ss = model.addSoftwareSystem("SS"); + Container a = ss.addContainer("A"); + Container b = ss.addContainer("B"); + Relationship relationship = a.uses(b, "Makes API calls using", "JSON/HTTPS"); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance aInstance = liveDeploymentNode.add(a); + ContainerInstance bInstance = liveDeploymentNode.add(b); + + NoRelationshipInDeploymentEnvironmentDslContext context = new NoRelationshipInDeploymentEnvironmentDslContext(new DeploymentEnvironmentDslContext("live"), relationship); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", aInstance); + elements.register("infrastructureNode", infrastructureNode); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("a", "->", "infrastructureNode"), archetype); + + Relationship aInstanceToInfrastructureNode = aInstance.getEfferentRelationshipWith(infrastructureNode); + assertEquals("Makes API calls using", aInstanceToInfrastructureNode.getDescription()); + assertEquals("JSON/HTTPS", aInstanceToInfrastructureNode.getTechnology()); + } + + @Test + void test_parse_AddsAViaRelationshipUOverridingTheDescriptionAndTechnologyOfTheRemovedRelationship() { + SoftwareSystem ss = model.addSoftwareSystem("SS"); + Container a = ss.addContainer("A"); + Container b = ss.addContainer("B"); + Relationship relationship = a.uses(b, "Makes API calls using", "JSON/HTTPS"); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode infrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance aInstance = liveDeploymentNode.add(a); + ContainerInstance bInstance = liveDeploymentNode.add(b); + + NoRelationshipInDeploymentEnvironmentDslContext context = new NoRelationshipInDeploymentEnvironmentDslContext(new DeploymentEnvironmentDslContext("live"), relationship); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", aInstance); + elements.register("infrastructureNode", infrastructureNode); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("a", "->", "infrastructureNode", "New description", "New technology"), archetype); + + Relationship aInstanceToInfrastructureNode = aInstance.getEfferentRelationshipWith(infrastructureNode); + assertEquals("New description", aInstanceToInfrastructureNode.getDescription()); + assertEquals("New technology", aInstanceToInfrastructureNode.getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ExpressionParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExpressionParserTests.java new file mode 100644 index 000000000..83bb4a3ab --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExpressionParserTests.java @@ -0,0 +1,587 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.ContainerView; +import com.structurizr.view.DeploymentView; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class ExpressionParserTests extends AbstractTests { + + private final ExpressionParser parser = new ExpressionParser(); + + @Test + void test_parseExpression_ThrowsAnException_WhenTheRelationshipSourceIsSpecifiedUsingLongSyntaxButDoesNotExist() { + try { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + parser.parseExpression("relationship.source==a", context); + fail(); + } catch (Exception e) { + assertEquals("The element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseExpression_ThrowsAnException_WhenTheRelationshipSourceIsSpecifiedUsingShortSyntaxButDoesNotExist() { + try { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + parser.parseExpression("relationship==a->*", context); + fail(); + } catch (Exception e) { + assertEquals("The element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseExpression_ThrowsAnException_WhenTheRelationshipDestinationIsSpecifiedUsingLongSyntaxButDoesNotExist() { + try { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + parser.parseExpression("relationship.destination==a", context); + fail(); + } catch (Exception e) { + assertEquals("The element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseExpression_ThrowsAnException_WhenTheRelationshipDestinationIsSpecifiedUsingShortSyntaxButDoesNotExist() { + try { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + parser.parseExpression("relationship==*->a", context); + fail(); + } catch (Exception e) { + assertEquals("The element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipSourceIsSpecifiedUsingLongSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship.source==a", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipSourceIsSpecifiedUsingAnExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + a.addTags("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + b.addTags("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + c.addTags("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + context.setIdentifierRegister(new IdentifiersRegister()); + + Set<ModelItem> relationships = parser.parseExpression("* -> element.tag==B", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + + relationships = parser.parseExpression("element.tag==A -> *", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + + relationships = parser.parseExpression("element.tag==A -> element.tag==B", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipSourceIsSpecifiedUsingShortSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship==a->*", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + + relationships = parser.parseExpression("a -> *", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipDestinationIsSpecifiedUsingLongSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship.destination==b", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipDestinationIsSpecifiedUsingShortSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship==*->b", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + + relationships = parser.parseExpression("* -> b", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipSourceAndDestinationAreSpecifiedUsingLongSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship.source==a && relationship.destination==b", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsARelationship_WhenTheRelationshipSourceAndDestinationAreSpecifiedUsingShortSyntax() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", a); + elements.register("b", b); + elements.register("c", c); + context.setIdentifierRegister(elements); + + Set<ModelItem> relationships = parser.parseExpression("relationship==a->b", context); + assertEquals(1, relationships.size()); + assertTrue(relationships.contains(aToB)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnAfferentCouplingExpressionWithAnElementIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("->b", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + + elements = parser.parseExpression("element==->b", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnEfferentCouplingExpressionWithAnElementIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("b->", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + + elements = parser.parseExpression("element==b->", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnAfferentAndEfferentCouplingExpressionWithAnElementIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("->b->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + + elements = parser.parseExpression("element==->b->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnAfferentAndEfferentCouplingExpressionWithAnElementExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + b.addTags("Tag 1"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("->element.tag==Tag 1->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + + elements = parser.parseExpression("element==->element.tag==Tag 1->", context); + assertEquals(3, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + assertTrue(elements.contains(c)); + } + + @Test + void test_parseExpression_ReturnsAllRelationships_WhenUsingTheWildcardRelationshipExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> relationships = parser.parseExpression("relationship==*->*", context); + assertEquals(2, relationships.size()); + assertTrue(relationships.contains(aToB)); + assertTrue(relationships.contains(bToC)); + + relationships = parser.parseExpression("* -> *", context); + assertEquals(2, relationships.size()); + assertTrue(relationships.contains(aToB)); + assertTrue(relationships.contains(bToC)); + + relationships = parser.parseExpression("relationship==*", context); + assertEquals(2, relationships.size()); + assertTrue(relationships.contains(aToB)); + assertTrue(relationships.contains(bToC)); + } + + @Test + void test_parseExpression_ReturnsElementsAndElementInstances_WhenUsingAnElementTagEqualsExpression() { + model.addPerson("User"); + SoftwareSystem ss = model.addSoftwareSystem("Software System"); + SoftwareSystemInstance ssi = model.addDeploymentNode("DN").add(ss); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Software System", context()); + assertEquals(2, elements.size()); + assertTrue(elements.contains(ss)); // this is tagged "Software System" + assertTrue(elements.contains(ssi)); // this is not tagged "Software System", but the element it's based upon is + } + + @Test + void test_parseExpression_ReturnsElementsAndElementInstances_WhenUsingAnElementTechnologyEqualsExpression() { + model.addPerson("User"); + SoftwareSystem ss = model.addSoftwareSystem("Software System"); + Container c = ss.addContainer("Container"); + c.setTechnology("Java"); + DeploymentNode dn = model.addDeploymentNode("DN"); + dn.setTechnology("EC2"); + InfrastructureNode in = dn.addInfrastructureNode("Infrastructure Node"); + in.setTechnology("ELB"); + ContainerInstance ci = dn.add(c); + + Set<ModelItem> elements = parser.parseExpression("element.technology==Java", context()); + assertEquals(2, elements.size()); + assertTrue(elements.contains(c)); // this has a technology property of "Java" + assertTrue(elements.contains(ci)); // this has no technology property, but the element it's based upon is + + elements = parser.parseExpression("element.technology==EC2", context()); + assertEquals(1, elements.size()); + assertTrue(elements.contains(dn)); + + elements = parser.parseExpression("element.technology==ELB", context()); + assertEquals(1, elements.size()); + assertTrue(elements.contains(in)); + } + + @Test + void test_parseExpression_ReturnsElementsAndElementInstances_WhenUsingAnElementPropertyEqualsExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + a.addProperty("Technical Debt", "Low"); + SoftwareSystem b = model.addSoftwareSystem("B"); + b.addProperty("Technical Debt", "Medium"); + SoftwareSystem c = model.addSoftwareSystem("C"); + c.addProperty("Technical Debt", "High"); + SoftwareSystem d = model.addSoftwareSystem("D"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + SoftwareSystemInstance ai = deploymentNode.add(a); + SoftwareSystemInstance bi = deploymentNode.add(b); + SoftwareSystemInstance ci = deploymentNode.add(c); + + Set<ModelItem> elements = parser.parseExpression("element.properties[Technical Debt]==High", context()); + assertEquals(2, elements.size()); + assertTrue(elements.contains(c)); // this has the property + assertTrue(elements.contains(ci)); // this doesn't have the property, but the element it's based upon does + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnElementTypeExpression() { + model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + context.setIdentifierRegister(new IdentifiersRegister()); + + Set<ModelItem> elements = parser.parseExpression("element.type==SoftwareSystem", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(softwareSystem)); + } + + @Test + void test_parseExpression_ThrowsAnException_WhenUsingAnElementParentExpressionAndTheElementDoesNotExist() { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + context.setIdentifierRegister(new IdentifiersRegister()); + + try { + parser.parseExpression("element.parent==a", context); + fail(); + } catch (Exception e) { + assertEquals("The parent element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingAnElementParentExpression() { + SoftwareSystem softwareSystemA = model.addSoftwareSystem("Software System A"); + Container container1 = softwareSystemA.addContainer("Container 1"); + SoftwareSystem softwareSystemB = model.addSoftwareSystem("Software System B"); + Container container2 = softwareSystemB.addContainer("Container 2"); + + ContainerView view = views.createContainerView(softwareSystemA, "key", "Description"); + ContainerViewDslContext context = new ContainerViewDslContext(view); + context.setWorkspace(workspace); + IdentifiersRegister identifiersRegister = new IdentifiersRegister(); + identifiersRegister.register("b", softwareSystemB); + context.setIdentifierRegister(identifiersRegister); + + Set<ModelItem> elements = parser.parseExpression("element.parent==b", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container2)); + } + + @Test + void test_parseExpression_ReturnsRelationshipsAndImpliedRelationships_WhenUsingARelationshipTagEqualsExpression() { + Person user = model.addPerson("User"); + SoftwareSystem a = model.addSoftwareSystem("A"); + Container aa = a.addContainer("AA"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Container bb = b.addContainer("BB"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Container cc = c.addContainer("CC"); + + Relationship r1 = user.uses(aa, "Uses"); + r1.addTags("Tag 1"); + Relationship r2 = user.uses(bb, "Uses"); + r2.addTags("Tag 2"); + Relationship r3 = user.uses(cc, "Uses"); + + Set<ModelItem> relationships = parser.parseExpression("relationship.tag==Tag 1", context()); + assertEquals(2, relationships.size()); + assertTrue(relationships.contains(r1)); + + Relationship impliedRelationship = user.getEfferentRelationshipWith(a); + assertTrue(relationships.contains(impliedRelationship)); + } + + @Test + void test_parseExpression_ReturnsRelationshipsAndImpliedRelationships_WhenUsingARelationshipPropertyEqualsExpression() { + Person user = model.addPerson("User"); + SoftwareSystem a = model.addSoftwareSystem("A"); + Container aa = a.addContainer("AA"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Container bb = b.addContainer("BB"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Container cc = c.addContainer("CC"); + + Relationship r1 = user.uses(aa, "Uses"); + r1.addProperty("Secure", "Yes"); + Relationship r2 = user.uses(bb, "Uses"); + r2.addProperty("Secure", "No"); + Relationship r3 = user.uses(cc, "Uses"); + + assertEquals(6, model.getRelationships().size()); + + Set<ModelItem> relationships = parser.parseExpression("relationship.properties[Secure]==Yes", context()); + assertEquals(2, relationships.size()); + assertTrue(relationships.contains(r1)); + + Relationship impliedRelationship = user.getEfferentRelationshipWith(a); + assertTrue(relationships.contains(impliedRelationship)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingElementNotEqualsIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("element!=c", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(a)); + assertTrue(elements.contains(b)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenUsingElementNotEqualsExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + Relationship aToB = a.uses(b, "Uses"); + Relationship bToC = b.uses(c, "Uses"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("a", a); + map.register("b", b); + map.register("c", c); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("element!=->b", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(c)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ExternalScriptDslContextTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExternalScriptDslContextTests.java new file mode 100644 index 000000000..922d5b287 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ExternalScriptDslContextTests.java @@ -0,0 +1,21 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +class ExternalScriptDslContextTests extends AbstractTests { + + @Test + void test_parseExternal_RunsTheScript_WhenAValidScriptFilenameIsSpecified() { + ExternalScriptDslContext context = new ExternalScriptDslContext(new WorkspaceDslContext(), new File("src/test/resources/dsl/workspace.dsl"), null, "test.kts"); + context.setWorkspace(workspace); + context.end(); + + assertNotNull(workspace.getModel().getPersonWithName("Kotlin")); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/FilteredViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/FilteredViewParserTests.java new file mode 100644 index 000000000..c34afec00 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/FilteredViewParserTests.java @@ -0,0 +1,139 @@ +package com.structurizr.dsl; + +import com.structurizr.view.FilteredView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FilteredViewParserTests extends AbstractTests { + + private FilteredViewParser parser = new FilteredViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("filtered", "baseKey", "key", "mode", "tags", "description", "extra")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Too many tokens, expected: filtered <baseKey> <include|exclude> <tags> [key] [description]", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheBaseKeyIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("filtered")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: filtered <baseKey> <include|exclude> <tags> [key] [description]", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheModeIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("filtered", "baseKey")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: filtered <baseKey> <include|exclude> <tags> [key] [description]", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheTagsAreMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("filtered", "baseKey", "include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: filtered <baseKey> <include|exclude> <tags> [key] [description]", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheModeIsInvalid() { + DslContext context = context(); + views.createDeploymentView("deployment", "Description"); + try { + parser.parse(context, tokens("filtered", "baseKey", "mode", "Tag 1, Tag 2", "key")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Filter mode should be include or exclude", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheBaseViewDoesNotExist() { + DslContext context = context(); + try { + parser.parse(context, tokens("filtered", "baseKey", "include", "Tag 1, Tag 2", "key")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The view \"baseKey\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheBaseViewIsNotAStaticOrDeploymentView() { + DslContext context = context(); + views.createDynamicView("baseKey", "Description"); + try { + parser.parse(context, tokens("filtered", "baseKey", "include", "Tag 1, Tag 2", "key")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The view \"baseKey\" must be a System Landscape, System Context, Container, Component, or Deployment view", iae.getMessage()); + } + } + + @Test + void test_parse_CreatesAFilteredView() { + DslContext context = context(); + views.createSystemLandscapeView("SystemLandscape", "Description"); + parser.parse(context, tokens("filtered", "SystemLandscape", "include", "Tag 1, Tag 2")); + List<FilteredView> views = new ArrayList<>(context.getWorkspace().getViews().getFilteredViews()); + + assertEquals(1, views.size()); + assertEquals("Filtered-001", views.get(0).getKey()); + assertEquals(2, views.get(0).getTags().size()); + assertTrue(views.get(0).getTags().contains("Tag 1")); + assertTrue(views.get(0).getTags().contains("Tag 2")); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAFilteredViewWithAKey() { + DslContext context = context(); + views.createSystemLandscapeView("SystemLandscape", "Description"); + parser.parse(context, tokens("filtered", "SystemLandscape", "include", "Tag 1, Tag 2", "key")); + List<FilteredView> views = new ArrayList<>(context.getWorkspace().getViews().getFilteredViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals(2, views.get(0).getTags().size()); + assertTrue(views.get(0).getTags().contains("Tag 1")); + assertTrue(views.get(0).getTags().contains("Tag 2")); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesAFilteredViewWithAKeyAndDescription() { + DslContext context = context(); + views.createSystemLandscapeView("SystemLandscape", "Description"); + parser.parse(context, tokens("filtered", "SystemLandscape", "include", "Tag 1, Tag 2", "key", "Description")); + List<FilteredView> views = new ArrayList<>(context.getWorkspace().getViews().getFilteredViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals(2, views.get(0).getTags().size()); + assertTrue(views.get(0).getTags().contains("Tag 1")); + assertTrue(views.get(0).getTags().contains("Tag 2")); + assertEquals("Description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementParserTests.java new file mode 100644 index 000000000..f9ff1d3ba --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementParserTests.java @@ -0,0 +1,64 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Person; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FindElementParserTests extends AbstractTests { + + private final FindElementParser parser = new FindElementParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!element", "name", "tokens")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !element <identifier|canonical name>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheIdentifierOrCanonicalNameIsNotSpecified() { + try { + parser.parse(context(), tokens("!element")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !element <identifier|canonical name>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheReferencedElementCannotBeFound() { + try { + parser.parse(context(), tokens("!element", "Person://User")); + fail(); + } catch (Exception e) { + assertEquals("An element identified by \"Person://User\" could not be found", e.getMessage()); + } + } + + @Test + void test_parse_FindsAnElementByCanonicalName() { + Person user = workspace.getModel().addPerson("User"); + ModelItem element = parser.parse(context(), tokens("!element", "Person://User")); + + assertSame(user, element); + } + + @Test + void test_parse_FindsAnElementByIdentifier() { + Person user = workspace.getModel().addPerson("User"); + + ModelDslContext context = context(); + IdentifiersRegister register = new IdentifiersRegister(); + register.register("user", user); + context.setIdentifierRegister(register); + + ModelItem modelItem = parser.parse(context, tokens("!element", "user")); + assertSame(modelItem, user); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementsParserTests.java new file mode 100644 index 000000000..49bf3ddf2 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindElementsParserTests.java @@ -0,0 +1,55 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class FindElementsParserTests extends AbstractTests { + + private final FindElementsParser parser = new FindElementsParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!elements", "expression", "tokens")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !elements <expression>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreNoElementsFound() { + try { + parser.parse(context(), tokens("!elements", "expression")); + fail(); + } catch (Exception e) { + assertEquals("No elements found for expression \"expression\"", e.getMessage()); + } + } + + @Test + void test_parse_FindsElementsByExpression() { + Container application = model.addSoftwareSystem("Software System").addContainer("Application"); + Component componentA = application.addComponent("A"); + Component componentB = application.addComponent("B"); + Component componentC = application.addComponent("C"); + + ContainerDslContext context = new ContainerDslContext(application); + context.setWorkspace(workspace); + IdentifiersRegister register = new IdentifiersRegister(); + register.register("application", application); + context.setIdentifierRegister(register); + + Set<Element> elements = parser.parse(context, tokens("!elements", "element.parent==application")); + assertTrue(elements.contains(componentA)); + assertTrue(elements.contains(componentB)); + assertTrue(elements.contains(componentC)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipParserTests.java new file mode 100644 index 000000000..91d9f6dd2 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipParserTests.java @@ -0,0 +1,72 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Person; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FindRelationshipParserTests extends AbstractTests { + + private final FindRelationshipParser parser = new FindRelationshipParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!relationship", "name", "tokens")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !relationship <identifier|canonical name>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheIdentifierOrCanonicalNameIsNotSpecified() { + try { + parser.parse(context(), tokens("!relationship")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !relationship <identifier|canonical name>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheReferencedRelationshipCannotBeFound() { + try { + parser.parse(context(), tokens("!relationship", "rel")); + fail(); + } catch (Exception e) { + assertEquals("A relationship identified by \"rel\" could not be found", e.getMessage()); + } + } + + @Test + void test_parse_FindsARelationshipByCanonicalName() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Description"); + + ModelDslContext context = context(); + + ModelItem modelItem = parser.parse(context, tokens("!relationship", "Relationship://SoftwareSystem://A -> SoftwareSystem://B (Description)")); + assertSame(modelItem, relationship); + } + + @Test + void test_parse_FindsARelationshipByIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Description"); + + ModelDslContext context = context(); + IdentifiersRegister register = new IdentifiersRegister(); + register.register("rel", relationship); + context.setIdentifierRegister(register); + + ModelItem modelItem = parser.parse(context, tokens("!relationship", "rel")); + assertSame(modelItem, relationship); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipsParserTests.java new file mode 100644 index 000000000..916f23407 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/FindRelationshipsParserTests.java @@ -0,0 +1,55 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class FindRelationshipsParserTests extends AbstractTests { + + private final FindRelationshipsParser parser = new FindRelationshipsParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!relationships", "expression", "tokens")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !relationships <expression>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreNoRelationshipsFound() { + try { + parser.parse(context(), tokens("!relationships", "expression")); + fail(); + } catch (Exception e) { + assertEquals("No relationships found for expression \"expression\"", e.getMessage()); + } + } + + @Test + void test_parse_FindsRelationshipsByExpression() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + Relationship relationship1 = a.uses(b, "Uses"); + Relationship relationship2 = b.uses(c, "Uses"); + Relationship relationship3 = a.uses(c, "Uses"); + + ModelDslContext context = context(); + IdentifiersRegister register = new IdentifiersRegister(); + register.register("c", c); + context.setIdentifierRegister(register); + + Set<Relationship> relationships = parser.parse(context, tokens("!relationships", "*->c")); + assertTrue(relationships.contains(relationship2)); + assertTrue(relationships.contains(relationship3)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/GroupParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/GroupParserTests.java new file mode 100644 index 000000000..b4415f1d1 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/GroupParserTests.java @@ -0,0 +1,130 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Component; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GroupParserTests extends AbstractTests { + + private final GroupParser parser = new GroupParser(); + + @Test + void parseContext_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseContext(null, tokens("group", "name", "{", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: group <name> {", e.getMessage()); + } + } + + @Test + void parseContext_ThrowsAnException_WhenTheNameIsMissing() { + try { + parser.parseContext(null, tokens("group")); + fail(); + } catch (Exception e) { + assertEquals("Expected: group <name> {", e.getMessage()); + } + } + + @Test + void parseContext_ThrowsAnException_WhenTheBraceIsMissing() { + try { + parser.parseContext(null, tokens("group", "Name", "foo")); + fail(); + } catch (Exception e) { + assertEquals("Expected: group <name> {", e.getMessage()); + } + } + + @Test + void parseContext() { + ElementGroup group = parser.parseContext(context(), tokens("group", "Group 1", "{")); + assertEquals("Group 1", group.getName()); + assertTrue(group.getElements().isEmpty()); + } + + @Test + void parseContext_NestedGroup_ThrowsAnExceptionWhenNestedGroupsAreNotConfigured() { + ModelDslContext context = new ModelDslContext(new ElementGroup("Group 1")); + context.setWorkspace(workspace); + + try { + parser.parseContext(context, tokens("group", "Group 2", "{")); + fail(); + } catch (Exception e) { + assertEquals("To use nested groups, please define a model property named structurizr.groupSeparator", e.getMessage()); + } + } + + @Test + void parseContext_NestedGroup() { + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + ModelDslContext context = new ModelDslContext(new ElementGroup("Group 1")); + context.setWorkspace(workspace); + + ElementGroup group = parser.parseContext(context, tokens("group", "Group 2", "{")); + assertEquals("Group 1/Group 2", group.getName()); + assertTrue(group.getElements().isEmpty()); + } + + @Test + void parseProperty_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseProperty(null, tokens("group", "name", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: group <name>", e.getMessage()); + } + } + + @Test + void parseProperty_ThrowsAnException_WhenTheNameIsMissing() { + try { + parser.parseProperty(null, tokens("group")); + fail(); + } catch (Exception e) { + assertEquals("Expected: group <name>", e.getMessage()); + } + } + + @Test + void parseProperty() { + Component component = workspace.getModel().addSoftwareSystem("Name").addContainer("Name").addComponent("Name"); + ComponentDslContext context = new ComponentDslContext(component); + context.setWorkspace(workspace); + + parser.parseProperty(context, tokens("group", "Group 1")); + assertEquals("Group 1", component.getGroup()); + } + + @Test + void parseProperty_NestedGroup_ThrowsAnExceptionWhenNestedGroupsAreNotConfigured() { + Component component = workspace.getModel().addSoftwareSystem("Name").addContainer("Name").addComponent("Name"); + component.setGroup("Group 1"); + ComponentDslContext context = new ComponentDslContext(component); + context.setWorkspace(workspace); + + try { + parser.parseProperty(context, tokens("group", "Group 2")); + fail(); + } catch (Exception e) { + assertEquals("To use nested groups, please define a model property named structurizr.groupSeparator", e.getMessage()); + } + } + + @Test + void parseProperty_NestedGroup() { + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + Component component = workspace.getModel().addSoftwareSystem("Name").addContainer("Name").addComponent("Name"); + component.setGroup("Group 1"); + ComponentDslContext context = new ComponentDslContext(component); + context.setWorkspace(workspace); + + parser.parseProperty(context, tokens("group", "Group 2")); + assertEquals("Group 1/Group 2", component.getGroup()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/HealthCheckParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/HealthCheckParserTests.java new file mode 100644 index 000000000..9ab9c60b4 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/HealthCheckParserTests.java @@ -0,0 +1,127 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.HttpHealthCheck; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.SoftwareSystemInstance; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class HealthCheckParserTests extends AbstractTests { + + private HealthCheckParser parser = new HealthCheckParser(); + + private SoftwareSystemInstance softwareSystemInstance; + + @BeforeEach + void setUp() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name"); + DeploymentNode deploymentNode = model.addDeploymentNode("Deployment Node"); + softwareSystemInstance = deploymentNode.add(softwareSystem); + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "name", "url", "interval", "timeout", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: healthCheck <name> <url> [interval] [timeout]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoNameIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck")); + fail(); + } catch (Exception e) { + assertEquals("Expected: healthCheck <name> <url> [interval] [timeout]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoUrlIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name")); + fail(); + } catch (Exception e) { + assertEquals("Expected: healthCheck <name> <url> [interval] [timeout]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidIntervalIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health", "hello")); + fail(); + } catch (Exception e) { + assertEquals("The interval of \"hello\" is not valid - it must be a positive integer (number of seconds)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenANegativeIntervalIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health", "-1")); + fail(); + } catch (Exception e) { + assertEquals("The interval must be a positive integer (number of seconds)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidTimeoutIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health", "60", "hello")); + fail(); + } catch (Exception e) { + assertEquals("The timeout of \"hello\" is not valid - it must be zero or a positive integer (number of milliseconds)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenANegativeTimeoutIsSpecified() { + try { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health", "60", "-1")); + fail(); + } catch (Exception e) { + assertEquals("The timeout must be zero or a positive integer (number of milliseconds)", e.getMessage()); + } + } + + @Test + void test_parse_AddsAHealthCheck_WhenTheNameAndUrlAreSpecified() { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health")); + + HttpHealthCheck healthCheck = softwareSystemInstance.getHealthChecks().iterator().next(); + assertEquals("Name", healthCheck.getName()); + assertEquals("https://example.com/health", healthCheck.getUrl()); + assertEquals(60, healthCheck.getInterval()); + assertEquals(0, healthCheck.getTimeout()); + } + + @Test + void test_parse_AddsAHealthCheck_WhenAllPropertiesAreSpecified() { + SoftwareSystemInstanceDslContext context = new SoftwareSystemInstanceDslContext(softwareSystemInstance); + parser.parse(context, tokens("healthCheck", "Name", "https://example.com/health", "120", "2000")); + + HttpHealthCheck healthCheck = softwareSystemInstance.getHealthChecks().iterator().next(); + assertEquals("Name", healthCheck.getName()); + assertEquals("https://example.com/health", healthCheck.getUrl()); + assertEquals(120, healthCheck.getInterval()); + assertEquals(2000, healthCheck.getTimeout()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierRegisterTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierRegisterTests.java new file mode 100644 index 000000000..8a141ce65 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierRegisterTests.java @@ -0,0 +1,123 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class IdentifierRegisterTests extends AbstractTests { + + private final IdentifiersRegister register = new IdentifiersRegister(); + + @Test + void test_validateIdentifierName() { + new IdentifiersRegister().validateIdentifierName("a"); + new IdentifiersRegister().validateIdentifierName("abc"); + new IdentifiersRegister().validateIdentifierName("ABC"); + new IdentifiersRegister().validateIdentifierName("softwaresystem"); + new IdentifiersRegister().validateIdentifierName("SoftwareSystem"); + new IdentifiersRegister().validateIdentifierName("123456"); + new IdentifiersRegister().validateIdentifierName("_softwareSystem"); + new IdentifiersRegister().validateIdentifierName("SoftwareSystem-1"); + + try { + new IdentifiersRegister().validateIdentifierName("-softwareSystem"); + fail(); + } catch (Exception e) { + assertEquals("Identifiers cannot start with a - character", e.getMessage()); + } + + try { + new IdentifiersRegister().validateIdentifierName("SoftwareSystém"); + fail(); + } catch (Exception e) { + assertEquals("Identifiers can only contain the following characters: a-zA-Z0-9_-", e.getMessage()); + } + } + + @Test + void test_register_ThrowsAnException_WhenTheElementHasAlreadyBeenRegisteredWithADifferentIdentifier() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + try { + register.register("a", softwareSystem); + register.register("x", softwareSystem); + fail(); + } catch (Exception e) { + assertEquals("The element is already registered with an identifier of \"a\"", e.getMessage()); + } + } + + @Test + void test_register_ThrowsAnException_WhenTheElementHasAlreadyBeenRegisteredWithAnInternalIdentifier() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + try { + register.register("", softwareSystem); + register.register("x", softwareSystem); + fail(); + } catch (Exception e) { + assertEquals("Please assign an identifier to \"SoftwareSystem://Software System\" before using it", e.getMessage()); + } + } + + @Test + void test_register_WhenTheElementHasAlreadyBeenRegisteredWithTheSameIdentifierCasedDifferently() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + register.register("SoftwareSystem", softwareSystem); + register.register("softwareSystem", softwareSystem); + register.register("softwaresystem", softwareSystem); + register.register("SOFTWARESYSTEM", softwareSystem); + } + + @Test + void test_getElement() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + register.register("SoftwareSystem", softwareSystem); + + assertSame(softwareSystem, register.getElement("SoftwareSystem")); + assertSame(softwareSystem, register.getElement("softwareSystem")); + assertSame(softwareSystem, register.getElement("softwaresystem")); + assertSame(softwareSystem, register.getElement("SOFTWARESYSTEM")); + } + + @Test + void test_getRelationships() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship rel = a.uses(b, "Uses"); + register.register("Rel", rel); + + assertSame(rel, register.getRelationship("Rel")); + assertSame(rel, register.getRelationship("rel")); + assertSame(rel, register.getRelationship("REL")); + } + + @Test + void test_register_ThrowsAnException_WhenTheRelationshipHasAlreadyBeenRegisteredWithADifferentIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship rel = a.uses(b, "Uses"); + try { + register.register("Rel1", rel); + register.register("Rel2", rel); + fail(); + } catch (Exception e) { + assertEquals("The relationship is already registered with an identifier of \"Rel1\"", e.getMessage()); + } + } + + @Test + void test_register_ThrowsAnException_WhenTheRelationshipHasAlreadyBeenRegisteredWithAnInternalIdentifier() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + Relationship rel = a.uses(b, "Uses"); + try { + register.register("", rel); + register.register("Rel", rel); + fail(); + } catch (Exception e) { + assertEquals("Please assign an identifier to \"Relationship://SoftwareSystem://A -> SoftwareSystem://B (Uses)\" before using it", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierScopeParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierScopeParserTests.java new file mode 100644 index 000000000..7ce861a1a --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/IdentifierScopeParserTests.java @@ -0,0 +1,46 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class IdentifierScopeParserTests extends AbstractTests { + + private IdentifierScopeParser parser = new IdentifierScopeParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!identifiers", "hierarchical", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !identifiers <flat|hierarchical>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoScopeIsSpecified() { + try { + parser.parse(context(), tokens("!identifiers")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !identifiers <flat|hierarchical>", e.getMessage()); + } + } + + @Test + void test_parse_SetsTheScope_WhenLocalIsSpecified() { + IdentifierScope scope = parser.parse(context(), tokens("!identifiers", "hierarchical")); + + assertEquals(IdentifierScope.Hierarchical, scope); + } + + @Test + void test_parse_SetsTheScope_WhenGlobalIsSpecified() { + IdentifierScope scope = parser.parse(context(), tokens("!identifiers", "flat")); + + assertEquals(IdentifierScope.Flat, scope); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewContentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewContentParserTests.java new file mode 100644 index 000000000..40cb94dab --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewContentParserTests.java @@ -0,0 +1,412 @@ +package com.structurizr.dsl; + +import com.structurizr.importer.diagrams.kroki.KrokiImporter; +import com.structurizr.importer.diagrams.mermaid.MermaidImporter; +import com.structurizr.importer.diagrams.plantuml.PlantUMLImporter; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ImageViewContentParserTests extends AbstractTests { + + private ImageViewContentParser parser; + private ImageView imageView; + + @BeforeEach + void setUp() { + imageView = workspace.getViews().createImageView("key"); + } + + @Test + void test_parsePlantUML_ThrowsAnException_WithTooFewTokens() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + parser = new ImageViewContentParser(); + parser.parsePlantUML(context, null, tokens("plantuml")); + fail(); + } catch (Exception e) { + assertEquals("Expected: plantuml <source|file|url|viewKey>", e.getMessage()); + } + } + + @Test + void test_parsePlantUML_ThrowsAnException_WhenUsingAFileNameInRestrictedMode() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + parser = new ImageViewContentParser(); + parser.parsePlantUML(context, null, tokens("plantuml", "image.puml")); + fail(); + } catch (Exception e) { + assertEquals("plantuml <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + void test_parsePlantUML_ThrowsAnException_WhenUsingAHttpsUrlAndHttpsIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTPS); + parser = new ImageViewContentParser(); + parser.parsePlantUML(context, null, tokens("plantuml", "https://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parsePlantUML_ThrowsAnException_WhenUsingAHttpUrlAndHttpIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTP); + parser = new ImageViewContentParser(); + parser.parsePlantUML(context, null, tokens("plantuml", "http://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parsePlantUML_Source() { + String source = """ + @startuml + Bob -> Alice : hello + @enduml"""; + + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + parser = new ImageViewContentParser(); + parser.parsePlantUML(new ImageViewDslContext(imageView), null, tokens("plantuml", source)); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", imageView.getContent()); + } + + @Test + void test_parsePlantUML_Source_Light() { + String source = """ + @startuml + Bob -> Alice : hello + @enduml"""; + + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Light); + parser.parsePlantUML(context, null, tokens("plantuml", source)); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", imageView.getContentLight()); + } + + @Test + void test_parsePlantUML_Source_Dark() { + String source = """ + @startuml + Bob -> Alice : hello + @enduml"""; + + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Dark); + parser.parsePlantUML(context, null, tokens("plantuml", source)); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", imageView.getContentDark()); + } + + @Test + void test_parsePlantUML_WithViewKey() { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description").addAllElements(); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + + parser = new ImageViewContentParser(); + parser.parsePlantUML(context, null, tokens("plantuml", "SystemLandscape")); + assertEquals("System Landscape View", imageView.getTitle()); + assertEquals("Description", imageView.getDescription()); + assertNull(imageView.getContent()); + assertEquals("https://plantuml.com/plantuml/svg/ZLBBJiCm4BpxArPmXfQgS0X9rF0IXt8Xg3q01pVP9bOTRsHlr0VYtt6Qj1n0Y9LihMTjpth64yVISbDfmOerGkZK3eFHE4wtZh62gJIvosIDC5Eu3WTjENupnsrtw3AhQbPa-g8G3XaSrj9A9Wk630gc6fXWGSnKGQuiPkqHKQeSmHDP9DxMA4JeUAlz9G2MYE739m0tCbiLbXgJtv8c6y3fSX_N--e36JxWutsq-ASVWm7SQwpGi5-Sz-dPytoZ5_DPaoTHz2-2gJBuaw33qxRT08RVo4kfifL1vm8O_TLWXwUjZZ3gaKUoQkTHgHEj2jEs6q3cPxJTXhIKEQsLAOwKJtAZggQQgvpB0CQNm-xntenEID5ABKtXlJs9ekHWtSLL_9hIajVI8dHUl_S6da0O_gPL78Dqa0WnGPFx7_C5", imageView.getContentLight()); + assertEquals("https://plantuml.com/plantuml/svg/ZLBBJiCm4BpxArRb37seS0X9rF0IXt8Xg3q01pTU4gkEDxAtwWFnxpXDAGSGOYLRwrdRivxnnBDqlAgDOCq68VPwXz5edEPRprZ3L5hb2zaWp3IkutvRJb_iSTiD-iBfXZNPGr48ZmmU6-aaamDB5WLJ0qom86QgGMc7HNj4L5eX12A7nDi6XOWzRqsu1C0HCRo71E1A5ilIqSggQpBa8ZWPxkDoNxqZorzuiOyM_mYZtuTRWpLQ3ekpGthwED-OnNosKbcI_8jWgYt-9EZml6qtWi4tybJfOcdH-mX6VpNOuNch8up67N9FJky2AarcT6dRTYCemeoksv1NKj5Qs_98-I0tkbxLSwsuYc1yFkWU7ypeX1IjrDAMmTjUacHVrWqlqkUStdWj7KBdzUl1m1x4yMzQfIb83vaG4xGg_9XF", imageView.getContentDark()); + } + + @Test + void test_parseMermaid_ThrowsAnException_WithTooFewTokens() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + parser = new ImageViewContentParser(); + parser.parseMermaid(context, null, tokens("mermaid")); + fail(); + } catch (Exception e) { + assertEquals("Expected: mermaid <source|file|url|viewKey>", e.getMessage()); + } + } + + @Test + void test_parseMermaid_ThrowsAnException_WhenUsingAFileNameInRestrictedMode() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + parser = new ImageViewContentParser(); + parser.parseMermaid(context, null, tokens("mermaid", "image.mmd")); + fail(); + } catch (Exception e) { + assertEquals("mermaid <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseMermaid_ThrowsAnException_WhenUsingAHttpsUrlAndHttpsIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTPS); + parser = new ImageViewContentParser(); + parser.parseMermaid(context, null, tokens("mermaid", "https://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseMermaid_ThrowsAnException_WhenUsingAHttpUrlAndHttpIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTP); + parser = new ImageViewContentParser(); + parser.parseMermaid(context, null, tokens("mermaid", "http://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseMermaid_Source() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + parser = new ImageViewContentParser(); + parser.parseMermaid(new ImageViewDslContext(imageView), null, tokens("mermaid", source)); + assertEquals("https://mermaid.ink/svg/pako:eJxVj70OgjAUhV_lppMm8gIMJlKUhUQHtspwAxfbSH9SaoihvLsgi571-85JzgSssS2xlHW9HRuJPkCV3w0sOQkuvRqCxqGGJDnGggJoa-gdIdsVFgZpnVPmsd_8bJWAT-WqEQSpzHPeEP_2r4Yi5KJEF6yrf0k12ghnoW5ymf8n0tPSuogO0w6TBj1w9DU7ANPkNaqWpRMLkvR6oqUOX31g8_wBLY9E1w==", imageView.getContent()); + } + + @Test + void test_parseMermaid_Source_Light() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Light); + parser.parseMermaid(context, null, tokens("mermaid", source)); + assertEquals("https://mermaid.ink/svg/pako:eJxVj70OgjAUhV_lppMm8gIMJlKUhUQHtspwAxfbSH9SaoihvLsgi571-85JzgSssS2xlHW9HRuJPkCV3w0sOQkuvRqCxqGGJDnGggJoa-gdIdsVFgZpnVPmsd_8bJWAT-WqEQSpzHPeEP_2r4Yi5KJEF6yrf0k12ghnoW5ymf8n0tPSuogO0w6TBj1w9DU7ANPkNaqWpRMLkvR6oqUOX31g8_wBLY9E1w==", imageView.getContentLight()); + } + + @Test + void test_parseMermaid_Source_Dark() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Dark); + parser.parseMermaid(context, null, tokens("mermaid", source)); + assertEquals("https://mermaid.ink/svg/pako:eJxVj70OgjAUhV_lppMm8gIMJlKUhUQHtspwAxfbSH9SaoihvLsgi571-85JzgSssS2xlHW9HRuJPkCV3w0sOQkuvRqCxqGGJDnGggJoa-gdIdsVFgZpnVPmsd_8bJWAT-WqEQSpzHPeEP_2r4Yi5KJEF6yrf0k12ghnoW5ymf8n0tPSuogO0w6TBj1w9DU7ANPkNaqWpRMLkvR6oqUOX31g8_wBLY9E1w==", imageView.getContentDark()); + } + + @Test + void test_parseMermaid_WithViewKey() { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description").addAllElements(); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + + parser = new ImageViewContentParser(); + parser.parseMermaid(context, null, tokens("mermaid", "SystemLandscape")); + assertEquals("System Landscape View", imageView.getTitle()); + assertEquals("Description", imageView.getDescription()); + assertEquals("https://mermaid.ink/svg/pako:eJxtkM1rwkAQxf-VYYrkEqmCUNjaQD33luLFeFizs8nifoTd1WjF_72Ja6Ffc5qB33vzeBfA2glCho3nXQvvq8oCaGX3ZTxrAkGSH3QEqbRmD_I2lR2ZcNgliVB8WAxsKizPIZKBN25FqHlHsFbUV7gd-UGRHO_4d8c8RO_29PMBwHywXAp1TMqXTDobpz2ppo0Mdk6LrHhdPg5A8YcK6oMYPM0mz2C4b5SdRtcxmHWnrNiUTsaee4KUd5s8fuWc_59wcZu8dtr5ryvlJSswBzTkDVcC2QVjS2as9l4iXq-fYuV7iw==", imageView.getContent()); + } + + @Test + void test_parseKroki_ThrowsAnException_WithTooFewTokens() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + parser = new ImageViewContentParser(); + parser.parseKroki(context, null, tokens("kroki")); + fail(); + } catch (Exception e) { + assertEquals("Expected: kroki <format> <source|file|url>", e.getMessage()); + } + } + + @Test + void test_parseKroki_ThrowsAnException_WhenUsingAFileNameInRestrictedMode() { + try { + parser = new ImageViewContentParser(); + parser.parseKroki(new ImageViewDslContext(imageView), null, tokens("kroki", "plantuml", "image.puml")); + fail(); + } catch (Exception e) { + assertEquals("kroki plantuml <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseKroki_ThrowsAnException_WhenUsingAHttpsUrlAndHttpsIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTPS); + parser = new ImageViewContentParser(); + parser.parseKroki(context, null, tokens("kroki", "plantuml", "https://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseKroki_ThrowsAnException_WhenUsingAHttpUrlAndHttpIsNotEnabled() { + try { + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setWorkspace(workspace); + context.getFeatures().disable(Features.HTTP); + parser = new ImageViewContentParser(); + parser.parseKroki(context, null, tokens("kroki", "plantuml", "http://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Image views via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseKroki_Source() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + parser = new ImageViewContentParser(); + parser.parseKroki(new ImageViewDslContext(imageView), null, tokens("kroki", "mermaid", source)); + assertEquals("https://kroki.io/mermaid/png/eNpVjLEOwiAURXe_4o068AMOJpZqlyZ16EYYXhrwES2PAEljxH-XdtK7nnOuffIyEcYMY7uDurOSFF3KMyYNQpxKZzLM7M2rQLPvGBJxCM7fD5verA7Id79aBjI5__hsRG714E2BVvUYMgf9A8aFC1yUu1H9_gMUTW2uyuLRopgwgsSovzbHM0c=", imageView.getContent()); + } + + @Test + void test_parseKroki_Source_Light() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Light); + parser.parseKroki(context, null, tokens("kroki", "mermaid", source)); + assertEquals("https://kroki.io/mermaid/png/eNpVjLEOwiAURXe_4o068AMOJpZqlyZ16EYYXhrwES2PAEljxH-XdtK7nnOuffIyEcYMY7uDurOSFF3KMyYNQpxKZzLM7M2rQLPvGBJxCM7fD5verA7Id79aBjI5__hsRG714E2BVvUYMgf9A8aFC1yUu1H9_gMUTW2uyuLRopgwgsSovzbHM0c=", imageView.getContentLight()); + } + + @Test + void test_parseKroki_Source_Dark() { + String source = """ + flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car]"""; + + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Dark); + parser.parseKroki(context, null, tokens("kroki", "mermaid", source)); + assertEquals("https://kroki.io/mermaid/png/eNpVjLEOwiAURXe_4o068AMOJpZqlyZ16EYYXhrwES2PAEljxH-XdtK7nnOuffIyEcYMY7uDurOSFF3KMyYNQpxKZzLM7M2rQLPvGBJxCM7fD5verA7Id79aBjI5__hsRG714E2BVvUYMgf9A8aFC1yUu1H9_gMUTW2uyuLRopgwgsSovzbHM0c=", imageView.getContentDark()); + } + + @Test + void test_parseImage_ThrowsAnException_WhenUsingAFileNameInRestrictedMode() { + try { + parser = new ImageViewContentParser(); + parser.parseImage(new ImageViewDslContext(imageView), null, tokens("image", "image.png")); + fail(); + } catch (Exception e) { + assertEquals("image <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + @Tag("IntegrationTest") + void test_parseImage() { + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.getFeatures().enable(Features.HTTPS); + context.getHttpClient().allow(".*"); + + parser.parseImage(context, null, tokens("image", "https://static.structurizr.com/img/structurizr-banner.png")); + assertEquals("https://static.structurizr.com/img/structurizr-banner.png", imageView.getContent()); + } + + @Test + @Tag("IntegrationTest") + void test_parseImage_Url_Light() { + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Light); + context.getFeatures().enable(Features.HTTPS); + context.getHttpClient().allow(".*"); + + parser.parseImage(context, null, tokens("image", "https://static.structurizr.com/img/structurizr-banner.png")); + assertEquals("https://static.structurizr.com/img/structurizr-banner.png", imageView.getContentLight()); + } + + @Test + @Tag("IntegrationTest") + void test_parseImage_Url_Dark() { + parser = new ImageViewContentParser(); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.setColorScheme(ColorScheme.Dark); + context.getFeatures().enable(Features.HTTPS); + context.getHttpClient().allow(".*"); + + parser.parseImage(context, null, tokens("image", "https://static.structurizr.com/img/structurizr-banner.png")); + assertEquals("https://static.structurizr.com/img/structurizr-banner.png", imageView.getContentDark()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewDslContextTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewDslContextTests.java new file mode 100644 index 000000000..b0e450edb --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewDslContextTests.java @@ -0,0 +1,31 @@ +package com.structurizr.dsl; + +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ImageViewDslContextTests extends AbstractTests { + + @Test + void end_ThrowsAnException_WhenThereIsNoContent() { + try { + ImageView imageView = workspace.getViews().createImageView("key"); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.end(); + fail(); + } catch (Exception e) { + assertEquals("The image view \"key\" has no content", e.getMessage()); + } + } + + @Test + void end_WhenThereIsContent() { + ImageView imageView = workspace.getViews().createImageView("key"); + imageView.setContent("http://example.com/image.png"); + ImageViewDslContext context = new ImageViewDslContext(imageView); + context.end(); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewParserTests.java new file mode 100644 index 000000000..fb2260f0a --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImageViewParserTests.java @@ -0,0 +1,62 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ImageViewParserTests extends AbstractTests { + + private final ImageViewParser parser = new ImageViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("image", "*", "key", "extra")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Too many tokens, expected: image <*|element identifier> [key] {", iae.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + DslContext context = context(); + try { + parser.parse(context, tokens("image", "element", "key")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"element\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parse_CreatesAnImageView() { + DslContext context = context(); + parser.parse(context, tokens("image", "*", "key")); + + ImageView imageView = (ImageView)context.getWorkspace().getViews().getViewWithKey("key"); + assertEquals("key", imageView.getKey()); + assertNull(imageView.getElement()); + assertNull(imageView.getElementId()); + } + + + @Test + void test_parse_CreatesAnImageViewForAnElement() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + parser.parse(context, tokens("image", "softwaresystem", "key")); + + ImageView imageView = (ImageView)context.getWorkspace().getViews().getViewWithKey("key"); + assertEquals("key", imageView.getKey()); + assertSame(softwareSystem, imageView.getElement()); + assertEquals(softwareSystem.getId(), imageView.getElementId()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ImplicitRelationshipParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImplicitRelationshipParserTests.java new file mode 100644 index 000000000..706454d33 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImplicitRelationshipParserTests.java @@ -0,0 +1,273 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class ImplicitRelationshipParserTests extends AbstractTests { + + private ImplicitRelationshipParser parser = new ImplicitRelationshipParser(); + private Archetype archetype = new Archetype("name", "type"); + + private ElementDslContext context(Person person) { + PersonDslContext context = new PersonDslContext(person); + context.setWorkspace(workspace); + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + + return context; + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse((ElementDslContext)null, tokens("->", "destination", "description", "technology", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: -> <identifier> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationIdentifierIsMissing() { + try { + parser.parse((ElementDslContext)null, tokens("->"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: -> <identifier> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationElementIsNotDefined() { + Person user = model.addPerson("User", "Description"); + ElementDslContext context = context(user); + IdentifiersRegister elements = new IdentifiersRegister(); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("->", "destination"), archetype); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"destination\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_AddsTheRelationship() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescription() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination", "Uses"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("", r.getTechnology()); + assertEquals("Relationship", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnology() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination", "Uses", "HTTP"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnologyAndTags() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("Relationship,Tag 1,Tag 2", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipWithADescriptionAndTechnologyAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + + assertEquals(1, model.getRelationships().size()); + Relationship r = model.getRelationships().iterator().next(); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); // overridden from archetype + assertEquals("HTTP", r.getTechnology()); // overridden from archetype + assertEquals("Relationship,Default Tag,Tag 1,Tag 2", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipAndImplicitRelationshipsWithADescriptionAndTechnologyAndTags() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + ElementDslContext context = context(user); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("destination", container); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + parser.parse(context, tokens("->", "destination", "Uses", "HTTP", "Tag 1,Tag 2"), archetype); + assertEquals(2, model.getRelationships().size()); + + // this is the relationship that was created + Relationship r = user.getEfferentRelationshipWith(container); + assertSame(user, r.getSource()); + assertSame(container, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("Relationship,Tag 1,Tag 2", r.getTags()); + + // and this is an implied relationship + r = user.getEfferentRelationshipWith(softwareSystem); + assertSame(user, r.getSource()); + assertSame(softwareSystem, r.getDestination()); + assertEquals("Uses", r.getDescription()); + assertEquals("HTTP", r.getTechnology()); + assertEquals("", r.getTags()); + } + + @Test + void test_parse_AddsTheRelationshipToAllSoftwareSystemInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(softwareSystem); + devDeploymentNode.add(softwareSystem); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + SoftwareSystemInstance liveSoftwareSystemInstance1 = liveDeploymentNode.add(softwareSystem); + SoftwareSystemInstance liveSoftwareSystemInstance2 = liveDeploymentNode.add(softwareSystem); + + InfrastructureNodeDslContext context = new InfrastructureNodeDslContext(liveInfrastructureNode); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwareSystem", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("->", "softwareSystem"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveSoftwareSystemInstance1)); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveSoftwareSystemInstance2)); + } + + @Test + void test_parse_AddsTheRelationshipToAllContainerInstancesInTheDeploymentEnvironment() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + + DeploymentNode devDeploymentNode = model.addDeploymentNode("dev", "Deployment Node", "Description", "Technology"); + devDeploymentNode.addInfrastructureNode("Infrastructure Node"); + devDeploymentNode.add(container); + devDeploymentNode.add(container); + + DeploymentNode liveDeploymentNode = model.addDeploymentNode("live", "Deployment Node", "Description", "Technology"); + InfrastructureNode liveInfrastructureNode = liveDeploymentNode.addInfrastructureNode("Infrastructure Node"); + ContainerInstance liveContainerInstance1 = liveDeploymentNode.add(container); + ContainerInstance liveContainerInstance2 = liveDeploymentNode.add(container); + + InfrastructureNodeDslContext context = new InfrastructureNodeDslContext(liveInfrastructureNode); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + assertEquals(0, model.getRelationships().size()); + + Set<Relationship> relationships = parser.parse(context, tokens("->", "container"), archetype); + + assertEquals(2, relationships.size()); + assertEquals(2, model.getRelationships().size()); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveContainerInstance1)); + assertTrue(liveInfrastructureNode.hasEfferentRelationshipWith(liveContainerInstance2)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ImpliedRelationshipsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImpliedRelationshipsParserTests.java new file mode 100644 index 000000000..04d17f55a --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ImpliedRelationshipsParserTests.java @@ -0,0 +1,97 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class ImpliedRelationshipsParserTests extends AbstractTests { + + private final ImpliedRelationshipsParser parser = new ImpliedRelationshipsParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!impliedRelationships", "boolean", "extra"), null); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !impliedRelationships <true|false|fqcn>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoFlagIsSpecified() { + try { + parser.parse(context(), tokens("!impliedRelationships"), null); + fail(); + } catch (Exception e) { + assertEquals("Expected: !impliedRelationships <true|false|fqcn>", e.getMessage()); + } + } + + @Test + void test_parse_SetsTheStrategy_WhenFalseIsSpecified() { + parser.parse(context(), tokens("!impliedRelationships", "false"), null); + + assertEquals("com.structurizr.model.DefaultImpliedRelationshipsStrategy", workspace.getModel().getImpliedRelationshipsStrategy().getClass().getCanonicalName()); + } + + @Test + void test_parse_SetsTheStrategy_WhenTrueIsSpecified() { + parser.parse(context(), tokens("!impliedRelationships", "true"), null); + + assertEquals("com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy", workspace.getModel().getImpliedRelationshipsStrategy().getClass().getCanonicalName()); + } + + @Test + void test_parse_SetsTheStrategy_WhenABuiltInStrategyIsUsedInUnrestrictedMode() { + parser.parse(context(), tokens("!impliedRelationships", "com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy"), new File(".")); + + assertEquals("com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy", workspace.getModel().getImpliedRelationshipsStrategy().getClass().getCanonicalName()); + } + + @Test + void test_parse_SetsTheStrategy_WhenABuiltInStrategyIsUsedAndCustomStrategiesAreNotEnabled() { + DslContext context = context(); + context.getFeatures().disable(Features.PLUGINS); + parser.parse(context, tokens("!impliedRelationships", "com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy"), new File(".")); + + assertEquals("com.structurizr.model.CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy", workspace.getModel().getImpliedRelationshipsStrategy().getClass().getCanonicalName()); + } + + @Test + void test_parse_ThrowsAnException_WhenACustomStrategyIsUsedAndCustomStrategiesAreNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().disable(Features.PLUGINS); + parser.parse(context, tokens("!impliedRelationships", "com.example.CustomImpliedRelationshipsStrategy"), new File(".")); + fail(); + } catch (Exception e) { + assertEquals("The implied relationships strategy com.example.CustomImpliedRelationshipsStrategy is not available (feature structurizr.feature.dsl.plugins is not enabled)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenACustomStrategyIsUsedButCannotBeLoaded() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.PLUGINS); + parser.parse(context, tokens("!impliedRelationships", "com.example.CustomImpliedRelationshipsStrategy"), new File(".")); + fail(); + } catch (Exception e) { + assertEquals("Error loading implied relationships strategy: com.example.CustomImpliedRelationshipsStrategy was not found", e.getMessage()); + } + } + + @Test + void test_parse_SetsTheStrategy_WhenACustomStrategyIsUsed() { + DslContext context = context(); + context.getFeatures().enable(Features.PLUGINS); + parser.parse(context, tokens("!impliedRelationships", "com.structurizr.dsl.example.CustomImpliedRelationshipsStrategy"), new File(".")); + + assertEquals("com.structurizr.dsl.example.CustomImpliedRelationshipsStrategy", workspace.getModel().getImpliedRelationshipsStrategy().getClass().getCanonicalName()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/IncludeParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/IncludeParserTests.java new file mode 100644 index 000000000..803a9a545 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/IncludeParserTests.java @@ -0,0 +1,87 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class IncludeParserTests extends AbstractTests { + + private final IncludeParser parser = new IncludeParser(); + + @Test + void test_parse_ThrowsAnException_WhenTheIncludeFeatureIsNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().disable(Features.INCLUDE); + parser.parse(context, null, tokens("!include", "file", "extra")); + fail(); + } catch (Exception e) { + assertEquals("!include is not permitted (feature structurizr.feature.dsl.include is not enabled)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.INCLUDE); + parser.parse(context, null, tokens("!include", "file", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !include <file|directory|url>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenAFileIsNotSpecified() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.INCLUDE); + parser.parse(context, null, tokens("!include")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !include <file|directory|url>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheFileSystemAccessFeatureIsNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.INCLUDE); + context.getFeatures().disable(Features.FILE_SYSTEM); + parser.parse(context, null, tokens("!include", "file")); + fail(); + } catch (Exception e) { + assertEquals("!include <file> is not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenIncludingFromHttpsAndHttpsIsNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.INCLUDE); + context.getFeatures().disable(Features.HTTPS); + parser.parse(context, null, tokens("!include", "https://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Includes via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenIncludingFromHttpAndHttpIsNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.INCLUDE); + context.getFeatures().disable(Features.HTTP); + parser.parse(context, null, tokens("!include", "http://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Includes via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/InfrastructureNodeParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/InfrastructureNodeParserTests.java new file mode 100644 index 000000000..1fbd90759 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/InfrastructureNodeParserTests.java @@ -0,0 +1,157 @@ +package com.structurizr.dsl; + +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.InfrastructureNode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class InfrastructureNodeParserTests extends AbstractTests { + + private InfrastructureNodeParser parser = new InfrastructureNodeParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("infrastructureNode", "name", "description", "technology", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: infrastructureNode <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("infrastructureNode"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: infrastructureNode <name> [description] [technology] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAnInfrastructureNode() { + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + parser.parse(context, tokens("infrastructureNode", "Name"), archetype); + + assertEquals(2, model.getElements().size()); + assertEquals(1, deploymentNode.getInfrastructureNodes().size()); + InfrastructureNode infrastructureNode = deploymentNode.getInfrastructureNodeWithName("Name"); + assertNotNull(infrastructureNode); + assertEquals("", infrastructureNode.getDescription()); + assertEquals("", infrastructureNode.getTechnology()); + assertEquals("Element,Infrastructure Node", infrastructureNode.getTags()); + assertEquals("Live", infrastructureNode.getEnvironment()); + } + + @Test + void test_parse_CreatesAnInfrastructureNodeWithADescription() { + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + parser.parse(context, tokens("infrastructureNode", "Name", "Description"), archetype); + + assertEquals(2, model.getElements().size()); + assertEquals(1, deploymentNode.getInfrastructureNodes().size()); + InfrastructureNode infrastructureNode = deploymentNode.getInfrastructureNodeWithName("Name"); + assertNotNull(infrastructureNode); + assertEquals("Description", infrastructureNode.getDescription()); + assertEquals("", infrastructureNode.getTechnology()); + assertEquals("Element,Infrastructure Node", infrastructureNode.getTags()); + assertEquals("Live", infrastructureNode.getEnvironment()); + } + + @Test + void test_parse_CreatesAnInfrastructureNodeWithADescriptionAndTechnology() { + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + parser.parse(context, tokens("infrastructureNode", "Name", "Description", "Technology"), archetype); + + assertEquals(2, model.getElements().size()); + assertEquals(1, deploymentNode.getInfrastructureNodes().size()); + InfrastructureNode infrastructureNode = deploymentNode.getInfrastructureNodeWithName("Name"); + assertNotNull(infrastructureNode); + assertEquals("Description", infrastructureNode.getDescription()); + assertEquals("Technology", infrastructureNode.getTechnology()); + assertEquals("Element,Infrastructure Node", infrastructureNode.getTags()); + assertEquals("Live", infrastructureNode.getEnvironment()); + } + + @Test + void test_parse_CreatesAnInfrastructureNodeWithADescriptionAndTechnologyAndTags() { + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + parser.parse(context, tokens("infrastructureNode", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(2, model.getElements().size()); + assertEquals(1, deploymentNode.getInfrastructureNodes().size()); + InfrastructureNode infrastructureNode = deploymentNode.getInfrastructureNodeWithName("Name"); + assertNotNull(infrastructureNode); + assertEquals("Description", infrastructureNode.getDescription()); + assertEquals("Technology", infrastructureNode.getTechnology()); + assertEquals("Element,Infrastructure Node,Tag 1,Tag 2", infrastructureNode.getTags()); + assertEquals("Live", infrastructureNode.getEnvironment()); + } + + @Test + void test_parse_CreatesAnInfrastructureNodeWithADescriptionAndTechnologyAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.setTechnology("Default Technology"); + archetype.addTags("Default Tag"); + + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + parser.parse(context, tokens("infrastructureNode", "Name", "Description", "Technology", "Tag 1, Tag 2"), archetype); + + assertEquals(2, model.getElements().size()); + assertEquals(1, deploymentNode.getInfrastructureNodes().size()); + InfrastructureNode infrastructureNode = deploymentNode.getInfrastructureNodeWithName("Name"); + assertNotNull(infrastructureNode); + assertEquals("Description", infrastructureNode.getDescription()); // overridden from archetype + assertEquals("Technology", infrastructureNode.getTechnology()); // overridden from archetype + assertEquals("Element,Infrastructure Node,Default Tag,Tag 1,Tag 2", infrastructureNode.getTags()); + assertEquals("Live", infrastructureNode.getEnvironment()); + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + InfrastructureNode infrastructureNode = model.addDeploymentNode("Deployment Node").addInfrastructureNode("Infrastructure Node"); + InfrastructureNodeDslContext context = new InfrastructureNodeDslContext(infrastructureNode); + parser.parseTechnology(context, tokens("technology", "technology", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + InfrastructureNode infrastructureNode = model.addDeploymentNode("Deployment Node").addInfrastructureNode("Infrastructure Node"); + InfrastructureNodeDslContext context = new InfrastructureNodeDslContext(infrastructureNode); + parser.parseTechnology(context, tokens("technology")); + fail(); + } catch (Exception e) { + assertEquals("Expected: technology <technology>", e.getMessage()); + } + } + + @Test + void test_parseTechnology_SetsTheDescription_WhenADescriptionIsSpecified() { + InfrastructureNode infrastructureNode = model.addDeploymentNode("Deployment Node").addInfrastructureNode("Infrastructure Node"); + InfrastructureNodeDslContext context = new InfrastructureNodeDslContext(infrastructureNode); + parser.parseTechnology(context, tokens("technology", "Technology")); + + assertEquals("Technology", infrastructureNode.getTechnology()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/InlineScriptDslContextTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/InlineScriptDslContextTests.java new file mode 100644 index 000000000..01067aa09 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/InlineScriptDslContextTests.java @@ -0,0 +1,23 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class InlineScriptDslContextTests extends AbstractTests { + + @Test + void test_end_ThrowsAnException_WhenAnUnsupportedLanguageIsSpecified() { + try { + InlineScriptDslContext context = new InlineScriptDslContext(new WorkspaceDslContext(), new File("workspace.dsl"), null, "java"); + context.end(); + fail(); + } catch (Exception e) { + assertEquals("Error running inline script, caused by java.lang.RuntimeException: Unsupported scripting language \"java\"", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/InstanceOfParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/InstanceOfParserTests.java new file mode 100644 index 000000000..494e90fff --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/InstanceOfParserTests.java @@ -0,0 +1,115 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class InstanceOfParserTests extends AbstractTests { + + private final InstanceOfParser parser = new InstanceOfParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("instanceOf", "identifier", "deploymentGroups", "tags", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: instanceOf <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheIdentifierIsNotSpecified() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("instanceOf")); + fail(); + } catch (Exception e) { + assertEquals("Expected: instanceOf <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("instanceOf", "softwareSystem")); + fail(); + } catch (Exception e) { + assertEquals("The element \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystem() { + DeploymentNodeDslContext context = new DeploymentNodeDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("instanceOf", "softwareSystem")); + fail(); + } catch (Exception e) { + assertEquals("The element \"softwareSystem\" must be a software system or a container", e.getMessage()); + } + } + + @Test + void test_parse_CreatesASoftwareSystemInstance() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("instanceOf", "softwareSystem")); + + assertEquals(3, model.getElements().size()); + assertEquals(1, deploymentNode.getSoftwareSystemInstances().size()); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.getSoftwareSystemInstances().iterator().next(); + assertSame(softwareSystem, softwareSystemInstance.getSoftwareSystem()); + assertEquals("Software System Instance", softwareSystemInstance.getTags()); + assertEquals("Live", softwareSystemInstance.getEnvironment()); + assertEquals(1, softwareSystemInstance.getDeploymentGroups().size()); + assertEquals("Default", softwareSystemInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotAContainer() { + DeploymentNodeDslContext context = new DeploymentNodeDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("instanceOf", "container")); + fail(); + } catch (Exception e) { + assertEquals("The element \"container\" must be a software system or a container", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAContainerInstance() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("instanceOf", "container")); + + assertEquals(4, model.getElements().size()); + assertEquals(1, deploymentNode.getContainerInstances().size()); + ContainerInstance containerInstance = deploymentNode.getContainerInstances().iterator().next(); + assertSame(container, containerInstance.getContainer()); + assertEquals("Container Instance", containerInstance.getTags()); + assertEquals("Live", containerInstance.getEnvironment()); + assertEquals(1, containerInstance.getDeploymentGroups().size()); + assertEquals("Default", containerInstance.getDeploymentGroups().iterator().next()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java new file mode 100644 index 000000000..fb1091782 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemParserTests.java @@ -0,0 +1,107 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ModelItemParserTests extends AbstractTests { + + private ModelItemParser parser = new ModelItemParser(); + + @Test + void test_parseTags_ThrowsAnException_WhenNoTagsAreSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ModelItemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseTags(context, tokens("tags")); + fail(); + } catch (Exception e) { + assertEquals("Expected: tags <tags> [tags]", e.getMessage()); + } + } + + @Test + void test_parseTags_AddsTheTags_WhenTagsAreSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ModelItemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseTags(context, tokens("tags", "Tag 1")); + assertEquals(3, softwareSystem.getTagsAsSet().size()); + assertTrue(softwareSystem.getTagsAsSet().contains("Tag 1")); + + parser.parseTags(context, tokens("tags", "Tag 1, Tag 2, Tag 3")); + assertEquals(5, softwareSystem.getTagsAsSet().size()); + assertTrue(softwareSystem.getTagsAsSet().contains("Tag 2")); + assertTrue(softwareSystem.getTagsAsSet().contains("Tag 3")); + + parser.parseTags(context, tokens("tags", "Tag 3", "Tag 4", "Tag 5")); + assertEquals(7, softwareSystem.getTagsAsSet().size()); + assertTrue(softwareSystem.getTagsAsSet().contains("Tag 4")); + assertTrue(softwareSystem.getTagsAsSet().contains("Tag 5")); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + SoftwareSystemDslContext context = new SoftwareSystemDslContext(null); + parser.parseDescription(context, tokens("description", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseDescription(context, tokens("description")); + fail(); + } catch (Exception e) { + assertEquals("Expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_SetsTheDescription_WhenADescriptionIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", ""); + SoftwareSystemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseDescription(context, tokens("description", "Description")); + + assertEquals("Description", softwareSystem.getDescription()); + } + + @Test + void test_parseUrl_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + ModelItemDslContext context = new SoftwareSystemDslContext(null); + parser.parseUrl(context, tokens("url", "url", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: url <url>", e.getMessage()); + } + } + + @Test + void test_parseUrl_ThrowsAnException_WhenNoUrlIsSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ModelItemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseUrl(context, tokens("url")); + fail(); + } catch (Exception e) { + assertEquals("Expected: url <url>", e.getMessage()); + } + } + + @Test + void test_parseUrl_SetsTheUrl_WhenAUrlIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + ModelItemDslContext context = new SoftwareSystemDslContext(softwareSystem); + parser.parseUrl(context, tokens("url", "http://example.com")); + + assertEquals("http://example.com", softwareSystem.getUrl()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemsParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemsParserTests.java new file mode 100644 index 000000000..004d345cf --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ModelItemsParserTests.java @@ -0,0 +1,48 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class ModelItemsParserTests extends AbstractTests { + + private final ModelItemsParser parser = new ModelItemsParser(); + + @Test + void test_parseTags_ThrowsAnException_WhenNoTagsAreSpecified() { + try { + parser.parseTags(null, tokens("tags")); + fail(); + } catch (Exception e) { + assertEquals("Expected: tags <tags> [tags]", e.getMessage()); + } + } + + @Test + void test_parseTags_AddsTheTags_WhenTagsAreSpecified() { + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + SoftwareSystem c = model.addSoftwareSystem("C"); + + ElementsDslContext context = new ElementsDslContext(null, Set.of(a, b, c)); + + parser.parseTags(context, tokens("tags", "Tag 1")); + for (Element element : context.getElements()) { + assertEquals(3, element.getTagsAsSet().size()); + assertTrue(element.getTagsAsSet().contains("Tag 1")); + } + + parser.parseTags(context, tokens("tags", "Tag 1, Tag 2, Tag 3")); + for (Element element : context.getElements()) { + assertEquals(5, element.getTagsAsSet().size()); + assertTrue(element.getTagsAsSet().contains("Tag 1")); + assertTrue(element.getTagsAsSet().contains("Tag 2")); + assertTrue(element.getTagsAsSet().contains("Tag 3")); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/NameValueParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/NameValueParserTests.java new file mode 100644 index 000000000..7c6a738ae --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/NameValueParserTests.java @@ -0,0 +1,68 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class NameValueParserTests extends AbstractTests { + + private final NameValueParser parser = new NameValueParser(); + + @Test + void test_parseConstant_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseConstant(tokens("!const", "name", "value", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !const <name> <value>", e.getMessage()); + } + } + + @Test + void test_parseConstant_ThrowsAnException_WhenNoNameOrValueIsSpecified() { + try { + parser.parseConstant(tokens("!const")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !const <name> <value>", e.getMessage()); + } + } + + @Test + void test_parseConstant_ThrowsAnException_WhenNoValueIsSpecified() { + try { + parser.parseConstant(tokens("!const", "name")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !const <name> <value>", e.getMessage()); + } + } + + @Test + void test_parseConstant_ThrowsAnException_WhenNameContainsDisallowedCharacters() { + try { + parser.parseConstant(tokens("!const", "${NAME}", "value")); + fail(); + } catch (Exception e) { + assertEquals("Constant/variable names must only contain the following characters: a-zA-Z0-9-_.", e.getMessage()); + } + } + + @Test + void test_parseConstant_CreatesAConstant() { + NameValuePair nameValuePair = parser.parseConstant(tokens("!const", "name", "value")); + assertEquals("name", nameValuePair.getName()); + assertEquals("value", nameValuePair.getValue()); + assertEquals(NameValueType.Constant, nameValuePair.getType()); + } + + @Test + void test_parseVariable_CreatesAVariable() { + NameValuePair nameValuePair = parser.parseVariable(tokens("!var", "name", "value")); + assertEquals("name", nameValuePair.getName()); + assertEquals("value", nameValuePair.getValue()); + assertEquals(NameValueType.Variable, nameValuePair.getType()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/NoRelationshipParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/NoRelationshipParserTests.java new file mode 100644 index 000000000..58fcb32fd --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/NoRelationshipParserTests.java @@ -0,0 +1,331 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class NoRelationshipParserTests extends AbstractTests { + + private static final String DEPLOYMENT_ENVIRONMENT = "live"; + + private final NoRelationshipParser parser = new NoRelationshipParser(); + private DeploymentEnvironmentDslContext context; + + @BeforeEach + void setUp() { + context = new DeploymentEnvironmentDslContext(DEPLOYMENT_ENVIRONMENT); + context.setWorkspace(workspace); + } + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(null, tokens("source", "-/>", "destination", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: <identifier> -/> <identifier> [description]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationIdentifierIsMissing() { + try { + parser.parse(null, tokens("source", "-/>")); + fail(); + } catch (Exception e) { + assertEquals("Not enough tokens, expected: <identifier> -/> <identifier> [description]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSourceElementIsNotDefined() { + try { + parser.parse(context, tokens("a", "-/>", "b")); + fail(); + } catch (Exception e) { + assertEquals("The source element \"a\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSourceElementIsNotAStaticStructureElementInstance() { + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", model.addPerson("User")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("a", "->", "b")); + fail(); + } catch (Exception e) { + assertEquals("The source element \"a\" is not valid - expecting a software system, software system instance, container, or container instance", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationElementIsNotDefined() { + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", model.addSoftwareSystem("A")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("a", "-/>", "b")); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"b\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheDestinationElementIsNotAStaticStructureElementInstance() { + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("a", model.addSoftwareSystem("A")); + elements.register("b", model.addPerson("User")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("a", "->", "b")); + fail(); + } catch (Exception e) { + assertEquals("The destination element \"b\" is not valid - expecting a software system, software system instance, container, or container instance", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenARelationshipDoesNotExist() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + deploymentNode.add(a); + deploymentNode.add(b); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("a", "-/>", "b")); + fail(); + } catch (Exception e) { + assertEquals("A relationship between \"a\" and \"b\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenARelationshipWithDescriptionDoesNotExist() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + deploymentNode.add(a); + deploymentNode.add(b); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("a", "-/>", "b", "Description")); + fail(); + } catch (Exception e) { + assertEquals("A relationship between \"a\" and \"b\" with description \"Description\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_RemovesAllRelationshipsBetweenSoftwareSystemInstances_WhenUsingSoftwareSystemInstanceIdentifiersAndNoDescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + SoftwareSystemInstance aInstance = deploymentNode.add(a); + SoftwareSystemInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("aInstance", aInstance); + elements.register("bInstance", bInstance); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("aInstance", "-/>", "bInstance")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(2, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertTrue(relationshipsRemoved.contains(relationship2)); + } + + @Test + void test_parse_RemovesAllRelationshipsBetweenSoftwareSystemInstances_WhenUsingSoftwareSystemIdentifiersAndNoDescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + SoftwareSystemInstance aInstance = deploymentNode.add(a); + SoftwareSystemInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("a", "-/>", "b")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(2, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertTrue(relationshipsRemoved.contains(relationship2)); + } + + @Test + void test_parse_RemovesAllRelationshipsBetweenContainerInstances_WhenUsingContainerIdentifiersAndNoDescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem ss = model.addSoftwareSystem("A"); + Container a = ss.addContainer("A"); + Container b = ss.addContainer("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + ContainerInstance aInstance = deploymentNode.add(a); + ContainerInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("a", "-/>", "b")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(2, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertTrue(relationshipsRemoved.contains(relationship2)); + } + + @Test + void test_parse_RemovesTheRelationshipBetweenSoftwareSystemInstances_WhenUsingSoftwareSystemInstanceIdentifiersAndADescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + SoftwareSystemInstance aInstance = deploymentNode.add(a); + SoftwareSystemInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("aInstance", aInstance); + elements.register("bInstance", bInstance); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("aInstance", "-/>", "bInstance", "Description 1")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(1, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertFalse(relationshipsRemoved.contains(relationship2)); + } + + @Test + void test_parse_RemovesTheRelationshipBetweenSoftwareSystemInstances_WhenUsingSoftwareSystemIdentifiersAndADescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem a = model.addSoftwareSystem("A"); + SoftwareSystem b = model.addSoftwareSystem("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + SoftwareSystemInstance aInstance = deploymentNode.add(a); + SoftwareSystemInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("a", "-/>", "b", "Description 1")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(1, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertFalse(relationshipsRemoved.contains(relationship2)); + } + + @Test + void test_parse_RemovesTheRelationshipBetweenContainerInstances_WhenUsingContainerIdentifiersAndADescription() { + IdentifiersRegister elements = new IdentifiersRegister(); + + SoftwareSystem ss = model.addSoftwareSystem("A"); + Container a = ss.addContainer("A"); + Container b = ss.addContainer("B"); + a.uses(b, "Description 1", "Technology"); + a.uses(b, "Description 2", "Technology"); + + DeploymentNode deploymentNode = model.addDeploymentNode(DEPLOYMENT_ENVIRONMENT, "Deployment Node", "Description", "Technology"); + ContainerInstance aInstance = deploymentNode.add(a); + ContainerInstance bInstance = deploymentNode.add(b); + Relationship relationship1 = aInstance.getEfferentRelationshipWith(bInstance, "Description 1"); + Relationship relationship2 = aInstance.getEfferentRelationshipWith(bInstance, "Description 2"); + + elements.register("a", a); + elements.register("b", b); + context.setIdentifierRegister(elements); + + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + + Set<Relationship> relationshipsRemoved = parser.parse(context, tokens("a", "-/>", "b", "Description 1")); + + assertFalse(aInstance.hasEfferentRelationshipWith(bInstance, "Description 1")); + assertTrue(aInstance.hasEfferentRelationshipWith(bInstance, "Description 2")); + assertEquals(1, relationshipsRemoved.size()); + assertTrue(relationshipsRemoved.contains(relationship1)); + assertFalse(relationshipsRemoved.contains(relationship2)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/PersonParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/PersonParserTests.java new file mode 100644 index 000000000..38960e0e0 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/PersonParserTests.java @@ -0,0 +1,81 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Person; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PersonParserTests extends AbstractTests { + + private final PersonParser parser = new PersonParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("person", "name", "description", "tags", "tokens"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: person <name> [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(context(), tokens("person"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: person <name> [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesAPerson() { + parser.parse(context(), tokens("person", "User"), archetype); + + assertEquals(1, model.getElements().size()); + Person user = model.getPersonWithName("User"); + assertNotNull(user); + assertEquals("", user.getDescription()); + assertEquals("Element,Person", user.getTags()); + } + + @Test + void test_parse_CreatesAPersonWithADescription() { + parser.parse(context(), tokens("person", "User", "Description"), archetype); + + assertEquals(1, model.getElements().size()); + Person user = model.getPersonWithName("User"); + assertNotNull(user); + assertEquals("Description", user.getDescription()); + assertEquals("Element,Person", user.getTags()); + } + + @Test + void test_parse_CreatesAPersonWithADescriptionAndTags() { + parser.parse(context(), tokens("person", "User", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + Person user = model.getPersonWithName("User"); + assertNotNull(user); + assertEquals("Description", user.getDescription()); + assertEquals("Element,Person,Tag 1,Tag 2", user.getTags()); + } + + @Test + void test_parse_CreatesAPersonWithADescriptionAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.addTags("Default Tag"); + + parser.parse(context(), tokens("person", "User", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + Person user = model.getPersonWithName("User"); + assertNotNull(user); + assertEquals("Description", user.getDescription()); // overridden from archetype + assertEquals("Element,Person,Default Tag,Tag 1,Tag 2", user.getTags()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/PerspectiveParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/PerspectiveParserTests.java new file mode 100644 index 000000000..3f11603dc --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/PerspectiveParserTests.java @@ -0,0 +1,72 @@ +package com.structurizr.dsl; + +import com.structurizr.model.ModelItem; +import com.structurizr.model.Perspective; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class PerspectiveParserTests extends AbstractTests { + + private final PerspectiveParser parser = new PerspectiveParser(); + + @Test + void test_parsePerspective_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + PerspectivesDslContext context = new PerspectivesDslContext((ModelItem)null); + parser.parse(context, tokens("name", "description", "value", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: <name> <description> [value]", e.getMessage()); + } + } + + @Test + void test_parsePerspective_ThrowsAnException_WhenNoNameIsSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PerspectivesDslContext context = new PerspectivesDslContext(softwareSystem); + parser.parse(context, tokens()); + fail(); + } catch (Exception e) { + assertEquals("Expected: <name> <description> [value]", e.getMessage()); + } + } + + @Test + void test_parsePerspective_ThrowsAnException_WhenNoDescriptionIsSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PerspectivesDslContext context = new PerspectivesDslContext(softwareSystem); + parser.parse(context, tokens("name")); + fail(); + } catch (Exception e) { + assertEquals("Expected: <name> <description> [value]", e.getMessage()); + } + } + + @Test + void test_parsePerspective_AddsThePerspective_WhenADescriptionIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PerspectivesDslContext context = new PerspectivesDslContext(softwareSystem); + parser.parse(context, tokens("Security", "Description")); + + Perspective perspective = softwareSystem.getPerspectives().stream().filter(p -> p.getName().equals("Security")).findFirst().get(); + assertEquals("Description", perspective.getDescription()); + assertEquals("", perspective.getValue()); + } + + @Test + void test_parsePerspective_AddsThePerspective_WhenADescriptionAndValueIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PerspectivesDslContext context = new PerspectivesDslContext(softwareSystem); + parser.parse(context, tokens("Security", "Description", "Value")); + + Perspective perspective = softwareSystem.getPerspectives().stream().filter(p -> p.getName().equals("Security")).findFirst().get(); + assertEquals("Description", perspective.getDescription()); + assertEquals("Value", perspective.getValue()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginDslContextTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginDslContextTests.java new file mode 100644 index 000000000..90900c706 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginDslContextTests.java @@ -0,0 +1,23 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class PluginDslContextTests extends AbstractTests { + + @Test + void test_end_ThrowsAnException_WhenThePluginClassDoesNotExist() { + try { + PluginDslContext context = new PluginDslContext("com.structurizr.TestPlugin", new File("src/test/dsl"), null); + context.end(); + fail(); + } catch (Exception e) { + assertEquals("Error running plugin com.structurizr.TestPlugin, caused by java.lang.ClassNotFoundException: com.structurizr.TestPlugin", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginParserTests.java new file mode 100644 index 000000000..c55de300a --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/PluginParserTests.java @@ -0,0 +1,38 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class PluginParserTests extends AbstractTests { + + private PluginParser parser = new PluginParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("!plugin", "com.example.ClassName", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !plugin <fqn>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoFullyQualifiedNameIsSpecified() { + try { + parser.parse(context(), tokens("!plugin")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !plugin <fqn>", e.getMessage()); + } + } + + @Test + void test_parse_ReturnsTheFullyQualifiedClassName_WhenAValidPluginIsSpecified() { + String fqcn = parser.parse(context(), tokens("!plugin", "com.example.ExampleStructurizrDslPlugin")); + assertEquals("com.example.ExampleStructurizrDslPlugin", fqcn); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/PropertyParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/PropertyParserTests.java new file mode 100644 index 000000000..33101711c --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/PropertyParserTests.java @@ -0,0 +1,31 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PropertyParserTests extends AbstractTests { + + @Test + void test_parseProperty_ThrowsAnException_WhenNoValueIsSpecified() { + try { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PropertiesDslContext context = new PropertiesDslContext(softwareSystem); + new PropertyParser().parse(context, tokens("name")); + fail(); + } catch (Exception e) { + assertEquals("Expected: <name> <value>", e.getMessage()); + } + } + + @Test + void test_parseProperty_AddsTheProperty_WhenAValueIsSpecified() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Name", "Description"); + PropertiesDslContext context = new PropertiesDslContext(softwareSystem); + new PropertyParser().parse(context, tokens("name", "value")); + + assertEquals("value", softwareSystem.getProperties().get("name")); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/RelationshipStyleParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/RelationshipStyleParserTests.java new file mode 100644 index 000000000..d59d7081d --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/RelationshipStyleParserTests.java @@ -0,0 +1,438 @@ +package com.structurizr.dsl; + +import com.structurizr.view.LineStyle; +import com.structurizr.view.RelationshipStyle; +import com.structurizr.view.Routing; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RelationshipStyleParserTests extends AbstractTests { + + private RelationshipStyleParser parser = new RelationshipStyleParser(); + private RelationshipStyle relationshipStyle; + + private RelationshipStyleDslContext relationshipStyleDslContext() { + relationshipStyle = workspace.getViews().getConfiguration().getStyles().addRelationshipStyle("Tag"); + RelationshipStyleDslContext context = new RelationshipStyleDslContext(relationshipStyle); + context.setWorkspace(workspace); + + return context; + } + + private StylesDslContext stylesDslContext() { + StylesDslContext context = new StylesDslContext(); + context.setWorkspace(workspace); + + return context; + } + + @Test + void test_parseRelationshipStyle_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseRelationshipStyle(stylesDslContext(), tokens("relationship", "tag", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: relationship <tag> {", e.getMessage()); + } + } + + @Test + void test_parseRelationshipStyle_ThrowsAnException_WhenTheTagIsMissing() { + try { + parser.parseRelationshipStyle(stylesDslContext(), tokens("relationship")); + fail(); + } catch (Exception e) { + assertEquals("Expected: relationship <tag> {", e.getMessage()); + } + } + + @Test + void test_parseRelationshipStyle_ThrowsAnException_WhenTheTagIsEmpty() { + try { + parser.parseRelationshipStyle(stylesDslContext(), tokens("relationship", "")); + fail(); + } catch (Exception e) { + assertEquals("A tag must be specified", e.getMessage()); + } + } + + @Test + void test_parseRelationshipStyle_CreatesAnRelationshipStyle() { + parser.parseRelationshipStyle(stylesDslContext(), tokens("relationship", "Relationship")); + + RelationshipStyle style = workspace.getViews().getConfiguration().getStyles().getRelationships().stream().filter(es -> "Relationship".equals(es.getTag())).findFirst().get(); + assertNotNull(style); + } + + @Test + void test_parseRelationshipStyle_FindsAnExistingRelationshipStyle() { + RelationshipStyle style = workspace.getViews().getConfiguration().getStyles().addRelationshipStyle("Tag"); + assertSame(style, parser.parseRelationshipStyle(stylesDslContext(), tokens("relationship", "Tag"))); + } + + @Test + void test_parseThickness_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseThickness(relationshipStyleDslContext(), tokens("thickness", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: thickness <number>", e.getMessage()); + } + } + + @Test + void test_parseThickness_ThrowsAnException_WhenTheThicknessIsMissing() { + try { + parser.parseThickness(relationshipStyleDslContext(), tokens("thickness")); + fail(); + } catch (Exception e) { + assertEquals("Expected: thickness <number>", e.getMessage()); + } + } + + @Test + void test_parseThickness_ThrowsAnException_WhenTheThicknessIsNotValid() { + try { + parser.parseThickness(relationshipStyleDslContext(), tokens("thickness", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Thickness must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseThickness_SetsTheThickness() { + parser.parseThickness(relationshipStyleDslContext(), tokens("thickness", "75")); + assertEquals(75, relationshipStyle.getThickness()); + } + + @Test + void test_parseColour_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseColour(relationshipStyleDslContext(), tokens("colour", "hex", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: colour <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseColour_ThrowsAnException_WhenTheColourIsMissing() { + try { + parser.parseColour(relationshipStyleDslContext(), tokens("colour")); + fail(); + } catch (Exception e) { + assertEquals("Expected: colour <#rrggbb|color name>", e.getMessage()); + } + } + + @Test + void test_parseColour_SetsTheColourWhenUsingAHexColourCode() { + parser.parseColour(relationshipStyleDslContext(), tokens("colour", "#ff0000")); + assertEquals("#ff0000", relationshipStyle.getColor()); + } + + @Test + void test_parseColour_SetsTheColourWhenUsingAColourName() { + parser.parseColour(relationshipStyleDslContext(), tokens("colour", "yellow")); + assertEquals("#ffff00", relationshipStyle.getColor()); + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseOpacity(relationshipStyleDslContext(), tokens("opacity", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: opacity <0-100>", e.getMessage()); + } + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenTheOpacityIsMissing() { + try { + parser.parseOpacity(relationshipStyleDslContext(), tokens("opacity")); + fail(); + } catch (Exception e) { + assertEquals("Expected: opacity <0-100>", e.getMessage()); + } + } + + @Test + void test_parseOpacity_ThrowsAnException_WhenTheOpacityIsNotValid() { + try { + parser.parseOpacity(relationshipStyleDslContext(), tokens("opacity", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Opacity must be an integer between 0 and 100", e.getMessage()); + } + } + + @Test + void test_parseOpacity_SetsTheOpacity() { + parser.parseOpacity(relationshipStyleDslContext(), tokens("opacity", "75")); + assertEquals(75, relationshipStyle.getOpacity()); + } + + @Test + void test_parseWidth_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseWidth(relationshipStyleDslContext(), tokens("width", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: width <number>", e.getMessage()); + } + } + + @Test + void test_parseWidth_ThrowsAnException_WhenTheWidthIsMissing() { + try { + parser.parseWidth(relationshipStyleDslContext(), tokens("width")); + fail(); + } catch (Exception e) { + assertEquals("Expected: width <number>", e.getMessage()); + } + } + + @Test + void test_parseWidth_ThrowsAnException_WhenTheWidthIsNotValid() { + try { + parser.parseWidth(relationshipStyleDslContext(), tokens("width", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Width must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseWidth_SetsTheWidth() { + parser.parseWidth(relationshipStyleDslContext(), tokens("width", "75")); + assertEquals(75, relationshipStyle.getWidth()); + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseFontSize(relationshipStyleDslContext(), tokens("fontSize", "number", "extrta")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: fontSize <number>", e.getMessage()); + } + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenTheFontSizeIsMissing() { + try { + parser.parseFontSize(relationshipStyleDslContext(), tokens("fontSize")); + fail(); + } catch (Exception e) { + assertEquals("Expected: fontSize <number>", e.getMessage()); + } + } + + @Test + void test_parseFontSize_ThrowsAnException_WhenTheFontSizeIsNotValid() { + try { + parser.parseFontSize(relationshipStyleDslContext(), tokens("fontSize", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Font size must be a positive integer", e.getMessage()); + } + } + + @Test + void test_parseFontSize_SetsTheFontSize() { + parser.parseFontSize(relationshipStyleDslContext(), tokens("fontSize", "75")); + assertEquals(75, relationshipStyle.getFontSize()); + } + + @Test + void test_parseDashed_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseDashed(relationshipStyleDslContext(), tokens("dashed", "boolean", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: dashed <true|false>", e.getMessage()); + } + } + + @Test + void test_parseDashed_ThrowsAnException_WhenTheDashedIsMissing() { + try { + parser.parseDashed(relationshipStyleDslContext(), tokens("dashed")); + fail(); + } catch (Exception e) { + assertEquals("Expected: dashed <true|false>", e.getMessage()); + } + } + + @Test + void test_parseDashed_ThrowsAnException_WhenTheDashedIsNotValid() { + try { + parser.parseDashed(relationshipStyleDslContext(), tokens("dashed", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Dashed must be true or false", e.getMessage()); + } + } + + @Test + void test_parseDashed_SetsTheDashed() { + RelationshipStyleDslContext context = relationshipStyleDslContext(); + parser.parseDashed(context, tokens("dashed", "false")); + assertEquals(false, relationshipStyle.getDashed()); + + parser.parseDashed(context, tokens("dashed", "true")); + assertEquals(true, relationshipStyle.getDashed()); + } + + @Test + void test_parseLineStyle_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseLineStyle(relationshipStyleDslContext(), tokens("style", "solid", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: style <solid|dashed|dotted>", e.getMessage()); + } + } + + @Test + void test_parseLineStyle_ThrowsAnException_WhenTheStyleIsMissing() { + try { + parser.parseLineStyle(relationshipStyleDslContext(), tokens("style")); + fail(); + } catch (Exception e) { + assertEquals("Expected: style <solid|dashed|dotted>", e.getMessage()); + } + } + + @Test + void test_parseLineStyle_ThrowsAnException_WhenTheStyleIsNotValid() { + try { + parser.parseLineStyle(relationshipStyleDslContext(), tokens("style", "none")); + fail(); + } catch (Exception e) { + assertEquals("The line style \"none\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseLineStyle_SetsTheStyle() { + parser.parseLineStyle(relationshipStyleDslContext(), tokens("style", "dotted")); + assertEquals(LineStyle.Dotted, relationshipStyle.getStyle()); + } + + @Test + void test_parsePosition_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parsePosition(relationshipStyleDslContext(), tokens("position", "number", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: position <0-100>", e.getMessage()); + } + } + + @Test + void test_parsePosition_ThrowsAnException_WhenThePositionIsMissing() { + try { + parser.parsePosition(relationshipStyleDslContext(), tokens("position")); + fail(); + } catch (Exception e) { + assertEquals("Expected: position <0-100>", e.getMessage()); + } + } + + @Test + void test_parsePosition_ThrowsAnException_WhenThePositionIsNotValid() { + try { + parser.parsePosition(relationshipStyleDslContext(), tokens("position", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Position must be an integer between 0 and 100", e.getMessage()); + } + } + + @Test + void test_parsePosition_SetsThePosition() { + parser.parsePosition(relationshipStyleDslContext(), tokens("position", "75")); + assertEquals(75, relationshipStyle.getPosition()); + } + + @Test + void test_parseRouting_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseRouting(relationshipStyleDslContext(), tokens("routing", "enum", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: routing <direct|orthogonal|curved>", e.getMessage()); + } + } + + @Test + void test_parseRouting_ThrowsAnException_WhenTheRoutingIsMissing() { + try { + parser.parseRouting(relationshipStyleDslContext(), tokens("routing")); + fail(); + } catch (Exception e) { + assertEquals("Expected: routing <direct|orthogonal|curved>", e.getMessage()); + } + } + + @Test + void test_parseRouting_ThrowsAnException_WhenTheRoutingIsNotValid() { + try { + parser.parseRouting(relationshipStyleDslContext(), tokens("routing", "rounded")); + fail(); + } catch (Exception e) { + assertEquals("The routing \"rounded\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseRouting_SetsTheRouting() { + parser.parseRouting(relationshipStyleDslContext(), tokens("routing", "curved")); + assertEquals(Routing.Curved, relationshipStyle.getRouting()); + } + + @Test + void test_parseJump_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseJump(relationshipStyleDslContext(), tokens("jump", "boolean", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: jump <true|false>", e.getMessage()); + } + } + + @Test + void test_parseJump_ThrowsAnException_WhenTheValueIsMissing() { + try { + parser.parseJump(relationshipStyleDslContext(), tokens("jump")); + fail(); + } catch (Exception e) { + assertEquals("Expected: jump <true|false>", e.getMessage()); + } + } + + @Test + void test_parseJump_ThrowsAnException_WhenTheValueIsNotValid() { + try { + parser.parseJump(relationshipStyleDslContext(), tokens("jump", "abc")); + fail(); + } catch (Exception e) { + assertEquals("Jump must be true or false", e.getMessage()); + } + } + + @Test + void test_parseJump_SetsTheJump() { + RelationshipStyleDslContext context = relationshipStyleDslContext(); + parser.parseJump(context, tokens("jump", "false")); + assertEquals(false, relationshipStyle.getJump()); + + parser.parseJump(context, tokens("jump", "true")); + assertEquals(true, relationshipStyle.getJump()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ScriptParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ScriptParserTests.java new file mode 100644 index 000000000..a7627f7af --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ScriptParserTests.java @@ -0,0 +1,34 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +class ScriptParserTests extends AbstractTests { + + private ScriptParser parser = new ScriptParser(); + + @Test + void test_parseExternal_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseExternal(tokens("!script", "test.kts", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: !script <filename>", e.getMessage()); + } + } + + @Test + void test_parseExternal_ThrowsAnException_WhenNoFilenameIsSpecified() { + try { + parser.parseExternal(tokens("!script")); + fail(); + } catch (Exception e) { + assertEquals("Expected: !script <filename>", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemInstanceParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemInstanceParserTests.java new file mode 100644 index 000000000..290c56a0b --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemInstanceParserTests.java @@ -0,0 +1,149 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SoftwareSystemInstanceParserTests extends AbstractTests { + + private final SoftwareSystemInstanceParser parser = new SoftwareSystemInstanceParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("softwareSystemInstance", "identifier", "deploymentGroups", "tags", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: softwareSystemInstance <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheIdentifierIsNotSpecified() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("softwareSystemInstance")); + fail(); + } catch (Exception e) { + assertEquals("Expected: softwareSystemInstance <identifier> [deploymentGroups] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + try { + parser.parse(new DeploymentNodeDslContext(null), tokens("softwareSystemInstance", "softwareSystem")); + fail(); + } catch (Exception e) { + assertEquals("The software system \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystem() { + DeploymentNodeDslContext context = new DeploymentNodeDslContext(null); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("softwareSystemInstance", "softwareSystem")); + fail(); + } catch (Exception e) { + assertEquals("The software system \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_CreatesASoftwareSystemInstanceInTheDefaultDeploymentGroup() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("softwareSystemInstance", "softwareSystem")); + + assertEquals(3, model.getElements().size()); + assertEquals(1, deploymentNode.getSoftwareSystemInstances().size()); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.getSoftwareSystemInstances().iterator().next(); + assertSame(softwareSystem, softwareSystemInstance.getSoftwareSystem()); + assertEquals("Software System Instance", softwareSystemInstance.getTags()); + assertEquals("Live", softwareSystemInstance.getEnvironment()); + assertEquals(1, softwareSystemInstance.getDeploymentGroups().size()); + assertEquals("Default", softwareSystemInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesASoftwareSystemInstanceInTheDefaultDeploymentGroupWithTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("softwareSystemInstance", "softwareSystem", "", "Tag 1, Tag 2")); + + assertEquals(3, model.getElements().size()); + assertEquals(1, deploymentNode.getSoftwareSystemInstances().size()); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.getSoftwareSystemInstances().iterator().next(); + assertSame(softwareSystem, softwareSystemInstance.getSoftwareSystem()); + assertEquals("Software System Instance,Tag 1,Tag 2", softwareSystemInstance.getTags()); + assertEquals("Live", softwareSystemInstance.getEnvironment()); + assertEquals(1, softwareSystemInstance.getDeploymentGroups().size()); + assertEquals("Default", softwareSystemInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesASoftwareSystemInstanceInTheSpecifiedDeploymentGroup() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", softwareSystem); + elements.register("live", new DeploymentEnvironment("Live")); + elements.register("group", new DeploymentGroup(new DeploymentEnvironment("Live"), "Group")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("softwareSystemInstance", "softwareSystem", "group")); + + assertEquals(3, model.getElements().size()); + assertEquals(1, deploymentNode.getSoftwareSystemInstances().size()); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.getSoftwareSystemInstances().iterator().next(); + assertSame(softwareSystem, softwareSystemInstance.getSoftwareSystem()); + assertEquals("Software System Instance", softwareSystemInstance.getTags()); + assertEquals("Live", softwareSystemInstance.getEnvironment()); + assertEquals(1, softwareSystemInstance.getDeploymentGroups().size()); + assertEquals("Group", softwareSystemInstance.getDeploymentGroups().iterator().next()); + } + + @Test + void test_parse_CreatesASoftwareSystemInstanceInTheSpecifiedDeploymentGroupWithTags() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + DeploymentNode deploymentNode = model.addDeploymentNode("Live", "Deployment Node", "Description", "Technology"); + + DeploymentNodeDslContext context = new DeploymentNodeDslContext(deploymentNode); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", softwareSystem); + elements.register("live", new DeploymentEnvironment("Live")); + elements.register("group", new DeploymentGroup(new DeploymentEnvironment("Live"), "Group")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("softwareSystemInstance", "softwareSystem", "group", "Tag 1, Tag 2")); + + assertEquals(3, model.getElements().size()); + assertEquals(1, deploymentNode.getSoftwareSystemInstances().size()); + SoftwareSystemInstance softwareSystemInstance = deploymentNode.getSoftwareSystemInstances().iterator().next(); + assertSame(softwareSystem, softwareSystemInstance.getSoftwareSystem()); + assertEquals("Software System Instance,Tag 1,Tag 2", softwareSystemInstance.getTags()); + assertEquals("Live", softwareSystemInstance.getEnvironment()); + assertEquals(1, softwareSystemInstance.getDeploymentGroups().size()); + assertEquals("Group", softwareSystemInstance.getDeploymentGroups().iterator().next()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemParserTests.java new file mode 100644 index 000000000..29f9b7840 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/SoftwareSystemParserTests.java @@ -0,0 +1,81 @@ +package com.structurizr.dsl; + +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SoftwareSystemParserTests extends AbstractTests { + + private final SoftwareSystemParser parser = new SoftwareSystemParser(); + private Archetype archetype = new Archetype("name", "type"); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("softwareSystem", "name", "description", "tags", "extra"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: softwareSystem <name> [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheNameIsNotSpecified() { + try { + parser.parse(context(), tokens("softwareSystem"), archetype); + fail(); + } catch (Exception e) { + assertEquals("Expected: softwareSystem <name> [description] [tags]", e.getMessage()); + } + } + + @Test + void test_parse_CreatesASoftwareSystem() { + parser.parse(context(), tokens("softwareSystem", "Name"), archetype); + + assertEquals(1, model.getElements().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Name"); + assertNotNull(softwareSystem); + assertEquals("", softwareSystem.getDescription()); + assertEquals("Element,Software System", softwareSystem.getTags()); + } + + @Test + void test_parse_CreatesASoftwareSystemWithADescription() { + parser.parse(context(), tokens("softwareSystem", "Name", "Description"), archetype); + + assertEquals(1, model.getElements().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Name"); + assertNotNull(softwareSystem); + assertEquals("Description", softwareSystem.getDescription()); + assertEquals("Element,Software System", softwareSystem.getTags()); + } + + @Test + void test_parse_CreatesASoftwareSystemWithADescriptionAndTags() { + parser.parse(context(), tokens("softwareSystem", "Name", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Name"); + assertNotNull(softwareSystem); + assertEquals("Description", softwareSystem.getDescription()); + assertEquals("Element,Software System,Tag 1,Tag 2", softwareSystem.getTags()); + } + + @Test + void test_parse_CreatesASoftwareSystemWithADescriptionAndTagsBasedUponAnArchetype() { + archetype = new Archetype("name", "type"); + archetype.setDescription("Default Description"); + archetype.addTags("Default Tag"); + + parser.parse(context(), tokens("softwareSystem", "Name", "Description", "Tag 1, Tag 2"), archetype); + + assertEquals(1, model.getElements().size()); + SoftwareSystem softwareSystem = model.getSoftwareSystemWithName("Name"); + assertNotNull(softwareSystem); + assertEquals("Description", softwareSystem.getDescription()); // overridden from archetype + assertEquals("Element,Software System,Default Tag,Tag 1,Tag 2", softwareSystem.getTags()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewAnimationStepParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewAnimationStepParserTests.java new file mode 100644 index 000000000..61b0a0b4a --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewAnimationStepParserTests.java @@ -0,0 +1,49 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class StaticViewAnimationStepParserTests extends AbstractTests { + + private StaticViewAnimationStepParser parser = new StaticViewAnimationStepParser(); + + @Test + void test_parseExplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((StaticViewDslContext)null, tokens("animationStep")); + fail(); + } catch (Exception e) { + assertEquals("Expected: animationStep <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parseImplicit_ThrowsAnException_WhenElementsAreMissing() { + try { + parser.parse((StaticViewAnimationDslContext) null, tokens()); + fail(); + } catch (Exception e) { + assertEquals("Expected: <identifier|element expression> [identifier|element expression...]", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementDoesNotExist() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + + StaticViewAnimationDslContext context = new StaticViewAnimationDslContext(view); + IdentifiersRegister map = new IdentifiersRegister(); + context.setIdentifierRegister(map); + + try { + parser.parse(context, tokens("user")); + fail(); + } catch (Exception e) { + assertEquals("The element/relationship \"user\" does not exist", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewContentParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewContentParserTests.java new file mode 100644 index 000000000..043a723fe --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewContentParserTests.java @@ -0,0 +1,830 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import com.structurizr.view.ComponentView; +import com.structurizr.view.SystemContextView; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticViewContentParserTests extends AbstractTests { + + private StaticViewContentParser parser = new StaticViewContentParser(); + + @Test + void test_parseInclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + try { + parser.parseInclude(new SystemLandscapeViewDslContext(null), tokens("include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: include <*|identifier|expression> [*|identifier|expression...]", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + try { + parser.parseInclude(new SystemLandscapeViewDslContext(null), tokens("include", "user")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"user\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddAContainerToASystemLandscapeView() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "container")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"container\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddAComponentToASystemLandscapeView() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + Component component = container.addComponent("Component", "Description", "Technology"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("component", component); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "component")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"component\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseInclude_AddsAllPeopleAndSoftwareSystemsToASystemLandscapeView_WhenTheGreedyWildcardIsSpecified() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + softwareSystem1.addContainer("Container 1", "Description", "Technology"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*")); + + assertEquals(3, view.getElements().size()); + assertEquals(2, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsAllPeopleAndSoftwareSystemsToASystemLandscapeView_WhenTheReluctantWildcardIsSpecified() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + softwareSystem1.addContainer("Container 1", "Description", "Technology"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*?")); + + assertEquals(3, view.getElements().size()); + assertEquals(2, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsTheSpecifiedElementsToASystemLandscapeView() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + CustomElement box1 = model.addCustomElement("Box 1"); + box1.uses(softwareSystem1, ""); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem1", softwareSystem1); + elements.register("softwaresystem2", softwareSystem2); + elements.register("box1", box1); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "user", "softwareSystem1", "box1")); + + assertEquals(3, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(user))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(softwareSystem1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(box1))); + assertTrue(view.getElements().stream().noneMatch(ev -> ev.getElement().equals(softwareSystem2))); + assertEquals(2, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsNearestNeighboursToASystemContextView_WhenTheGreedyWildcardIsSpecified() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + softwareSystem1.addContainer("Container 1", "Description", "Technology"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + user.uses(softwareSystem2, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemContextView view = views.createSystemContextView(softwareSystem1, "key", "Description"); + SystemContextViewDslContext context = new SystemContextViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*")); + + assertEquals(3, view.getElements().size()); + assertEquals(3, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsNearestNeighboursToASystemContextView_WhenTheReluctantWildcardIsSpecified() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + softwareSystem1.addContainer("Container 1", "Description", "Technology"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + user.uses(softwareSystem2, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemContextView view = views.createSystemContextView(softwareSystem1, "key", "Description"); + SystemContextViewDslContext context = new SystemContextViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "*?")); + + assertEquals(3, view.getElements().size()); + assertEquals(2, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsTheSpecifiedPeopleAndSoftwareSystemsToASystemContextView() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemContextView view = views.createSystemContextView(softwareSystem1, "key", "Description"); + SystemContextViewDslContext context = new SystemContextViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem1", softwareSystem1); + elements.register("softwaresystem2", softwareSystem2); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "user")); + + assertEquals(2, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(user))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(softwareSystem1))); + assertTrue(view.getElements().stream().noneMatch(ev -> ev.getElement().equals(softwareSystem2))); + assertEquals(1, view.getRelationships().size()); + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddAContainerToASystemContextView() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + + SystemContextView view = views.createSystemContextView(softwareSystem, "key", "Description"); + SystemContextViewDslContext context = new SystemContextViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("container", container); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "container")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"container\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseInclude_ThrowsAnException_WhenTryingToAddAComponentToASystemContextView() { + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Container container = softwareSystem.addContainer("Container", "Description", "Technology"); + Component component = container.addComponent("Component", "Description", "Technology"); + + SystemContextView view = views.createSystemContextView(softwareSystem, "key", "Description"); + SystemContextViewDslContext context = new SystemContextViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("component", component); + context.setIdentifierRegister(elements); + + try { + parser.parseInclude(context, tokens("include", "component")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element \"component\" can not be added to this type of view", iae.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheNoElementsAreSpecified() { + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseExclude(context, tokens("include")); + fail(); + } catch (RuntimeException iae) { + assertEquals("Expected: exclude <identifier|expression> [identifier|expression...]", iae.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheSpecifiedElementDoesNotExist() { + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseExclude(context, tokens("exclude", "user")); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element/relationship \"user\" does not exist", iae.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheSpecifiedPeopleAndSoftwareSystemsFromAView() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1", "Description"); + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2", "Description"); + user.uses(softwareSystem1, "Uses"); + softwareSystem1.uses(softwareSystem2, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem1", softwareSystem1); + elements.register("softwaresystem2", softwareSystem2); + context.setIdentifierRegister(elements); + + assertEquals(3, view.getElements().size()); + assertEquals(2, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "user", "softwaresystem1")); + + assertEquals(1, view.getElements().size()); + assertTrue(view.getElements().stream().noneMatch(ev -> ev.getElement().equals(user))); + assertTrue(view.getElements().stream().noneMatch(ev -> ev.getElement().equals(softwareSystem1))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(softwareSystem2))); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExplicitIdentifierIsSpecified() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + Relationship rel = user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister identifersRegister = new IdentifiersRegister(); + identifersRegister.register("rel", rel); + context.setIdentifierRegister(identifersRegister); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "rel")); + + assertEquals(2, view.getElements().size()); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(user))); + assertTrue(view.getElements().stream().anyMatch(ev -> ev.getElement().equals(softwareSystem))); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipSourceElementDoesNotExistInTheModel() { + try { + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "relationship.source==user && relationship.destination==softwareSystem")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"user\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_ThrowsAnException_WhenTheRelationshipDestinationElementDoesNotExistInTheModel() { + try { + Person user = model.addPerson("User", "Description"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.add(user); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + context.setIdentifierRegister(elements); + + parser.parseExclude(context, tokens("exclude", "relationship.source==user && relationship.destination==softwareSystem")); + + fail(); + } catch (RuntimeException re) { + assertEquals("The element \"softwareSystem\" does not exist", re.getMessage()); + } + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSourceAndDestination() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.source==user && relationship.destination==softwareSystem")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithSourceAndWildcard() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.source==user")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithWildcardAndDestination() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship.destination==softwareSystem")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseExclude_RemovesTheRelationshipFromAView_WhenAnExpressionIsSpecifiedWithWildcardAndWildcard() { + Person user = model.addPerson("User", "Description"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System", "Description"); + user.uses(softwareSystem, "Uses"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("user", user); + elements.register("softwaresystem", softwareSystem); + context.setIdentifierRegister(elements); + + assertEquals(2, view.getElements().size()); + assertEquals(1, view.getRelationships().size()); + + parser.parseExclude(context, tokens("exclude", "relationship==*")); + assertEquals(0, view.getRelationships().size()); + } + + @Test + void test_parseInclude_AddsAllElementsWithTheSpecifiedTag() { + Person user = model.addPerson("User"); + user.addTags("Tag 1"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(user)); + } + + @Test + void test_parseInclude_AddsAllElementsWithTheSpecifiedTags() { + Person user = model.addPerson("User"); + user.addTags("Tag 1", "Tag 2"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + softwareSystem.addTags("Tag 1"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1,Tag 2")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(user)); + } + + @Test + void test_parseInclude_AddsAllElementsWithTheSpecifiedTagIgnoringElementsThatAreNotPermitted() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + model.getElements().forEach(e -> e.addTags("Tag 1")); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + assertNull(view.getElementView(container)); // containers are not permitted on system landscape views + assertNull(view.getElementView(component)); // components are not permitted on system landscape views + } + + @Test + void test_parseInclude_AddsAllElementsWithTheSpecifiedTagsIgnoringElementsThatAreNotPermitted() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + model.getElements().forEach(e -> e.addTags("Tag 1", "Tag 2")); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag==Tag 1,Tag 2")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + assertNull(view.getElementView(container)); // containers are not permitted on system landscape views + assertNull(view.getElementView(component)); // components are not permitted on system landscape views + } + + @Test + void test_parseInclude_AddsAllElementsWithoutTheSpecifiedTag() { + Person user = model.addPerson("User"); + user.addTags("Tag 1"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag!=Tag 1")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseInclude_AddsAllElementsWithoutTheSpecifiedTags() { + Person user = model.addPerson("User"); + user.addTags("Tag 1", "Tag 2"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + softwareSystem.addTags("Tag 1"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag!=Tag 1,Tag 2")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseInclude_AddsAllElementsWithoutTheSpecifiedTagIgnoringElementsThatAreNotPermitted() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container 1"); + Component component = container.addComponent("Component"); + + model.getElements().forEach(e -> e.addTags("Tag 1")); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "element.tag!=Tag 2")); + + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + assertNull(view.getElementView(container)); // containers are not permitted on system landscape views + assertNull(view.getElementView(component)); // components are not permitted on system landscape views + } + + @Test + void test_parseExclude_RemovesAllElementsWithTheSpecifiedTag() { + Person user = model.addPerson("User"); + user.addTags("Tag 1"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "element.tag==Tag 1")); + + assertEquals(1, view.getElements().size()); + assertNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseExclude_RemovesAllElementsWithTheSpecifiedTags() { + Person user = model.addPerson("User"); + user.addTags("Tag 1", "Tag 2"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + softwareSystem.addTags("Tag 1"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "element.tag==Tag 1,Tag 2")); + + assertEquals(1, view.getElements().size()); + assertNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseExclude_RemovesAllElementsWithoutTheSpecifiedTag() { + Person user = model.addPerson("User"); + user.addTags("Tag 1"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "element.tag!=Tag 1")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseExclude_RemovesAllElementsWithoutTheSpecifiedTags() { + Person user = model.addPerson("User"); + user.addTags("Tag 1", "Tag 2"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + softwareSystem.addTags("Tag 1"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + assertEquals(2, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNotNull(view.getElementView(softwareSystem)); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "element.tag!=Tag 1,Tag 2")); + + assertEquals(1, view.getElements().size()); + assertNotNull(view.getElementView(user)); + assertNull(view.getElementView(softwareSystem)); + } + + @Test + void test_parseInclude_AddsAllRelationshipsWithTheSpecifiedTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + Relationship relationship1 = user.uses(softwareSystem, "1"); + relationship1.addTags("Tag 1"); + Relationship relationship2 = user.uses(softwareSystem, "2"); + relationship2.addTags("Tag 2"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + view.remove(relationship1); + view.remove(relationship2); + assertEquals(0, view.getRelationships().size()); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "relationship.tag==Tag 1")); + + assertEquals(1, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(relationship1)); + } + + @Test + void test_parseInclude_AddsAllRelationshipsWithTheSpecifiedTags() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + Relationship relationship1 = user.uses(softwareSystem, "1"); + relationship1.addTags("Tag 1", "Tag 2"); + Relationship relationship2 = user.uses(softwareSystem, "2"); + relationship2.addTags("Tag 2"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + view.remove(relationship1); + view.remove(relationship2); + assertEquals(0, view.getRelationships().size()); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseInclude(context, tokens("include", "relationship.tag==Tag 1,Tag 2")); + + assertEquals(1, view.getRelationships().size()); + assertNotNull(view.getRelationshipView(relationship1)); + } + + @Test + void test_parseExclude_RemovesAllRelationshipsWithTheSpecifiedTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + Relationship relationship1 = user.uses(softwareSystem, "1"); + relationship1.addTags("Tag 1"); + Relationship relationship2 = user.uses(softwareSystem, "2"); + relationship2.addTags("Tag 2"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + assertEquals(2, view.getRelationships().size()); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "relationship.tag==Tag 1")); + + assertEquals(1, view.getRelationships().size()); + assertNull(view.getRelationshipView(relationship1)); + assertNotNull(view.getRelationshipView(relationship2)); + } + + @Test + void test_parseExclude_RemovesAllRelationshipsWithTheSpecifiedTags() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + + Relationship relationship1 = user.uses(softwareSystem, "1"); + relationship1.addTags("Tag 1", "Tag 2"); + Relationship relationship2 = user.uses(softwareSystem, "2"); + relationship2.addTags("Tag 2"); + + SystemLandscapeView view = views.createSystemLandscapeView("key", "Description"); + view.addAllElements(); + assertEquals(2, view.getRelationships().size()); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + parser.parseExclude(context, tokens("exclude", "relationship.tag==Tag 1,Tag 2")); + + assertEquals(1, view.getRelationships().size()); + assertNull(view.getRelationshipView(relationship1)); + assertNotNull(view.getRelationshipView(relationship2)); + } + + @Test + void test_parseInclude_IncludesTheMostAbstractElementWhenEfferentCouplingExpressionUsed() { + SoftwareSystem ss1 = model.addSoftwareSystem("Software System 1"); + Container c1 = ss1.addContainer("Container 1"); + Component cc1 = c1.addComponent("Component 1"); + + SoftwareSystem ss2 = model.addSoftwareSystem("Software System 2"); + Container c2 = ss2.addContainer("Container 2"); + Component cc2 = c2.addComponent("Component 2"); + + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + cc1.uses(cc2, "Uses"); + + ComponentView view = views.createComponentView(c1, "key", "Description"); + ComponentViewDslContext context = new ComponentViewDslContext(view); + context.setWorkspace(workspace); + + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("cc1", cc1); + context.setIdentifierRegister(elements); + + parser.parseInclude(context, tokens("include", "cc1->")); + + assertEquals(2, view.getElements().size()); + assertTrue(view.isElementInView(cc1)); + assertTrue(view.isElementInView(ss2)); // this is the software system, not the component + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewExpressionParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewExpressionParserTests.java new file mode 100644 index 000000000..5c4a45e38 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/StaticViewExpressionParserTests.java @@ -0,0 +1,199 @@ +package com.structurizr.dsl; + +import com.structurizr.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticViewExpressionParserTests extends AbstractTests { + + private StaticViewExpressionParser parser = new StaticViewExpressionParser(); + + @Test + void test_parseExpression_ThrowsAnException_WhenElementTypeIsNotSupported() { + try { + parser.parseExpression("element.type==DeploymentNode", null); + fail(); + } catch (RuntimeException iae) { + assertEquals("The element type of \"DeploymentNode\" is not valid for this view", iae.getMessage()); + } + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsPerson() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==Person", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(user)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsSoftwareSystem() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==SoftwareSystem", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(softwareSystem)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsContainer() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==Container", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementTypeEqualsComponent() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.type==Component", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(component)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementHasTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Container", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenElementDoesNotHaveTag() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag!=Container", context); + assertEquals(3, elements.size()); + assertFalse(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenBooleanAndUsed() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Element && element.type==Container", context); + assertEquals(1, elements.size()); + assertTrue(elements.contains(container)); + } + + @Test + void test_parseExpression_ReturnsElements_WhenBooleanOrUsed() { + Person user = model.addPerson("User"); + SoftwareSystem softwareSystem = model.addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + Set<ModelItem> elements = parser.parseExpression("element.tag==Container || element.type==Component", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(container)); + assertTrue(elements.contains(component)); + } + + @Test + void test_parseExpression_ReturnsElements_ForAfferentCouplings() { + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + component1.uses(component2, "Uses"); + + ComponentViewDslContext context = new ComponentViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("container1", container1); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("container1->", context); + assertEquals(4, elements.size()); + assertTrue(elements.contains(container1)); + assertTrue(elements.contains(softwareSystem2)); + assertTrue(elements.contains(container2)); + assertTrue(elements.contains(component2)); + } + + @Test + void test_parseExpression_ReturnsElements_ForAfferentCouplingsOfType() { + SoftwareSystem softwareSystem1 = model.addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + + SoftwareSystem softwareSystem2 = model.addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + + model.setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + component1.uses(component2, "Uses"); + + ComponentViewDslContext context = new ComponentViewDslContext(null); + context.setWorkspace(workspace); + + IdentifiersRegister map = new IdentifiersRegister(); + map.register("container1", container1); + context.setIdentifierRegister(map); + + Set<ModelItem> elements = parser.parseExpression("container1-> && element.type==Container", context); + assertEquals(2, elements.size()); + assertTrue(elements.contains(container1)); + assertTrue(elements.contains(container2)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemContextViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemContextViewParserTests.java new file mode 100644 index 000000000..0b04ce40f --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemContextViewParserTests.java @@ -0,0 +1,109 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemContextView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class SystemContextViewParserTests extends AbstractTests { + + private SystemContextViewParser parser = new SystemContextViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("systemContext", "identifier", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: systemContext <software system identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSoftwareSystemIdentifierIsMissing() { + DslContext context = context(); + try { + parser.parse(context, tokens("systemContext")); + fail(); + } catch (Exception e) { + assertEquals("Expected: systemContext <software system identifier> [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheSoftwareSystemIsNotDefined() { + DslContext context = context(); + try { + parser.parse(context, tokens("systemContext", "softwareSystem", "key")); + fail(); + } catch (Exception e) { + assertEquals("The software system \"softwareSystem\" does not exist", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenTheElementIsNotASoftwareSystem() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addPerson("Name", "Description")); + context.setIdentifierRegister(elements); + + try { + parser.parse(context, tokens("systemContext", "softwareSystem", "key")); + fail(); + } catch (Exception e) { + assertEquals("The element \"softwareSystem\" is not a software system", e.getMessage()); + } + } + + @Test + void test_parse_CreatesASystemContextView() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("systemContext", "softwareSystem")); + List<SystemContextView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemContextViews()); + + assertEquals(1, views.size()); + assertEquals("SystemContext-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesASystemContextViewWithAKey() { + DslContext context = context(); + IdentifiersRegister elements = new IdentifiersRegister(); + elements.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(elements); + + parser.parse(context, tokens("systemContext", "softwareSystem", "key")); + List<SystemContextView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemContextViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesASystemContextViewWithAKeyAndDescription() { + DslContext context = context(); + IdentifiersRegister register = new IdentifiersRegister(); + register.register("softwaresystem", model.addSoftwareSystem("Name", "Description")); + context.setIdentifierRegister(register); + + parser.parse(context, tokens("systemContext", "softwareSystem", "key", "Description")); + List<SystemContextView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemContextViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("Description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemLandscapeViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemLandscapeViewParserTests.java new file mode 100644 index 000000000..5b00923cf --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/SystemLandscapeViewParserTests.java @@ -0,0 +1,60 @@ +package com.structurizr.dsl; + +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class SystemLandscapeViewParserTests extends AbstractTests { + + private SystemLandscapeViewParser parser = new SystemLandscapeViewParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + DslContext context = context(); + try { + parser.parse(context, tokens("systemLandscape", "key", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: systemLandscape [key] [description] {", e.getMessage()); + } + } + + @Test + void test_parse_CreatesASystemLandscapeView() { + DslContext context = context(); + parser.parse(context, tokens("systemLandscape")); + List<SystemLandscapeView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemLandscapeViews()); + + assertEquals(1, views.size()); + assertEquals("SystemLandscape-001", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesASystemLandscapeViewWithAKey() { + DslContext context = context(); + parser.parse(context, tokens("systemLandscape", "key")); + List<SystemLandscapeView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemLandscapeViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("", views.get(0).getDescription()); + } + + @Test + void test_parse_CreatesASystemLandscapeViewWithAKeyAndDescription() { + DslContext context = context(); + parser.parse(context, tokens("systemLandscape", "key", "description")); + List<SystemLandscapeView> views = new ArrayList<>(context.getWorkspace().getViews().getSystemLandscapeViews()); + + assertEquals(1, views.size()); + assertEquals("key", views.get(0).getKey()); + assertEquals("description", views.get(0).getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/TerminologyParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/TerminologyParserTests.java new file mode 100644 index 000000000..d0438c6b6 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/TerminologyParserTests.java @@ -0,0 +1,173 @@ +package com.structurizr.dsl; + +import com.structurizr.view.MetadataSymbols; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class TerminologyParserTests extends AbstractTests { + + private TerminologyParser parser = new TerminologyParser(); + + @Test + void test_parsePerson_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parsePerson(context(), tokens("person")); + fail(); + } catch (Exception e) { + assertEquals("Expected: person <term>", e.getMessage()); + } + } + + @Test + void test_parsePerson_SetsTheTerm_WhenOneIsSpecified() { + parser.parsePerson(context(), tokens("person", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getPerson()); + } + + @Test + void test_parseSoftwareSystem_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseSoftwareSystem(context(), tokens("softwareSystem")); + fail(); + } catch (Exception e) { + assertEquals("Expected: softwareSystem <term>", e.getMessage()); + } + } + + @Test + void test_parseSoftwareSystem_SetsTheTerm_WhenOneIsSpecified() { + parser.parseSoftwareSystem(context(), tokens("softwareSystem", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getSoftwareSystem()); + } + + @Test + void test_parseContainer_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseContainer(context(), tokens("container")); + fail(); + } catch (Exception e) { + assertEquals("Expected: container <term>", e.getMessage()); + } + } + + @Test + void test_parseContainer_SetsTheTerm_WhenOneIsSpecified() { + parser.parseContainer(context(), tokens("container", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getContainer()); + } + + @Test + void test_parseComponent_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseComponent(context(), tokens("component")); + fail(); + } catch (Exception e) { + assertEquals("Expected: component <term>", e.getMessage()); + } + } + + @Test + void test_parseComponent_SetsTheTerm_WhenOneIsSpecified() { + parser.parseComponent(context(), tokens("component", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getComponent()); + } + + @Test + void test_parsedeploymentNode_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseDeploymentNode(context(), tokens("deploymentNode")); + fail(); + } catch (Exception e) { + assertEquals("Expected: deploymentNode <term>", e.getMessage()); + } + } + + @Test + void test_parsedeploymentNode_SetsTheTerm_WhenOneIsSpecified() { + parser.parseDeploymentNode(context(), tokens("deploymentNode", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getDeploymentNode()); + } + + @Test + void test_parseInfrastructureNode_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseInfrastructureNode(context(), tokens("infrastructureNode")); + fail(); + } catch (Exception e) { + assertEquals("Expected: infrastructureNode <term>", e.getMessage()); + } + } + + @Test + void test_parseInfrastructureNode_SetsTheTerm_WhenOneIsSpecified() { + parser.parseInfrastructureNode(context(), tokens("infrastructureNode", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getInfrastructureNode()); + } + + @Test + void test_parseRelationship_ThrowsAnException_WhenNoTermIsSpecified() { + try { + parser.parseRelationship(context(), tokens("relationship")); + fail(); + } catch (Exception e) { + assertEquals("Expected: relationship <term>", e.getMessage()); + } + } + + @Test + void test_parseRelationship_SetsTheTerm_WhenOneIsSpecified() { + parser.parseRelationship(context(), tokens("relationship", "TERM")); + + assertEquals("TERM", workspace.getViews().getConfiguration().getTerminology().getRelationship()); + } + + @Test + void test_parseMetadataSymbols_ThrowsAnException_WhenNoSymbolTypeIsSpecified() { + try { + parser.parseMetadataSymbols(context(), tokens("metadata")); + fail(); + } catch (Exception e) { + assertEquals("Expected: metadata <square|round|curly|angle|double-angle|none>", e.getMessage()); + } + } + + @Test + void test_parseMetadataSymbols_ThrowsAnException_WhenAnInvalidSymbolTypeIsSpecified() { + try { + parser.parseMetadataSymbols(context(), tokens("metadata", "invalid")); + fail(); + } catch (Exception e) { + assertEquals("The symbol type \"invalid\" is not valid", e.getMessage()); + } + } + + @Test + void test_parseMetadataSymbols_SetsTheMetadataSymbols_WhenSpecified() { + parser.parseMetadataSymbols(context(), tokens("metadata", "square")); + assertEquals(MetadataSymbols.SquareBrackets, workspace.getViews().getConfiguration().getMetadataSymbols()); + + parser.parseMetadataSymbols(context(), tokens("metadata", "round")); + assertEquals(MetadataSymbols.RoundBrackets, workspace.getViews().getConfiguration().getMetadataSymbols()); + + parser.parseMetadataSymbols(context(), tokens("metadata", "curly")); + assertEquals(MetadataSymbols.CurlyBrackets, workspace.getViews().getConfiguration().getMetadataSymbols()); + + parser.parseMetadataSymbols(context(), tokens("metadata", "angle")); + assertEquals(MetadataSymbols.AngleBrackets, workspace.getViews().getConfiguration().getMetadataSymbols()); + + parser.parseMetadataSymbols(context(), tokens("metadata", "double-angle")); + assertEquals(MetadataSymbols.DoubleAngleBrackets, workspace.getViews().getConfiguration().getMetadataSymbols()); + + parser.parseMetadataSymbols(context(), tokens("metadata", "none")); + assertEquals(MetadataSymbols.None, workspace.getViews().getConfiguration().getMetadataSymbols()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ThemeParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ThemeParserTests.java new file mode 100644 index 000000000..28d3daa2b --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ThemeParserTests.java @@ -0,0 +1,143 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +class ThemeParserTests extends AbstractTests { + + private final ThemeParser parser = new ThemeParser(); + + @Test + void test_parseTheme_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parseTheme(context(), null, tokens("theme", "url", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: theme <url|file>", e.getMessage()); + } + } + + @Test + void test_parseTheme_ThrowsAnException_WhenNoThemeIsSpecified() { + try { + parser.parseTheme(context(), null, tokens("theme")); + fail(); + } catch (Exception e) { + assertEquals("Expected: theme <url|file>", e.getMessage()); + } + } + + @Test + void test_parseTheme_AddsTheTheme_WhenAThemeIsSpecified() { + parser.parseTheme(context(), null, tokens("theme", "http://example.com/1")); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("http://example.com/1", workspace.getViews().getConfiguration().getThemes()[0]); + } + + @Test + void test_parseTheme_AddsTheTheme_WhenTheDefaultThemeIsSpecified() { + parser.parseTheme(context(), null, tokens("theme", "default")); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("https://static.structurizr.com/themes/default/theme.json", workspace.getViews().getConfiguration().getThemes()[0]); + } + + @Test + void test_parseThemes_ThrowsAnException_WhenNoThemesAreSpecified() { + try { + parser.parseThemes(context(), null, tokens("themes")); + fail(); + } catch (Exception e) { + assertEquals("Expected: themes <url|file> [url|file] ... [url|file]", e.getMessage()); + } + } + + @Test + void test_parseThemes_AddsTheTheme_WhenOneThemeIsSpecified() { + parser.parseThemes(context(), null, tokens("themes", "http://example.com/1")); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("http://example.com/1", workspace.getViews().getConfiguration().getThemes()[0]); + } + + @Test + void test_parseThemes_AddsTheThemes_WhenMultipleThemesAreSpecified() { + parser.parseThemes(context(), null, tokens("themes", "http://example.com/1", "http://example.com/2", "http://example.com/3")); + + assertEquals(3, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("http://example.com/1", workspace.getViews().getConfiguration().getThemes()[0]); + assertEquals("http://example.com/2", workspace.getViews().getConfiguration().getThemes()[1]); + assertEquals("http://example.com/3", workspace.getViews().getConfiguration().getThemes()[2]); + } + + @Test + void test_parseThemes_AddsTheTheme_WhenTheDefaultThemeIsSpecified() { + parser.parseThemes(context(), null, tokens("themes", "default")); + + assertEquals(1, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("https://static.structurizr.com/themes/default/theme.json", workspace.getViews().getConfiguration().getThemes()[0]); + } + + @Test + void test_parseTheme_ThrowsAnException_WhenTheThemeFileDoesNotExist() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.FILE_SYSTEM); + + File dslFile = new File("src/test/resources/themes/workspace.dsl"); + + parser.parseTheme(context, dslFile, tokens("theme", "my-theme.json")); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage().endsWith("/src/test/resources/themes/my-theme.json does not exist")); + } + } + + @Test + void test_parseTheme_ThrowsAnException_WhenTheThemeFileIsADirectory() { + try { + DslContext context = context(); + context.getFeatures().enable(Features.FILE_SYSTEM); + + File dslFile = new File("src/test/resources/workspace.dsl"); + + parser.parseTheme(context, dslFile, tokens("theme", "themes")); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage().endsWith("/src/test/resources/themes is not a file")); + } + } + + @Test + void test_parseTheme_InlinesTheTheme_WhenAThemeFileIsSpecified() { + DslContext context = context(); + context.getFeatures().enable(Features.FILE_SYSTEM); + + File dslFile = new File("src/test/resources/themes/workspace.dsl"); + + parser.parseTheme(context, dslFile, tokens("theme", "theme.json")); + + assertEquals(0, workspace.getViews().getConfiguration().getThemes().length); + assertEquals("#ff0000", workspace.getViews().getConfiguration().getStyles().getElementStyle("Tag").getBackground()); + assertEquals("#00ff00", workspace.getViews().getConfiguration().getStyles().getRelationshipStyle("Tag").getColor()); + } + + @Test + void test_parseTheme_ThrowsAnException_WhenAThemeFileIsSpecifiedAndFileSystemAccessIsNotEnabled() { + try { + DslContext context = context(); + context.getFeatures().disable(Features.FILE_SYSTEM); + + File dslFile = new File("src/test/resources/themes/workspace.dsl"); + parser.parseTheme(context, dslFile, tokens("theme", "theme.json")); + fail(); + } catch (Exception e) { + assertEquals("File-based themes are not permitted (feature structurizr.feature.dsl.filesystem is not enabled)", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/TokenizerTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/TokenizerTests.java new file mode 100644 index 000000000..71b316836 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/TokenizerTests.java @@ -0,0 +1,71 @@ +package com.structurizr.dsl; + +import com.structurizr.model.Location; +import com.structurizr.model.Person; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TokenizerTests extends AbstractTests { + + @Test + void tokenize_ReturnsASingleToken_WhenTheLineIsOneUnquotedToken() { + List<String> tokens = new Tokenizer().tokenize("Hello"); + assertEquals(1, tokens.size()); + assertEquals("Hello", tokens.get(0)); + } + + @Test + void tokenize_ReturnsASingleToken_WhenTheLineIsOneUnquotedTokenWithEscapedQuoteCharacters() { + List<String> tokens = new Tokenizer().tokenize("\"Hello \\\"World\\\"\""); + assertEquals(1, tokens.size()); + assertEquals("Hello \\\"World\\\"", tokens.get(0)); + } + + @Test + void tokenize_ReturnsASingleToken_WhenTheLineIsTwoUnquotedTokens() { + List<String> tokens = new Tokenizer().tokenize("Hello World"); + assertEquals(2, tokens.size()); + assertEquals("Hello", tokens.get(0)); + assertEquals("World", tokens.get(1)); + } + + @Test + void tokenize_ReturnsTwoTokens_WhenTheLineIsOneQuotedToken() { + List<String> tokens = new Tokenizer().tokenize("\"Hello World\""); + assertEquals(1, tokens.size()); + assertEquals("Hello World", tokens.get(0)); + } + + @Test + void tokenize_ReturnsTwoTokens_WhenTheLineIsTwoQuotedTokens() { + List<String> tokens = new Tokenizer().tokenize("\"Hello\" \"World\""); + assertEquals(2, tokens.size()); + assertEquals("Hello", tokens.get(0)); + assertEquals("World", tokens.get(1)); + } + + @Test + void tokenize_ReturnsTokens_WhenTheLineIsSeveralQuotedTokens() { + List<String> tokens = new Tokenizer().tokenize("user = person \"User\""); + assertEquals(4, tokens.size()); + assertEquals("user", tokens.get(0)); + assertEquals("=", tokens.get(1)); + assertEquals("person", tokens.get(2)); + assertEquals("User", tokens.get(3)); + } + + @Test + void tokenize_ReturnsASingleToken_WhenTheLineIncludesTabCharacters() { + List<String> tokens = new Tokenizer().tokenize("\t\tuser\t=\tperson\t\"User\""); + assertEquals(4, tokens.size()); + assertEquals("user", tokens.get(0)); + assertEquals("=", tokens.get(1)); + assertEquals("person", tokens.get(2)); + assertEquals("User", tokens.get(3)); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/TokensTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/TokensTests.java new file mode 100644 index 000000000..0b03ba3c1 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/TokensTests.java @@ -0,0 +1,23 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TokensTests { + + @Test + void test_get_HandlesEscapedDoubleQuotes() { + Tokens tokens = new Tokens(Collections.singletonList("Hello \\\"World\\\"")); + assertEquals("Hello \"World\"", tokens.get(0)); + } + + @Test + void test_get_HandlesEscapedNewlines() { + Tokens tokens = new Tokens(Collections.singletonList("Hello\\nWorld")); + assertEquals("Hello\nWorld", tokens.get(0)); + } + +} diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/UserRoleParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/UserRoleParserTests.java new file mode 100644 index 000000000..df80a4b25 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/UserRoleParserTests.java @@ -0,0 +1,80 @@ +package com.structurizr.dsl; + +import com.structurizr.configuration.Role; +import com.structurizr.configuration.User; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class UserRoleParserTests extends AbstractTests { + + private UserRoleParser parser = new UserRoleParser(); + + @Test + void test_parse_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(context(), tokens("username", "role", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: <username> <read|write>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoUsernameIsSpecified() { + try { + parser.parse(context(), tokens("")); + fail(); + } catch (Exception e) { + assertEquals("Expected: <username> <read|write>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenNoRoleIsSpecified() { + try { + parser.parse(context(), tokens("user@example.com")); + fail(); + } catch (Exception e) { + assertEquals("Expected: <username> <read|write>", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenAnInvalidRoleIsSpecified() { + try { + parser.parse(context(), tokens("user@example.com", "role")); + fail(); + } catch (Exception e) { + assertEquals("The role should be \"read\" or \"write\"", e.getMessage()); + } + } + + @Test + void test_parse_AddsAReadOnlyUser() { + parser.parse(context(), tokens("user@example.com", "read")); + + Set<User> users = context().getWorkspace().getConfiguration().getUsers(); + assertEquals(1, users.size()); + + User user = users.stream().findFirst().get(); + assertEquals("user@example.com", user.getUsername()); + assertEquals(Role.ReadOnly, user.getRole()); + } + + @Test + void test_parse_AddsAReadWriteUser() { + parser.parse(context(), tokens("user@example.com", "write")); + + Set<User> users = context().getWorkspace().getConfiguration().getUsers(); + assertEquals(1, users.size()); + + User user = users.stream().findFirst().get(); + assertEquals("user@example.com", user.getUsername()); + assertEquals(Role.ReadWrite, user.getRole()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/ViewParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/ViewParserTests.java new file mode 100644 index 000000000..bad186b03 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/ViewParserTests.java @@ -0,0 +1,156 @@ +package com.structurizr.dsl; + +import com.structurizr.view.CustomView; +import com.structurizr.view.DeploymentView; +import com.structurizr.view.DynamicView; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ViewParserTests extends AbstractTests { + + private ViewParser parser = new ViewParser(); + + @Test + void test_parseTitle_ThrowsAnException_WhenThereAreTooManyTokens() { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + try { + parser.parseTitle(context, tokens("title", "title", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: title <title>", e.getMessage()); + } + } + + @Test + void test_parseTitle_ThrowsAnException_WhenNoTitleIsSpecified() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseTitle(context, tokens("title")); + fail(); + } catch (Exception e) { + assertEquals("Expected: title <title>", e.getMessage()); + } + } + + @Test + void test_parseTitle_SetsTheTitleOfACustomView() { + CustomView view = workspace.getViews().createCustomView("key", "title", "description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + assertEquals("title", view.getTitle()); + parser.parseTitle(context, tokens("title", "A new title")); + assertEquals("A new title", view.getTitle()); + } + + @Test + void test_parseTitle_SetsTheTitleOfAStaticView() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getTitle()); + parser.parseTitle(context, tokens("title", "A new title")); + assertEquals("A new title", view.getTitle()); + } + + @Test + void test_parseTitle_SetsTheTitleOfADynamicView() { + DynamicView view = workspace.getViews().createDynamicView("key", "description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getTitle()); + parser.parseTitle(context, tokens("title", "A new title")); + assertEquals("A new title", view.getTitle()); + } + + @Test + void test_parseTitle_SetsTheTitleOfADeploymentView() { + DeploymentView view = workspace.getViews().createDeploymentView("key", "description"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + assertNull(view.getTitle()); + parser.parseTitle(context, tokens("title", "A new title")); + assertEquals("A new title", view.getTitle()); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenThereAreTooManyTokens() { + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(null); + context.setWorkspace(workspace); + + try { + parser.parseDescription(context, tokens("description", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_ThrowsAnException_WhenNoTitleIsSpecified() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + try { + parser.parseDescription(context, tokens("description")); + fail(); + } catch (Exception e) { + assertEquals("Expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_SetsTheTitleOfACustomView() { + CustomView view = workspace.getViews().createCustomView("key", "title", "description"); + CustomViewDslContext context = new CustomViewDslContext(view); + context.setWorkspace(workspace); + + assertEquals("description", view.getDescription()); + parser.parseDescription(context, tokens("description", "A new description")); + assertEquals("A new description", view.getDescription()); + } + + @Test + void test_parseDescription_SetsTheTitleOfAStaticView() { + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "description"); + SystemLandscapeViewDslContext context = new SystemLandscapeViewDslContext(view); + context.setWorkspace(workspace); + + assertEquals("description", view.getDescription()); + parser.parseDescription(context, tokens("description", "A new description")); + assertEquals("A new description", view.getDescription()); + } + + @Test + void test_parseDescription_SetsTheTitleOfADynamicView() { + DynamicView view = workspace.getViews().createDynamicView("key", "description"); + DynamicViewDslContext context = new DynamicViewDslContext(view); + context.setWorkspace(workspace); + + assertEquals("description", view.getDescription()); + parser.parseDescription(context, tokens("description", "A new description")); + assertEquals("A new description", view.getDescription()); + } + + @Test + void test_parseDescription_SetsTheTitleOfADeploymentView() { + DeploymentView view = workspace.getViews().createDeploymentView("key", "description"); + DeploymentViewDslContext context = new DeploymentViewDslContext(view); + context.setWorkspace(workspace); + + assertEquals("description", view.getDescription()); + parser.parseDescription(context, tokens("description", "A new description")); + assertEquals("A new description", view.getDescription()); + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/WorkspaceParserTests.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/WorkspaceParserTests.java new file mode 100644 index 000000000..3f3ba44b4 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/WorkspaceParserTests.java @@ -0,0 +1,131 @@ +package com.structurizr.dsl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +class WorkspaceParserTests extends AbstractTests { + + private final WorkspaceParser parser = new WorkspaceParser(); + + @Test + void test_parseTitle_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + parser.parse(null, tokens("workspace", "name", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: workspace [name] [description] or workspace extends <file|url>", e.getMessage()); + } + } + + @Test + void test_parse_DoesNotThrowAnException_WhenNoPropertiesAreSpecified() { + assertEquals("Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + workspace = parser.parse(null, tokens("workspace")); + assertEquals("Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + } + + @Test + void test_parse_SetsTheWorkspaceName_WhenANameIsSpecified() { + assertEquals("Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + workspace = parser.parse(null, tokens("workspace", "New Name")); + assertEquals("New Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + } + + @Test + void test_parse_SetsTheWorkspaceNameAndDescription_WhenANameAndDescriptionAreSpecified() { + assertEquals("Name", workspace.getName()); + assertEquals("Description", workspace.getDescription()); + workspace = parser.parse(null, tokens("workspace", "New Name", "New Description")); + assertEquals("New Name", workspace.getName()); + assertEquals("New Description", workspace.getDescription()); + } + + @Test + void test_parse_ThrowsAnException_WhenExtendingAHttpsUrlAndHttpsIsNotEnabled() { + try { + DslParserContext context = new DslParserContext(null, null); + context.getFeatures().disable(Features.HTTPS); + + parser.parse(context, tokens("workspace", "extends", "https://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Extends via HTTPS are not permitted (feature structurizr.feature.dsl.https is not enabled)", e.getMessage()); + } + } + + @Test + void test_parse_ThrowsAnException_WhenExtendingAHttpUrlAndHttpIsNotEnabled() { + try { + DslParserContext context = new DslParserContext(null, null); + context.getFeatures().disable(Features.HTTPS); + + parser.parse(context, tokens("workspace", "extends", "http://example.com")); + fail(); + } catch (Exception e) { + assertEquals("Extends via HTTP are not permitted (feature structurizr.feature.dsl.http is not enabled)", e.getMessage()); + } + } + + @Test + void test_parseName_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + ModelItemDslContext context = new SoftwareSystemDslContext(null); + parser.parseName(context, tokens("name", "name", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: name <name>", e.getMessage()); + } + } + + @Test + void test_parseName_ThrowsAnException_WhenNoUrlIsSpecified() { + try { + parser.parseName(context(), tokens("name")); + fail(); + } catch (Exception e) { + assertEquals("Expected: name <name>", e.getMessage()); + } + } + + @Test + void test_parseName_SetsTheName_WhenANameIsSpecified() { + parser.parseName(context(), tokens("name", "A new name")); + + assertEquals("A new name", context().getWorkspace().getName()); + } + + @Test + void test_parseDescription_ThrowsAnException_WhenThereAreTooManyTokens() { + try { + ModelItemDslContext context = new SoftwareSystemDslContext(null); + parser.parseDescription(context, tokens("description", "description", "extra")); + fail(); + } catch (Exception e) { + assertEquals("Too many tokens, expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_ThrowsAnException_WhenNoUrlIsSpecified() { + try { + parser.parseDescription(context(), tokens("description")); + fail(); + } catch (Exception e) { + assertEquals("Expected: description <description>", e.getMessage()); + } + } + + @Test + void test_parseDescription_SetsTheDescription_WhenADescriptionIsSpecified() { + parser.parseDescription(context(), tokens("description", "A new description")); + + assertEquals("A new description", context().getWorkspace().getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomImpliedRelationshipsStrategy.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomImpliedRelationshipsStrategy.java new file mode 100644 index 000000000..bb45da945 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomImpliedRelationshipsStrategy.java @@ -0,0 +1,13 @@ +package com.structurizr.dsl.example; + +import com.structurizr.model.ImpliedRelationshipsStrategy; +import com.structurizr.model.Relationship; + +public class CustomImpliedRelationshipsStrategy implements ImpliedRelationshipsStrategy { + + @Override + public void createImpliedRelationships(Relationship relationship) { + + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomTypeMatcher.java b/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomTypeMatcher.java new file mode 100644 index 000000000..6530ae000 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/dsl/example/CustomTypeMatcher.java @@ -0,0 +1,18 @@ +package com.structurizr.dsl.example; + +import com.structurizr.component.Type; +import com.structurizr.component.matcher.TypeMatcher; + +public class CustomTypeMatcher implements TypeMatcher { + + @Override + public boolean matches(Type type) { + return false; + } + + @Override + public String toString() { + return "CustomTypeMatcher{}"; + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDecisionImporter.java b/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDecisionImporter.java new file mode 100644 index 000000000..e17d2370e --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDecisionImporter.java @@ -0,0 +1,6 @@ +package com.structurizr.example; + +import com.structurizr.importer.documentation.AdrToolsDecisionImporter; + +public class ExampleDecisionImporter extends AdrToolsDecisionImporter { +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDocumentationImporter.java b/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDocumentationImporter.java new file mode 100644 index 000000000..59e58a1c4 --- /dev/null +++ b/structurizr-dsl/src/test/java/com/structurizr/example/ExampleDocumentationImporter.java @@ -0,0 +1,6 @@ +package com.structurizr.example; + +import com.structurizr.importer.documentation.DefaultDocumentationImporter; + +public class ExampleDocumentationImporter extends DefaultDocumentationImporter { +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/amazon-web-services-local.dsl b/structurizr-dsl/src/test/resources/dsl/amazon-web-services-local.dsl new file mode 100644 index 000000000..6cb363198 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/amazon-web-services-local.dsl @@ -0,0 +1,65 @@ +workspace "Amazon Web Services Example" "An example AWS deployment architecture." { + + !identifiers hierarchical + + model { + springPetClinic = softwaresystem "Spring PetClinic" "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." "Spring Boot Application" { + webApplication = container "Web Application" "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." "Java and Spring Boot" + database = container "Database" "Stores information regarding the veterinarians, the clients, and their pets." "Relational database schema" "Database" + webApplication -> database "Reads from and writes to" "JDBC/SSL" + } + + live = deploymentEnvironment "Live" { + aws = deploymentNode "Amazon Web Services" "" "" "Amazon Web Services - Cloud" { + region = deploymentNode "US-East-1" "" "" "Amazon Web Services - Region" { + route53 = infrastructureNode "Route 53" "" "" "Amazon Web Services - Route 53" + elb = infrastructureNode "Elastic Load Balancer" "" "" "Amazon Web Services - Elastic Load Balancing" + + autoscalingGroup = deploymentNode "Autoscaling group" "" "" "Amazon Web Services - Auto Scaling" { + ec2 = deploymentNode "Amazon EC2" "" "" "Amazon Web Services - EC2" { + webApplicationInstance = containerInstance springPetClinic.webApplication + elb -> webApplicationInstance "Forwards requests to" "HTTPS" + } + } + + rds = deploymentNode "Amazon RDS" "" "" "Amazon Web Services - RDS" { + mysql = deploymentNode "MySQL" "" "" "Amazon Web Services - RDS MySQL instance" { + databaseInstance = containerInstance springPetClinic.database + } + } + + route53 -> elb "Forwards requests to" "HTTPS" + } + } + } + } + + views { + deployment springPetClinic "Live" "AmazonWebServicesDeployment" { + include * + autolayout lr + + animation { + live.aws.region.route53 + live.aws.region.elb + live.aws.region.autoscalingGroup.ec2.webApplicationInstance + live.aws.region.rds.mysql.databaseInstance + } + } + + styles { + element "Element" { + shape roundedbox + background #ffffff + } + element "Database" { + shape cylinder + } + element "Infrastructure Node" { + shape roundedbox + } + } + + themes https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/amazon-web-services.dsl b/structurizr-dsl/src/test/resources/dsl/amazon-web-services.dsl new file mode 100644 index 000000000..78948465d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/amazon-web-services.dsl @@ -0,0 +1,84 @@ +workspace "Amazon Web Services Example" "An example AWS deployment architecture." { + + model { + springPetClinic = softwaresystem "Spring PetClinic" "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." "Spring Boot Application" { + webApplication = container "Web Application" "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." "Java and Spring Boot" + database = container "Database" "Stores information regarding the veterinarians, the clients, and their pets." "Relational database schema" "Database" + } + + webApplication -> database "Reads from and writes to" "MySQL Protocol/SSL" + + live = deploymentEnvironment "Live" { + + deploymentNode "Amazon Web Services" { + tags "Amazon Web Services - Cloud" + + region = deploymentNode "US-East-1" { + tags "Amazon Web Services - Region" + + route53 = infrastructureNode "Route 53" { + tags "Amazon Web Services - Route 53" + } + + elb = infrastructureNode "Elastic Load Balancer" { + tags "Amazon Web Services - Elastic Load Balancing" + } + + deploymentNode "Autoscaling group" { + tags "Amazon Web Services - Auto Scaling" + + deploymentNode "Amazon EC2" { + tags "Amazon Web Services - EC2" + + webApplicationInstance = containerInstance webApplication + } + } + + deploymentNode "Amazon RDS" { + tags "Amazon Web Services - RDS" + + deploymentNode "MySQL" { + tags "Amazon Web Services - RDS MySQL instance" + + databaseInstance = containerInstance database + } + } + + } + } + + route53 -> elb "Forwards requests to" "HTTPS" + elb -> webApplicationInstance "Forwards requests to" "HTTPS" + } + } + + views { + deployment springPetClinic "Live" "AmazonWebServicesDeployment" { + include * + autolayout lr + + animation { + route53 + elb + webApplicationInstance + databaseInstance + } + } + + styles { + element "Element" { + shape roundedbox + background #ffffff + } + element "Database" { + shape cylinder + } + element "Infrastructure Node" { + shape roundedbox + } + } + + themes https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-for-custom-elements.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-for-custom-elements.dsl new file mode 100644 index 000000000..c3d2a3cf7 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-for-custom-elements.dsl @@ -0,0 +1,17 @@ +workspace { + + model { + archetypes { + hardwareSystem = element { + metadata "Hardware System" + tag "Hardware System" + } + } + + a = softwareSystem "A" + b = hardwareSystem "B" + + a -> b "Gets data from" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-for-defaults.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-for-defaults.dsl new file mode 100644 index 000000000..cc6d71aee --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-for-defaults.dsl @@ -0,0 +1,38 @@ +workspace { + + model { + archetypes { + softwaresystem { + description "Default Description" + tag "Default Tag" + + properties { + "Default Property Name" "Default Property Value" + } + + perspectives { + "Default Perspective Name" "Default Perspective Description" + } + } + + -> { + description "Default Description" + technology "Default Technology" + tag "Default Tag" + + properties { + "Default Property Name" "Default Property Value" + } + + perspectives { + "Default Perspective Name" "Default Perspective Description" + } + } + } + + a = softwareSystem "A" + b = softwareSystem "B" + a -> b + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-for-extension.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-for-extension.dsl new file mode 100644 index 000000000..105e25b44 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-for-extension.dsl @@ -0,0 +1,33 @@ +workspace { + + model { + archetypes { + softwaresystem { + tag "Default Tag" + } + + externalSoftwareSystem = softwareSystem { + tag "External Software System" + } + + -> { + technology "Default Technology" + tag "Default Tag" + } + + sync = -> { + tag "Synchronous" + } + + https = --sync-> { + technology "HTTPS" + tag "HTTPS" + } + } + + a = softwareSystem "A" "Description of A." + b = externalSoftwareSystem "B" "Description of B." + a --https-> b "Makes API calls to" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-for-implicit-relationships.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-for-implicit-relationships.dsl new file mode 100644 index 000000000..8a642f3bd --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-for-implicit-relationships.dsl @@ -0,0 +1,15 @@ +workspace { + model { + archetypes { + https = -> { + technology "HTTPS" + tag "HTTPS" + } + } + + a = softwareSystem "A" + b = softwareSystem "B" { + --https-> a "Makes API calls to" + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-child.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-child.dsl new file mode 100644 index 000000000..0b5d03293 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-child.dsl @@ -0,0 +1,9 @@ +workspace extends archetypes-from-workspace-extension-parent.dsl { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + a -> b + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-parent.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-parent.dsl new file mode 100644 index 000000000..2ae521527 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes-from-workspace-extension-parent.dsl @@ -0,0 +1,34 @@ +workspace { + + model { + archetypes { + softwaresystem { + description "Default Description" + tag "Default Tag" + + properties { + "Default Property Name" "Default Property Value" + } + + perspectives { + "Default Perspective Name" "Default Perspective Description" + } + } + + -> { + description "Default Description" + technology "Default Technology" + tag "Default Tag" + + properties { + "Default Property Name" "Default Property Value" + } + + perspectives { + "Default Perspective Name" "Default Perspective Description" + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/archetypes.dsl b/structurizr-dsl/src/test/resources/dsl/archetypes.dsl new file mode 100644 index 000000000..d892dcb5d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/archetypes.dsl @@ -0,0 +1,51 @@ +workspace { + + model { + archetypes { + application = container { + tag "Application" + } + datastore = container { + tag "Datastore" + } + + microservice = group + + springBootApplication = application { + technology "Spring Boot" + tags "Spring Boot" + } + + restController = component { + technology "Spring MVC REST Controller" + tag "Spring MVC REST Controller" + } + repository = component { + technology "Spring Data Repository" + tag "Spring Data Repository" + } + + https = -> { + technology "HTTPS" + } + } + + a = softwareSystem "A" + + x = softwareSystem "X" { + customerService = microservice "Customer Service" { + db = datastore "Customer database" + api = springBootApplication "Customer API" { + customerController = restController "Customer Controller" { + a --https-> this "Makes API calls using" + } + customerRepository = repository "Customer Repository" { + customerController -> this + this -> db + } + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc.dsl new file mode 100644 index 000000000..6cca65aed --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc.dsl @@ -0,0 +1,249 @@ +/* + * This is a combined version of the following workspaces: + * + * - "Big Bank plc - System Landscape" (https://structurizr.com/share/28201/) + * - "Big Bank plc - Internet Banking System" (https://structurizr.com/share/36141/) +*/ +workspace "Big Bank plc" "This is an example workspace to illustrate the key features of Structurizr, via the DSL, based around a fictional online banking system." { + + model { + customer = person "Personal Banking Customer" "A customer of the bank, with personal bank accounts." "Customer" + + group "Big Bank plc" { + supportStaff = person "Customer Service Staff" "Customer service staff within the bank." "Bank Staff" + backoffice = person "Back Office Staff" "Administration and support staff within the bank." "Bank Staff" + + mainframe = softwaresystem "Mainframe Banking System" "Stores all of the core banking information about customers, accounts, transactions, etc." "Existing System" + email = softwaresystem "E-mail System" "The internal Microsoft Exchange e-mail system." "Existing System" + atm = softwaresystem "ATM" "Allows customers to withdraw cash." "Existing System" + + internetBankingSystem = softwaresystem "Internet Banking System" "Allows customers to view information about their bank accounts, and make payments." { + singlePageApplication = container "Single-Page Application" "Provides all of the Internet banking functionality to customers via their web browser." "JavaScript and Angular" "Web Browser" + mobileApp = container "Mobile App" "Provides a limited subset of the Internet banking functionality to customers via their mobile device." "Xamarin" "Mobile App" + webApplication = container "Web Application" "Delivers the static content and the Internet banking single page application." "Java and Spring MVC" + apiApplication = container "API Application" "Provides Internet banking functionality via a JSON/HTTPS API." "Java and Spring MVC" { + signinController = component "Sign In Controller" "Allows users to sign in to the Internet Banking System." "Spring MVC Rest Controller" + accountsSummaryController = component "Accounts Summary Controller" "Provides customers with a summary of their bank accounts." "Spring MVC Rest Controller" + resetPasswordController = component "Reset Password Controller" "Allows users to reset their passwords with a single use URL." "Spring MVC Rest Controller" + securityComponent = component "Security Component" "Provides functionality related to signing in, changing passwords, etc." "Spring Bean" + mainframeBankingSystemFacade = component "Mainframe Banking System Facade" "A facade onto the mainframe banking system." "Spring Bean" + emailComponent = component "E-mail Component" "Sends e-mails to users." "Spring Bean" + } + database = container "Database" "Stores user registration information, hashed authentication credentials, access logs, etc." "Oracle Database Schema" "Database" + } + } + + # relationships between people and software systems + customer -> internetBankingSystem "Views account balances, and makes payments using" + internetBankingSystem -> mainframe "Gets account information from, and makes payments using" + internetBankingSystem -> email "Sends e-mail using" + email -> customer "Sends e-mails to" + customer -> supportStaff "Asks questions to" "Telephone" + supportStaff -> mainframe "Uses" + customer -> atm "Withdraws cash using" + atm -> mainframe "Uses" + backoffice -> mainframe "Uses" + + # relationships to/from containers + customer -> webApplication "Visits bigbank.com/ib using" "HTTPS" + customer -> singlePageApplication "Views account balances, and makes payments using" + customer -> mobileApp "Views account balances, and makes payments using" + webApplication -> singlePageApplication "Delivers to the customer's web browser" + + # relationships to/from components + singlePageApplication -> signinController "Makes API calls to" "JSON/HTTPS" + singlePageApplication -> accountsSummaryController "Makes API calls to" "JSON/HTTPS" + singlePageApplication -> resetPasswordController "Makes API calls to" "JSON/HTTPS" + mobileApp -> signinController "Makes API calls to" "JSON/HTTPS" + mobileApp -> accountsSummaryController "Makes API calls to" "JSON/HTTPS" + mobileApp -> resetPasswordController "Makes API calls to" "JSON/HTTPS" + signinController -> securityComponent "Uses" + accountsSummaryController -> mainframeBankingSystemFacade "Uses" + resetPasswordController -> securityComponent "Uses" + resetPasswordController -> emailComponent "Uses" + securityComponent -> database "Reads from and writes to" "JDBC" + mainframeBankingSystemFacade -> mainframe "Makes API calls to" "XML/HTTPS" + emailComponent -> email "Sends e-mail using" + + deploymentEnvironment "Development" { + deploymentNode "Developer Laptop" "" "Microsoft Windows 10 or Apple macOS" { + deploymentNode "Web Browser" "" "Chrome, Firefox, Safari, or Edge" { + developerSinglePageApplicationInstance = containerInstance singlePageApplication + } + deploymentNode "Docker Container - Web Server" "" "Docker" { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + developerWebApplicationInstance = containerInstance webApplication + developerApiApplicationInstance = containerInstance apiApplication + } + } + deploymentNode "Docker Container - Database Server" "" "Docker" { + deploymentNode "Database Server" "" "Oracle 12c" { + developerDatabaseInstance = containerInstance database + } + } + } + deploymentNode "Big Bank plc" "" "Big Bank plc data center" "" { + deploymentNode "bigbank-dev001" "" "" "" { + softwareSystemInstance mainframe + } + } + + } + + deploymentEnvironment "Live" { + deploymentNode "Customer's mobile device" "" "Apple iOS or Android" { + liveMobileAppInstance = containerInstance mobileApp + } + deploymentNode "Customer's computer" "" "Microsoft Windows or Apple macOS" { + deploymentNode "Web Browser" "" "Chrome, Firefox, Safari, or Edge" { + liveSinglePageApplicationInstance = containerInstance singlePageApplication + } + } + + deploymentNode "Big Bank plc" "" "Big Bank plc data center" { + deploymentNode "bigbank-web***" "" "Ubuntu 16.04 LTS" "" 4 { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + liveWebApplicationInstance = containerInstance webApplication + } + } + deploymentNode "bigbank-api***" "" "Ubuntu 16.04 LTS" "" 8 { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + liveApiApplicationInstance = containerInstance apiApplication + } + } + + deploymentNode "bigbank-db01" "" "Ubuntu 16.04 LTS" { + primaryDatabaseServer = deploymentNode "Oracle - Primary" "" "Oracle 12c" { + livePrimaryDatabaseInstance = containerInstance database + } + } + deploymentNode "bigbank-db02" "" "Ubuntu 16.04 LTS" "Failover" { + secondaryDatabaseServer = deploymentNode "Oracle - Secondary" "" "Oracle 12c" "Failover" { + liveSecondaryDatabaseInstance = containerInstance database "Failover" + } + } + deploymentNode "bigbank-prod001" "" "" "" { + softwareSystemInstance mainframe + } + } + + primaryDatabaseServer -> secondaryDatabaseServer "Replicates data to" + } + } + + views { + systemlandscape "SystemLandscape" { + include * + autoLayout + } + + systemcontext internetBankingSystem "SystemContext" { + include * + animation { + internetBankingSystem + customer + mainframe + email + } + autoLayout + } + + container internetBankingSystem "Containers" { + include * + animation { + customer mainframe email + webApplication + singlePageApplication + mobileApp + apiApplication + database + } + autoLayout + } + + component apiApplication "Components" { + include * + animation { + singlePageApplication mobileApp database email mainframe + signinController securityComponent + accountsSummaryController mainframeBankingSystemFacade + resetPasswordController emailComponent + } + autoLayout + } + + dynamic apiApplication "SignIn" "Summarises how the sign in feature works in the single-page application." { + singlePageApplication -> signinController "Submits credentials to" + signinController -> securityComponent "Validates credentials using" + securityComponent -> database "select * from users where username = ?" + database -> securityComponent "Returns user data to" + securityComponent -> signinController "Returns true if the hashed password matches" + signinController -> singlePageApplication "Sends back an authentication token to" + autoLayout + } + + deployment internetBankingSystem "Development" "DevelopmentDeployment" { + include * + animation { + developerSinglePageApplicationInstance + developerWebApplicationInstance developerApiApplicationInstance + developerDatabaseInstance + } + autoLayout + } + + deployment internetBankingSystem "Live" "LiveDeployment" { + include * + animation { + liveSinglePageApplicationInstance + liveMobileAppInstance + liveWebApplicationInstance liveApiApplicationInstance + livePrimaryDatabaseInstance + liveSecondaryDatabaseInstance + } + autoLayout + } + + styles { + element "Person" { + color #ffffff + fontSize 22 + shape Person + } + element "Customer" { + background #08427b + } + element "Bank Staff" { + background #999999 + } + element "Software System" { + background #1168bd + color #ffffff + } + element "Existing System" { + background #999999 + color #ffffff + } + element "Container" { + background #438dd5 + color #ffffff + } + element "Web Browser" { + shape WebBrowser + } + element "Mobile App" { + shape MobileDeviceLandscape + } + element "Database" { + shape Cylinder + } + element "Component" { + background #85bbf0 + color #000000 + } + element "Failover" { + opacity 25 + } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/internet-banking-system.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/internet-banking-system.dsl new file mode 100644 index 000000000..ec0dc243b --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/internet-banking-system.dsl @@ -0,0 +1,189 @@ +!const INTERNET_BANKING_SYSTEM_INCLUDE "details.dsl" + +workspace "Big Bank plc - Internet Banking System" "The software architecture of the Big Bank plc Internet Banking System." { + + model { + !include model/people-and-software-systems.dsl + + # relationships to/from containers + customer -> webApplication "Visits bigbank.com/ib using" "HTTPS" + customer -> singlePageApplication "Views account balances, and makes payments using" + customer -> mobileApp "Views account balances, and makes payments using" + webApplication -> singlePageApplication "Delivers to the customer's web browser" + + # relationships to/from components + singlePageApplication -> signinController "Makes API calls to" "JSON/HTTPS" + singlePageApplication -> accountsSummaryController "Makes API calls to" "JSON/HTTPS" + singlePageApplication -> resetPasswordController "Makes API calls to" "JSON/HTTPS" + mobileApp -> signinController "Makes API calls to" "JSON/HTTPS" + mobileApp -> accountsSummaryController "Makes API calls to" "JSON/HTTPS" + mobileApp -> resetPasswordController "Makes API calls to" "JSON/HTTPS" + signinController -> securityComponent "Uses" + accountsSummaryController -> mainframeBankingSystemFacade "Uses" + resetPasswordController -> securityComponent "Uses" + resetPasswordController -> emailComponent "Uses" + securityComponent -> database "Reads from and writes to" "JDBC" + mainframeBankingSystemFacade -> mainframe "Makes API calls to" "XML/HTTPS" + emailComponent -> email "Sends e-mail using" + + deploymentEnvironment "Development" { + deploymentNode "Developer Laptop" "" "Microsoft Windows 10 or Apple macOS" { + deploymentNode "Web Browser" "" "Chrome, Firefox, Safari, or Edge" { + developerSinglePageApplicationInstance = containerInstance singlePageApplication + } + deploymentNode "Docker Container - Web Server" "" "Docker" { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + developerWebApplicationInstance = containerInstance webApplication + developerApiApplicationInstance = containerInstance apiApplication + } + } + deploymentNode "Docker Container - Database Server" "" "Docker" { + deploymentNode "Database Server" "" "Oracle 12c" { + developerDatabaseInstance = containerInstance database + } + } + } + deploymentNode "Big Bank plc" "" "Big Bank plc data center" "" { + deploymentNode "bigbank-dev001" "" "" "" { + softwareSystemInstance mainframe + } + } + } + + deploymentEnvironment "Live" { + deploymentNode "Customer's mobile device" "" "Apple iOS or Android" { + liveMobileAppInstance = containerInstance mobileApp + } + deploymentNode "Customer's computer" "" "Microsoft Windows or Apple macOS" { + deploymentNode "Web Browser" "" "Chrome, Firefox, Safari, or Edge" { + liveSinglePageApplicationInstance = containerInstance singlePageApplication + } + } + + deploymentNode "Big Bank plc" "" "Big Bank plc data center" { + deploymentNode "bigbank-web***" "" "Ubuntu 16.04 LTS" "" 4 { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + liveWebApplicationInstance = containerInstance webApplication + } + } + deploymentNode "bigbank-api***" "" "Ubuntu 16.04 LTS" "" 8 { + deploymentNode "Apache Tomcat" "" "Apache Tomcat 8.x" { + liveApiApplicationInstance = containerInstance apiApplication + } + } + + deploymentNode "bigbank-db01" "" "Ubuntu 16.04 LTS" { + primaryDatabaseServer = deploymentNode "Oracle - Primary" "" "Oracle 12c" { + livePrimaryDatabaseInstance = containerInstance database + } + } + deploymentNode "bigbank-db02" "" "Ubuntu 16.04 LTS" "Failover" { + secondaryDatabaseServer = deploymentNode "Oracle - Secondary" "" "Oracle 12c" "Failover" { + liveSecondaryDatabaseInstance = containerInstance database "Failover" + } + } + deploymentNode "bigbank-prod001" "" "" "" { + softwareSystemInstance mainframe + } + } + + primaryDatabaseServer -> secondaryDatabaseServer "Replicates data to" + } + } + + views { + systemcontext internetBankingSystem "SystemContext" { + include * + animation { + internetBankingSystem + customer + mainframe + email + } + } + + container internetBankingSystem "Containers" { + include * + animation { + customer mainframe email + webApplication + singlePageApplication + mobileApp + apiApplication + database + } + } + + component apiApplication "Components" { + include * + animation { + singlePageApplication mobileApp database email mainframe + signinController securityComponent + accountsSummaryController mainframeBankingSystemFacade + resetPasswordController emailComponent + } + } + + dynamic apiApplication "SignIn" "Summarises how the sign in feature works in the single-page application." { + singlePageApplication -> signinController "Submits credentials to" + signinController -> securityComponent "Validates credentials using" + securityComponent -> database "select * from users where username = ?" + database -> securityComponent "Returns user data to" + securityComponent -> signinController "Returns true if the hashed password matches" + signinController -> singlePageApplication "Sends back an authentication token to" + } + + deployment internetBankingSystem "Development" "DevelopmentDeployment" { + include * + animation { + developerSinglePageApplicationInstance + developerWebApplicationInstance developerApiApplicationInstance + developerDatabaseInstance + } + } + + deployment internetBankingSystem "Live" "LiveDeployment" { + include * + animation { + liveSinglePageApplicationInstance + liveMobileAppInstance + liveWebApplicationInstance liveApiApplicationInstance + livePrimaryDatabaseInstance + liveSecondaryDatabaseInstance + } + } + + styles { + !include views/styles-people.dsl + + element "Software System" { + background #1168bd + color #ffffff + } + element "Existing System" { + background #999999 + color #ffffff + } + element "Container" { + background #438dd5 + color #ffffff + } + element "Web Browser" { + shape WebBrowser + } + element "Mobile App" { + shape MobileDeviceLandscape + } + element "Database" { + shape Cylinder + } + element "Component" { + background #85bbf0 + color #000000 + } + element "Failover" { + opacity 25 + } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/adrs/0001-record-architecture-decisions.md b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/adrs/0001-record-architecture-decisions.md new file mode 100644 index 000000000..7461d99d3 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/adrs/0001-record-architecture-decisions.md @@ -0,0 +1,19 @@ +# 1. Record architecture decisions + +Date: 2020-06-05 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project. + +## Decision + +We will use Architecture Decision Records, as described by Michael Nygard in this article: [http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) + +## Consequences + +See Michael Nygard's article, linked above. \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/details.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/details.dsl new file mode 100644 index 000000000..c3b491544 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/details.dsl @@ -0,0 +1,15 @@ +singlePageApplication = container "Single-Page Application" "Provides all of the Internet banking functionality to customers via their web browser." "JavaScript and Angular" "Web Browser" +mobileApp = container "Mobile App" "Provides a limited subset of the Internet banking functionality to customers via their mobile device." "Xamarin" "Mobile App" +webApplication = container "Web Application" "Delivers the static content and the Internet banking single page application." "Java and Spring MVC" +apiApplication = container "API Application" "Provides Internet banking functionality via a JSON/HTTPS API." "Java and Spring MVC" { + signinController = component "Sign In Controller" "Allows users to sign in to the Internet Banking System." "Spring MVC Rest Controller" + accountsSummaryController = component "Accounts Summary Controller" "Provides customers with a summary of their bank accounts." "Spring MVC Rest Controller" + resetPasswordController = component "Reset Password Controller" "Allows users to reset their passwords with a single use URL." "Spring MVC Rest Controller" + securityComponent = component "Security Component" "Provides functionality related to signing in, changing passwords, etc." "Spring Bean" + mainframeBankingSystemFacade = component "Mainframe Banking System Facade" "A facade onto the mainframe banking system." "Spring Bean" + emailComponent = component "E-mail Component" "Sends e-mails to users." "Spring Bean" +} +database = container "Database" "Stores user registration information, hashed authentication credentials, access logs, etc." "Oracle Database Schema" "Database" + +!docs docs +!adrs adrs \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/01-context.md b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/01-context.md new file mode 100644 index 000000000..5440e9435 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/01-context.md @@ -0,0 +1,11 @@ +## Context + +Here is some context about the Internet Banking System... + +![](embed:SystemContext) + +### Internet Banking System +... + +### Mainframe Banking System +... diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/02-containers.md b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/02-containers.md new file mode 100644 index 000000000..d4d8d9aab --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/02-containers.md @@ -0,0 +1,21 @@ +## Software Architecture + +Here is some information about the software architecture of the Internet Banking System... + +![](embed:Containers) + +### Web Application +... + +### Database +... + +Here is some information about the API Application... + +![](embed:Components) + +### Sign in process + +Here is some information about the Sign In Controller, including how the sign in process works... + +![](embed:SignIn) \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/03-development-environment.adoc b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/03-development-environment.adoc new file mode 100644 index 000000000..9f9b6d664 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/03-development-environment.adoc @@ -0,0 +1,5 @@ +== Development Environment + +Here is some information about how to set up a development environment for the Internet Banking System... + +image::embed:DevelopmentDeployment[] \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/04-deployment.adoc b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/04-deployment.adoc new file mode 100644 index 000000000..be82ea565 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/docs/04-deployment.adoc @@ -0,0 +1,5 @@ +== Deployment + +Here is some information about the live deployment environment for the Internet Banking System... + +image::embed:LiveDeployment[] \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/summary.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/summary.dsl new file mode 100644 index 000000000..71adf255f --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/internet-banking-system/summary.dsl @@ -0,0 +1 @@ +url https://structurizr.com/share/36141/diagrams#SystemContext \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/people-and-software-systems.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/people-and-software-systems.dsl new file mode 100644 index 000000000..d5d051be7 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/model/people-and-software-systems.dsl @@ -0,0 +1,25 @@ +customer = person "Personal Banking Customer" "A customer of the bank, with personal bank accounts." "Customer" + +group "Big Bank plc" { + supportStaff = person "Customer Service Staff" "Customer service staff within the bank." "Bank Staff" + backoffice = person "Back Office Staff" "Administration and support staff within the bank." "Bank Staff" + + mainframe = softwaresystem "Mainframe Banking System" "Stores all of the core banking information about customers, accounts, transactions, etc." "Existing System" + email = softwaresystem "E-mail System" "The internal Microsoft Exchange e-mail system." "Existing System" + atm = softwaresystem "ATM" "Allows customers to withdraw cash." "Existing System" + + internetBankingSystem = softwaresystem "Internet Banking System" "Allows customers to view information about their bank accounts, and make payments." { + !include "internet-banking-system/${INTERNET_BANKING_SYSTEM_INCLUDE}" + } +} + +# relationships between people and software systems +customer -> internetBankingSystem "Views account balances, and makes payments using" +internetBankingSystem -> mainframe "Gets account information from, and makes payments using" +internetBankingSystem -> email "Sends e-mail using" +email -> customer "Sends e-mails to" +customer -> supportStaff "Asks questions to" "Telephone" +supportStaff -> mainframe "Uses" +customer -> atm "Withdraws cash using" +atm -> mainframe "Uses" +backoffice -> mainframe "Uses" \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/system-landscape.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/system-landscape.dsl new file mode 100644 index 000000000..ac7dbf561 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/system-landscape.dsl @@ -0,0 +1,23 @@ +!const INTERNET_BANKING_SYSTEM_INCLUDE "summary.dsl" + +workspace "Big Bank plc - System Landscape" "The system landscape for Big Bank plc." { + + model { + !include model/people-and-software-systems.dsl + } + + views { + systemlandscape "SystemLandscape" { + include * + } + + styles { + !include views/styles-people.dsl + + element "Software System" { + background #999999 + color #ffffff + } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/big-bank-plc/views/styles-people.dsl b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/views/styles-people.dsl new file mode 100644 index 000000000..f8052dca0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/big-bank-plc/views/styles-people.dsl @@ -0,0 +1,11 @@ +element "Person" { + color #ffffff + fontSize 22 + shape Person +} +element "Customer" { + background #08427b +} +element "Bank Staff" { + background #999999 +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/bulk-operations.dsl b/structurizr-dsl/src/test/resources/dsl/bulk-operations.dsl new file mode 100644 index 000000000..d1833badc --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/bulk-operations.dsl @@ -0,0 +1,71 @@ +workspace { + + model { + user = person "User" + + !identifiers flat + + softwareSystem1 = softwareSystem "Software System 1" { + application1 = container "Application" { + component "ComponentA" + component "ComponentB" + component "ComponentC" + } + + databaseSchema1 = container "Database Schema" + + !elements "element.parent==application1" { + tags "Tag 1" + user -> this "Uses 1" + this -> databaseSchema1 "Uses 1" { + tags "Tag" + } + } + + !elements "element.parent==application1" { + -> databaseSchema1 "Uses 2" { + tags "Tag" + } + } + } + + !identifiers hierarchical + + softwareSystem2 = softwareSystem "Software System 2" { + application2 = container "Application" { + component "ComponentA" + component "ComponentB" + component "ComponentC" + } + + databaseSchema2 = container "Database Schema" + + !elements "element.parent==application2" { + tags "Tag 1" + user -> this "Uses" + this -> softwareSystem2.databaseSchema2 "Uses 1" { + tags "Tag" + } + } + + !elements "element.parent==application2" { + this -> databaseSchema2 "Uses 2" { + tags "Tag" + } + } + + !elements "element.parent==application2" { + -> softwareSystem2.databaseSchema2 "Uses 3" { + tags "Tag" + } + } + + !elements "element.parent==application2" { + -> databaseSchema2 "Uses 4" { + tags "Tag" + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/color-schemes.dsl b/structurizr-dsl/src/test/resources/dsl/color-schemes.dsl new file mode 100644 index 000000000..aeeb22134 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/color-schemes.dsl @@ -0,0 +1,31 @@ +workspace { + + views { + styles { + element "Element" { + shape roundedbox + } + relationship "Relationship" { + style solid + } + + light { + element "Element" { + colour #000000 + } + relationship "Relationship" { + colour #000000 + } + } + + dark { + element "Element" { + colour #ffffff + } + relationship "Relationship" { + colour #ffffff + } + } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/constant.dsl b/structurizr-dsl/src/test/resources/dsl/constant.dsl new file mode 100644 index 000000000..714ce0d27 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/constant.dsl @@ -0,0 +1,5 @@ +workspace { + + !constant NAME VALUE + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-child.dsl b/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-child.dsl new file mode 100644 index 000000000..aa10fdab4 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-child.dsl @@ -0,0 +1,7 @@ +workspace extends constants-and-variables-from-workspace-extension-parent.dsl { + + model { + softwareSystem "${NAME}" "${DESCRIPTION}" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-parent.dsl b/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-parent.dsl new file mode 100644 index 000000000..442ce70ea --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/constants-and-variables-from-workspace-extension-parent.dsl @@ -0,0 +1,6 @@ +workspace { + + !const "NAME" "Name" + !var "DESCRIPTION" "Description" + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/custom-view-animation.dsl b/structurizr-dsl/src/test/resources/dsl/custom-view-animation.dsl new file mode 100644 index 000000000..fe1679cca --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/custom-view-animation.dsl @@ -0,0 +1,32 @@ +workspace { + + model { + a = element "A" + b = element "B" + + a -> b + } + + views { + custom { + include * + + // add animation steps via element identifiers + animation { + a + b + } + } + + custom { + include * + + // add animation steps via element expressions + animation { + a + a-> + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0001-record-architecture-decisions.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0001-record-architecture-decisions.md new file mode 100644 index 000000000..f30860000 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0001-record-architecture-decisions.md @@ -0,0 +1,19 @@ +# 1. Record architecture decisions + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project. + +## Decision + +We will use Architecture Decision Records, as described by Michael Nygard in this article: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions + +## Consequences + +See Michael Nygard's article, linked above. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0002-implement-as-shell-scripts.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0002-implement-as-shell-scripts.md new file mode 100644 index 000000000..8e6ea15e6 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0002-implement-as-shell-scripts.md @@ -0,0 +1,28 @@ +# 2. Implement as shell scripts + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +ADRs are plain text files stored in a subdirectory of the project. + +The tool needs to create new files and apply small edits to +the Status section of existing files. + +## Decision + +The tool is implemented as shell scripts that use standard Unix +tools -- grep, sed, awk, etc. + +## Consequences + +The tool won't support Windows. Being plain text files, ADRs can +be created by hand and edited in any text editor. This tool just +makes the process more convenient. + +Development will have to cope with differences between Unix +variants, particularly Linux and MacOS X. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0003-single-command-with-subcommands.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0003-single-command-with-subcommands.md new file mode 100644 index 000000000..f64db8da1 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0003-single-command-with-subcommands.md @@ -0,0 +1,45 @@ +# 3. Single command with subcommands + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +The tool provides a number of related commands to create +and manipulate architecture decision records. + +How can the user find out about the commands that are available? + +## Decision + +The tool defines a single command, called `adr`. + +The first argument to `adr` (the subcommand) specifies the +action to perform. Further arguments are interpreted by the +subcommand. + +Running `adr` without any arguments lists the available +subcommands. + +Subcommands are implemented as scripts in the same +directory as the `adr` script. E.g. the subcommand `new` is +implemented as the script `adr-new`, the subcommand `help` +as the script `adr-help` and so on. + +Helper scripts that are part of the implementation but not +subcommands follow a different naming convention, so that +subcommands can be listed by filtering and transforming script +file names. + +## Consequences + +Users can more easily explore the capabilities of the tool. + +Users are already used to this style of command-line tool. For +example, Git works this way. + +Each subcommand can be implemented in the most appropriate +language. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0004-markdown-format.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0004-markdown-format.md new file mode 100644 index 000000000..86a21bf7e --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0004-markdown-format.md @@ -0,0 +1,40 @@ +# 4. Markdown format + +Date: 2016-02-12 + +## Status + +Superceded by [10. AsciiDoc format](0010-asciidoc-format.md) + +## Context + +The decision records must be stored in a plain text format: + +* This works well with version control systems. + +* It allows the tool to modify the status of records and insert + hyperlinks when one decision supercedes another. + +* Decisions can be read in the terminal, IDE, version control + browser, etc. + +People will want to use some formatting: lists, code examples, +and so on. + +People will want to view the decision records in a more readable +format than plain text, and maybe print them out. + + +## Decision + +Record architecture decisions in [Markdown format](https://daringfireball.net/projects/markdown/). + +## Consequences + +Decisions can be read in the terminal. + +Decisions will be formatted nicely and hyperlinked by the +browsers of project hosting sites like GitHub and Bitbucket. + +Tools like [Pandoc](http://pandoc.org/) can be used to convert +the decision records into HTML or PDF. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0005-help-comments.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0005-help-comments.md new file mode 100644 index 000000000..b19bf0fb1 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0005-help-comments.md @@ -0,0 +1,42 @@ +# 5. Help comments + +Date: 2016-02-13 + +## Status + +Accepted + +Amended by [9. Help scripts](0009-help-scripts.md) + +## Context + +The tool will have a `help` subcommand to provide documentation +for users. + +It's nice to have usage documentation in the script files +themselves, in comments. When reading the code, that's the first +place to look for information about how to run a script. + +## Decision + +Write usage documentation in comments in the source file. + +Distinguish between documentation comments and normal comments. +Documentation comments have two hash characters at the start of +the line. + +The `adr help` command can parse comments out from the script +using the standard Unix tools `grep` and `cut`. + +## Consequences + +No need to maintain help text in a separate file. + +Help text can easily be kept up to date as the script is edited. + +There's no automated check that the help text is up to date. The +tests do not work well as documentation for users, and the help +text is not easily cross-checked against the code. + +This won't work if any subcommands are not implemented as scripts +that use '#' as a comment character. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md new file mode 100644 index 000000000..4a3485f79 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md @@ -0,0 +1,41 @@ +# 6. Packaging and distribution in other version control repositories + +Date: 2016-02-16 + +## Status + +Accepted + +## Context + +Users want to install adr-tools with their preferred package +manager. For example, Ubuntu users use `apt`, RedHat users use +`yum` and Mac OS X users use [Homebrew](http://brew.sh). + +The developers of `adr-tools` don't know how, nor have permissions, +to use all these packaging and distribution systems. Therefore packaging +and distribution must be done by "downstream" parties. + +The developers of the tool should not favour any one particular +packaging and distribution solution. + +## Decision + +The `adr-tools` project will not contain any packaging or +distribution scripts and config. + +Packaging and distribution will be managed by other projects in +separate version control repositories. + +## Consequences + +The git repo of this project will be simpler. + +Eventually, users will not have to use Git to get the software. + +We will have to tag releases in the `adr-tools` repository so that +packaging projects know what can be published and how it should be +identified. + +We will document how users can install the software in this +project's README file. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md new file mode 100644 index 000000000..a649b2356 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md @@ -0,0 +1,31 @@ +# 7. Invoke adr-config executable to get configuration + +Date: 2016-12-17 + +## Status + +Accepted + +## Context + +Packagers (e.g. Homebrew developers) want to configure adr-tools to match the conventions of their installation. + +Currently, this is done by sourcing a file `config.sh`, which should sit beside the `adr` executable. + +This name is too common. + +The `config.sh` file is not executable, and so doesn't belong in a bin directory. + +## Decision + +Replace `config.sh` with an executable, named `adr-config` that outputs configuration. + +Each script in ADR Tools will eval the output of `adr-config` to configure itself. + +## Consequences + +Configuration within ADR Tools is a little more complicated. + +Packagers can write their own implementation of `adr-config` that outputs configuration that matches the platform's installation conventions, and deploy it next to the `adr` script. + +To make development easier, the implementation of `adr-config` in the project's src/ directory will output configuration that lets the tool to run from the src/ directory without any installation step. (Packagers should not include this script in a deployable package.) diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0008-use-iso-8601-format-for-dates.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0008-use-iso-8601-format-for-dates.md new file mode 100644 index 000000000..4146f11df --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0008-use-iso-8601-format-for-dates.md @@ -0,0 +1,43 @@ +# 8. Use ISO 8601 Format for Dates + +Date: 2017-02-21 + +## Status + +Accepted + +## Context + +`adr-tools` seeks to communicate the history of architectural decisions of a +project. An important component of the history is the time at which a decision +was made. + +To communicate effectively, `adr-tools` should present information as +unambiguously as possible. That means that culture-neutral data formats should +be preferred over culture-specific formats. + +Existing `adr-tools` deployments format dates as `dd/mm/yyyy` by default. That +formatting is common formatting in the United Kingdom (where the `adr-tools` +project was originally written), but is easily confused with the `mm/dd/yyyy` +format preferred in the United States. + +The default date format may be overridden by setting `ADR_DATE` in `config.sh`. + +## Decision + +`adr-tools` will use the ISO 8601 format for dates: `yyyy-mm-dd` + +## Consequences + +Dates are displayed in a standard, culture-neutral format. + +The UK-style and ISO 8601 formats can be distinguished by their separator +character. The UK-style dates used a slash (`/`), while the ISO dates use a +hyphen (`-`). + +Prior to this decision, `adr-tools` was deployed using the UK format for dates. +After adopting the ISO 8601 format, existing deployments of `adr-tools` must do +one of the following: + + * Accept mixed formatting of dates within their documentation library. + * Update existing documents to use ISO 8601 dates by running `adr upgrade-repository` diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0009-help-scripts.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0009-help-scripts.md new file mode 100644 index 000000000..0146d127c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0009-help-scripts.md @@ -0,0 +1,28 @@ +# 9. Help scripts + +Date: 2018-06-26 + +## Status + +Accepted + +Amends [5. Help comments](0005-help-comments.md) + +## Context + +Currently help text is generated by extracting specially formatted comments from the top of the command script. + +This makes it easy for developers of the tool: documentation and code is all in one place. + +But, it means that help text cannot include calculated values, such as the location of files. + +## Decision + +Where necessary, help text can be generated by a script. + +The script will be called _adr_help_<command>_<subcommand> + +## Consequences + +Help scripts can include helper scripts to locate files, giving more accurate instructions to the user that reflect how the tool is deployed in their environment. + diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0010-asciidoc-format.md b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0010-asciidoc-format.md new file mode 100644 index 000000000..edb613d1a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/adrtools/0010-asciidoc-format.md @@ -0,0 +1,21 @@ +# 10. AsciiDoc format + +Date: 2018-08-11 + +## Status + +Accepted + +Supercedes [4. Markdown format](0004-markdown-format.md) + +## Context + +The issue motivating this decision, and any context that influences or constrains the decision. + +## Decision + +The change that we're proposing or have agreed to implement. + +## Consequences + +What becomes easier or more difficult to do and any risks introduced by the change that will need to be mitigated. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md new file mode 100644 index 000000000..26cceeb7a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md @@ -0,0 +1,22 @@ +# Use Log4brains to manage the ADRs + +- Status: accepted +- Date: 2024-01-10 +- Tags: dev-tools, doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which tool(s) should we use to manage these records? + +## Considered Options + +- [Log4brains](https://github.com/thomvaill/log4brains): architecture knowledge base (command-line + static site generator) +- [ADR Tools](https://github.com/npryce/adr-tools): command-line to create ADRs +- [ADR Tools Python](https://bitbucket.org/tinkerer_/adr-tools-python/src/master/): command-line to create ADRs +- [adr-viewer](https://github.com/mrwilson/adr-viewer): static site generator +- [adr-log](https://adr.github.io/adr-log/): command-line to create a TOC of ADRs + +## Decision Outcome + +Chosen option: "Log4brains", because it includes the features of all the other tools, and even more. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md new file mode 100644 index 000000000..f5ee1949a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md @@ -0,0 +1,42 @@ +# Use Markdown Architectural Decision Records + +- Status: accepted +- Date: 2024-01-10 +- Tags: doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with Log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with Log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. +- The Log4brains patch adds more features, like tags. + +The "Log4brains patch" performs the following modifications to the original template: + +- Change the ADR filenames format (`NNN-adr-name` becomes `YYYYMMDD-adr-name`), to avoid conflicts during Git merges. +- Add a `draft` status, to enable collaborative writing. +- Add a `Tags` field. + +## Links + +- Relates to [Use Log4brains to manage the ADRs](20240111-use-log4brains-to-manage-the-adrs.md) diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240113-decision-3.md b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240113-decision-3.md new file mode 100644 index 000000000..4a2c40918 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240113-decision-3.md @@ -0,0 +1,7 @@ +# Decision 3 + +- Status: superseded by [20240111-decision-4](20240114-decision-4.md) + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240114-decision-4.md b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240114-decision-4.md new file mode 100644 index 000000000..bff063a29 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/log4brains/20240114-decision-4.md @@ -0,0 +1,11 @@ +# Decision 4 + +- Status: accepted + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. + +## Links + +- Supersedes [20240111-decision-3](20240113-decision-3.md) diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0000-use-markdown-any-decision-records.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0000-use-markdown-any-decision-records.md new file mode 100644 index 000000000..df5c4f9d0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0000-use-markdown-any-decision-records.md @@ -0,0 +1,30 @@ +--- +parent: Decisions +nav_order: 0 +--- +# Use Markdown Any Decision Records + +## Context and Problem Statement + +We want to record any decisions made in this project independent whether decisions concern the architecture ("architectural decision record"), the code, or other fields. +Which format and structure should these records follow? + +## Considered Options + +* [MADR](https://adr.github.io/madr/) 3.0.0 – The Markdown Any Decision Records +* [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +* [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +* Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +* Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 3.0.0", because + +* Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +* MADR allows for structured capturing of any decision. +* The MADR format is lean and fits our development style. +* The MADR structure is comprehensible and facilitates usage & maintenance. +* The MADR project is vivid. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0001-use-CC0-or-MIT-as-license.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0001-use-CC0-or-MIT-as-license.md new file mode 100644 index 000000000..09ec7aaf0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0001-use-CC0-or-MIT-as-license.md @@ -0,0 +1,49 @@ +--- +parent: Decisions +nav_order: 1 +--- +# Dual License the Work + +## Context and Problem Statement + +Everything needs to be licensed, otherwise the default copyright laws apply. +For instance, in Germany that means users may not alter anything without explicitly asking for permission. +For more information see <https://help.github.com/articles/licensing-a-repository/>. + +We want to have MADR used without any hassle and that users can just go ahead and write MADRs. + +## Considered Options + +* [CC0](https://creativecommons.org/share-your-work/public-domain/cc0/) +* BSD3 +* MIT +* Dual license with MIT and CC0 +* No license +* Other open source licenses + +## Decision Outcome + +Chosen option: "Dual license", because this lets users choose whether CC0 or MIT fits better on their work. + +## Pros and Cons of the Options + +## CC0 + +* Good, because this license donates the content to "public domain" and does so as legally as possible. +* Bad, because it does not contain attribution - and [attribution is important](https://opensource.stackexchange.com/a/9126/5671). + +## BSD3 + +* Bad, because it [is unclear whether it can be used for documentation](https://opensource.stackexchange.com/a/9545/5671) + +## MIT + +* Good, because it [explicitly may be used for documentation](https://opensource.stackexchange.com/a/9545/5671) +* Good, because it is lean. + +## Dual license with MIT and CC0 + +With the SPDX identifier `MIT OR CC0-1.0`, the receiver of the documents can decide which license thay want to use. + +* Good, because offers freedom at the receiver +* Bad, because dual licensing is not widely known diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0002-do-not-use-numbers-in-headings.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0002-do-not-use-numbers-in-headings.md new file mode 100644 index 000000000..9f3197305 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0002-do-not-use-numbers-in-headings.md @@ -0,0 +1,24 @@ +--- +parent: Decisions +nav_order: 2 +--- +# Do Not Use Numbers in Headings + +## Context and Problem Statement + +How to render the first line in an ADR? +ADRs have to take a unique identifier. + +## Considered Options + +* Use the title only +* Add the ADR number in front of the title (e.g., "# 2. Do not use numbers in headings") + +## Decision Outcome + +Chosen option: "Use the title only", because + +* This is common in other markdown files, too. + One does not add numbering manually at the markdown files, but tries to get the numbers injected by the rendering framework or CSS. +* Enables renaming of ADRs (before publication) easily +* Allows copy'n'paste of ADRs from other repositories without having to worry about the numbers. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0003-provide-own-madr-tools.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0003-provide-own-madr-tools.md new file mode 100644 index 000000000..00750fd68 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0003-provide-own-madr-tools.md @@ -0,0 +1,33 @@ +--- +parent: Decisions +nav_order: 3 +status: on hold +--- +# Write Own MADR Tooling + +## Context and Problem Statement + +Developers seem to like tooling to create ADRs. +That tooling should support MADR. +The tooling should be easy to install. + +## Considered Options + +* Include in [adr-tools](https://github.com/npryce/adr-tools), 924 stars as of 2018-06-14 +* Include in [adr-j](https://github.com/adoble/adr-j), 2 stars as of 2018-06-14 +* Include in [adr](https://github.com/phodal/adr), 72 stars as of 2018-06-14 +* Write own MADR tooling +* No tool support + +## Decision Outcome + +Chosen option: "Write own MADR tooling", because + +* adding MADR support to `adr-tools` [was rejected](https://github.com/npryce/adr-tools/pull/43) +* other tooling seem to a) modify MADR or b) do not keep up with changes on MADR. + +We accept that this comes with maintenance cost. + +## More Information + +An overview on current tooling of MADR is available at <https://adr.github.io/madr/tooling.html>. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0004-write-own-toc-tool.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0004-write-own-toc-tool.md new file mode 100644 index 000000000..038204407 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0004-write-own-toc-tool.md @@ -0,0 +1,29 @@ +--- +parent: Decisions +nav_order: 4 +--- +# Write Own TOC Tool + +## Context and Problem Statement + +ADRs have to be indexed somehow. E.g., for offering a website showing all ADRs. + +## Considered Options + +* Write own tool `adr-log` +* Use `adr-tools`' TOC functionality + +## Decision Outcome + +Chosen option: "Write own tool `adr-log`", because + +* we want to have the format `ADR-0001 - Title` in the TOC. +* `adr-tools` offers `title` only. + +We accept that changing `adr-tools` would also be possible. +It is prepared to included header and footer: <https://github.com/npryce/adr-tools/blob/master/tests/generate-contents-with-header-and-footer.sh>. + +### Consequences + +* Good, because `adr-log` is installable using `npm install -g adr-log`, which is easier than installing `adr-tools`. +* Bad, because another tool has to be maintained diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0005-use-dashes-in-filenames.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0005-use-dashes-in-filenames.md new file mode 100644 index 000000000..4c94869e8 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0005-use-dashes-in-filenames.md @@ -0,0 +1,26 @@ +--- +parent: Decisions +nav_order: 5 +--- +# Use Dashes in Filenames + +## Context and Problem Statement + +What is the pattern of the filename where an ADR is stored? + +## Considered Options + +* `NNNN-title-with-dashes.md` - format used by [adr-tools](https://github.com/npryce/adr-tools) +* `YYYY-MM-DD Title` - see <https://github.com/joelparkerhenderson/architecture_decision_record#adr-file-name-conventions> + +## Decision Outcome + +Chosen option: "`NNNN-title-with-dashes.md`", because + +* `NNNN` provides a unique number, which can be used for referencing in the forms + * `ADR-0001` in plain text and + * by `@ADR(1)` Java code (enabled by [e-adr](https://adr.github.io/e-adr/)) +* The creation time of an ADR is of historical interest only, if it gets updated somehow. + The arguments are similar than the ones by [Does Git have keyword expansion?](https://git.wiki.kernel.org/index.php/GitFaq#Does_Git_have_keyword_expansion.3F) +* Having no spaces in filenames eases working in the command line +* This is exactly the format offered by [adr-tools](https://github.com/npryce/adr-tools) diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0006-use-names-as-identifier.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0006-use-names-as-identifier.md new file mode 100644 index 000000000..1734742b8 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0006-use-names-as-identifier.md @@ -0,0 +1,24 @@ +--- +parent: Decisions +nav_order: 6 +--- +# Use Names as Identifier + +## Context and Problem Statement + +An option is listed at "Considered Options" and repeated at "Pros and Cons of the Options". Finally, the chosen option is stated at "Decision Outcome". + +## Decision Drivers + +* Easy to read +* Easy to write +* Avoid copy and paste errors + +## Considered Options + +* Repeat all option names if they occur +* Assign an identifier to an option, e.g., `[A] Use gradle as build tool` + +## Decision Outcome + +Chosen option: "Repeat all option names if they occur", because 1) there is no markdown standard for identifiers, 2) the document is harder to read if there are multiple options which must be remembered. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0007-do-not-emphasize-line-headings.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0007-do-not-emphasize-line-headings.md new file mode 100644 index 000000000..c12abeab3 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0007-do-not-emphasize-line-headings.md @@ -0,0 +1,23 @@ +--- +parent: Decisions +nav_order: 7 +--- +# Do Not Emphasize Line Headings + +## Context and Problem Statement + +MADR contains lines such as `Chosen option: "[option 1]"`. Should "Chosen option" be emphasized? + +## Decision Drivers + +* MADR should be easy to read +* MADR should be easy to write + +## Considered Options + +* Do not emphasize line headings +* Emphasize line headings + +## Decision Outcome + +Chosen option: "Do not emphasize line headings", because 1) these headings always are put at the beginning of a line and followed by a colon. Thus, they are already easy to identified as line heading. 2) Readers not familiar with Markdown might be confused by stars in the text. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-add-status-field.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-add-status-field.md new file mode 100644 index 000000000..bae2bb479 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-add-status-field.md @@ -0,0 +1,100 @@ +--- +parent: Decisions +nav_order: 8 +--- +# Add Status Field + +## Context and Problem Statement + +Technical Story: <https://github.com/adr/madr/issues/2> + +ADRs have a status. Should this be tracked? And if it should, how should we track it? + +## Considered Options + +* Use YAML front matter +* Use badge +* Use text line +* Use separate heading +* Use table +* Do not add status + +## Decision Outcome + +Chosen option: "Use YAML front matter", because comes out best (see below). + +## Pros and Cons of the Options + +### Use YAML front matter + +Example: + +```markdown +--- +parent: Decisions +nav_order: 3 +status: on hold +--- +# Write own MADR tooling +``` + +* Good, because YAML front matter is supported by most Markdown parsers + +### Use badge + +#### Examples + +* ![Example "Use Angular" with "status: accepted"](0008-example-badge.png) +* [![Example "status: superseded"](https://img.shields.io/badge/status-superseeded_by_ADR_0001-orange.svg?style=flat-square)](https://github.com/adr/madr/blob/main/docs/decisions/0001-use-CC0-as-license.md) + +--- + +* Good, because plain markdown +* Good, because looks good +* Bad, because hard to read in markdown source +* Bad, because relies on the online service <https://shields.io> or [local badges have to be generated](https://github.com/badges/shields#using-the-badge-library) +* Bad, because at local usages, many badges have to be generated (superseeded-by-ADR-0006, for each ADR number) +* Bad, because not easy to write + +### Use text line + +Example: `Status: Accepted` + +* Good, because plain markdown +* Good, because easy to read +* Good, because easy to write +* Good, because looks OK in both markdown-source (MD) and in rendered versions (HTML, PDF) +* Good, because no dependencies on external tools +* Good, because single line indicates the current state +* Bad, because "Status" line needs to be maintained +* Bad, because uses space at the beginning. When users read MADR, they should directly dive into the context and problem and not into the status + +### Use separate heading + +Example: ![example for separate heading](0008-example-separate-heading.png) + +* Good, because plain markdown +* Good, because easy to write +* Bad, because it uses much space: At least three lines: heading, status, separating empty line + +### Use table + +Example: ![example for table](0008-example-table.png) + +* Good, because history can be included +* Good, because multiple entries can be made +* Good, because already implemented in `adr-tools` fork +* Bad, because not covered by the [CommonMark specification 0.28 (2017-08-01)](http://spec.commonmark.org/0.28/) +* Bad, because hard to read +* Bad, because outdated entries cannot be easily identified +* Bad, because needs more markdown training + +### Do not add status + +* Good, because MADR is kept lean +* Bad, because users demand state field +* Bad, because not in line with other ADR templates + +## More Information + +See [ADR-0013](0013-use-yaml-front-matter-for-meta-data.md) for more reasoning on using YAML front matter. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-badge.png b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-badge.png new file mode 100644 index 000000000..20ced2835 Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-badge.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-separate-heading.png b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-separate-heading.png new file mode 100644 index 000000000..8a898033e Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-separate-heading.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-table.png b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-table.png new file mode 100644 index 000000000..768c2d145 Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0008-example-table.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md new file mode 100644 index 000000000..f08efe328 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md @@ -0,0 +1,79 @@ +--- +parent: Decisions +nav_order: 9 +--- +# Support Links To Other ADRs Inside an ADR + +## Context and Problem Statement + +A decision might point to another decision. +For instance, if a decision is a follow-up to another decision. +This should be supported by MADR, too. + +Technical Story: <https://github.com/adr/madr/issues/9> + +## Considered Options + +* Include in section "More Information" +* Use tables +* Use heading together with a bullet list directly after status +* Use heading together with a bullet list directly after "Decision Outcome" +* Use heading together with a bullet list at the end +* Do not add links + +## Decision Outcome + +Chosen option: "Include in section 'More Information'", because comes out best (see below). + +## Pros and Cons of the Options + +### Include in section "More Information" + +Example: + +```markdown +## More Information + +[ADR-0008](0008-add-status-field.md) reasons on adding meta data (such as status). +``` + +* Good, because provides freedom to the user +* Bad, because parsing gets harder + +### Use tables + +* Good, because easy to write +* Good, because history is shown (enabled by concept) +* Good, because [current `adr-tools`' support](https://github.com/npryce/adr-tools/pull/43) uses tables to describe links. +* Bad, because not supported by the CommonMark spec +* Bad, because unclear whether a link was super seeded by another one +* Bad, because valid links not clear at first sight (there might be outdated links shown) + +### Use heading together with a bullet list directly after status + +Example: +![grafik](https://user-images.githubusercontent.com/1366654/36787434-6a63e318-1c8a-11e8-8824-4dd7b3d0f2c6.png) + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Bad, because not consistent with the status label (refs <https://github.com/adr/madr/issues/2>) + +### Use heading together with a bullet list directly after "Decision Outcome" + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Good, because the options are first introduced and then the links +* Good, because consistent with position of "Decision Outcome" +* Bad, because reader might get distracted: He might expect explanation of the options instead of links to something else +* Bad, because not consistent with scientific papers, where related work and future work are coming after the discussion of the content. + +### Use heading together with a bullet list at the end + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Good, because the options and pros/cons are kept together with the option list. +* Good, because consistent with pattern format + +### Do not add links + +* Good, because template stays minimal diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0010-support-categories.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0010-support-categories.md new file mode 100644 index 000000000..a9160f5d9 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0010-support-categories.md @@ -0,0 +1,106 @@ +--- +parent: Decisions +nav_order: 10 +--- +# Support Categories + +## Context and Problem Statement + +ADRs are recorded. The number of ADRs grows and the context/topic/scope of ADRs might be different (e.g., frontend, backend) + +## Decision Drivers + +* Easy to find groups ADRs in hundreds of ADRs +* Easy to group +* Easy to create +* Good finding without external tooling +* Keep newcomers in mind (should be doable in <10 minutes) +* Keep template lean + +## Considered Options + +* Use labels +* Add `* Category: CATEGORY` directly under the heading (similar to <https://gist.github.com/FaKeller/2f9c63b6e1d436abb7358b68bf396f57>) +* Use YAML front matter +* Encode category in filename +* Use subfolders with local IDs +* Use subfolders with global IDs +* Don't do it. + +## Decision Outcome + +Chosen option: "Use subfolders with local IDs", because comes out best (see below). + +## Pros and Cons of the Options + +### Use labels + +Example: + +Use Angular ![category-frontend](https://img.shields.io/badge/category-frontend-blue.svg?style=flat-square) + +`![category-frontend](https://img.shields.io/badge/category-frontend-blue.svg?style=flat-square)` + +* Good, because full markdown +* Good, because linking to an overview page is possible (using markdown) +* Bad, because not straight-forward to parse +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Add `* Category: CATEGORY` directly under the heading + +* Good, because full markdown +* Good, because linking to an overview page is possible (using markdown) +* Good, because straight-forward to parse +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Use YAML front matter + +Example: + +```yaml +--- +category: frontend +--- +``` + +* Good, because nearly straight-forward to parse +* Good, because Jekyll supports it +* Bad, because YAML front matter is not part of the [CommonMarc Spec](http://spec.commonmark.org/) +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Encode category in filename + +Example: `0050--frontend--title-with-dashes.md` + +* Good, because programmatic filtering is possible +* Good, because `ls -la | grep --category--` works +* Bad, because plain file list in Windows explorer cannot be filtered +* Bad, because as bad as [TagSpaces](https://www.tagspaces.org/), which stores the tags in the filenames in brackets. E.g., `demo[demotag secondtag].md`. + +### Use subfolders with local IDs + +Optionally "to-be-categorized" folder. + +One level of subfolder, not nested + +#### Examples + +* `docs/decisions/smar/0000-secure-entities.md` +* `docs/decisions/smar/0001-flexible-properties-selection.md` + +#### Pros/cons + +* Good, because grouping is done by folders (which are natural for grouping) +* Good, because typos can easily be spotted +* Bad, because there is no unique number identifying an ADR +* Bad, because two indices have to be maintained (`adr-log` needs to be updated) +* Bad, because [e-adr](https://github.com/adr/e-adr) needs to be adapted to `@ADR("category", number)` (not that bad) +* Bad, because when category is unknown it is hard to find the right folder +* Bad, because using categories might be hampering newcomers + +### Use subfolders with global IDs + +#### Examples + +* `docs/decisions/smar/0005-secure-entities.md` +* `docs/decisions/smar/0047-flexible-properties-selection.md` diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0011-use-asterisk-as-list-marker.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0011-use-asterisk-as-list-marker.md new file mode 100644 index 000000000..60956c008 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0011-use-asterisk-as-list-marker.md @@ -0,0 +1,20 @@ +--- +parent: Decisions +nav_order: 11 +--- +# Use Asterisk as List Marker + +## Context and Problem Statement + +Lists in Markdown can be indicated by `*` (asterisk) or `-` (hyphen). + +## Considered Options + +* Use an asterisk +* Use a hyphen + +## Decision Outcome + +Chosen option: "Use an asterisk", because an asterisk does not have a meaning of "good" or "bad", whereas a hyphen `-` could be read as indicator of something negative (in contrast to `+`, which could be more be read as "good"). + +According to the [Markdown Style Guide](http://www.cirosantilli.com/markdown-style-guide/), an asterisk as list marker is more readable (see [readability profile](http://www.cirosantilli.com/markdown-style-guide/#readability-profile)). diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md new file mode 100644 index 000000000..c73921b26 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md @@ -0,0 +1,46 @@ +--- +parent: Decisions +nav_order: 12 +--- +# Use Curly Braces to Denote Placeholders + +## Context and Problem Statement + +When crafting an ADR placeholders need to be replaced by real values. +How to mark the placeholders? + +## Considered Options + +* Use curly braces +* Use square brackets +* Use less-than and greater-than + +## Decision Outcome + +Chosen option: "Use curly braces", because comes out best (see below). + +## Pros and Cons of the Options + +### Use curly braces + +Example: `{option 1}`. + +* Good, because [consistent to mustache templates](https://krasimirtsonev.com/blog/article/markdown-smart-placeholders). +* Good, because no confusion with markdown notation for links + +### Use square brackets + +Example: `[option 1]`. + +* Good, because used in MADR 1.x and MADR 2.x +* Bad, because confusion with markdown notation for links +* Bad, because some users did not remove the brackets. Example: `Date: [2021-03-12]` or `Good, because [user no longer activatess shortcut accidently when entering task]`. + +### Use less-than and greater-than + +Example: `<option 1>` + +Idea taken from <https://github.com/schubmat/DecisionCapture/blob/master/templates/captureTemplate_full.md> + +* Good, because kept in Markdown as is +* Bad, because could be mixed up with an HTML element diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-example.png b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-example.png new file mode 100644 index 000000000..9facf8226 Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-example.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md new file mode 100644 index 000000000..2788effc9 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md @@ -0,0 +1,65 @@ +--- +parent: Decisions +nav_order: 13 +--- +# Use YAML front matter for metadata + +## Context and Problem Statement + +MADR offers the fields "Status", "Deciders", and "Date". +These are a kind of metadata fields. +Should this data be included in the ADR directly, or should it be separated somehow? + +## Decision Drivers + +* Easy to read +* Easy to write + +## Considered Options + +* Use YAML front matter +* Use plain Markdown everywhere + +## Decision Outcome + +Chosen option: "Use YAML front matter", because comes out best (see below). + +## Pros and Cons of the Options + +## Use YAML front matter + +Example: + +```markdown +--- +status: accepted +deciders: +date: +--- + +## Context and problem statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? +``` + +Rendered output: + +![adr-013 rendered output](0013-example.png) + +* Good, because it shortens the body (essence of the ADR) +* Good, because tools can handle it more easily +* Good, because indicates the lower importance of the data +* Bad, because pretends to be more accurate than it can be (e.g., possible status values) +* Bad, because rendering not standardized +* Bad, because not all Markdown parsers can parse it + +## Use plain Markdown everywhere + +* Good, because all parsers can handle it +* Bad, because special markdown parsing tooling is needed +* Bad, because metadata is handled the same way as the content + +## More Information + +[ADR-0008](0008-add-status-field.md) reasons on adding metadata (such as status). diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0014-allow-neutral-arguments.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0014-allow-neutral-arguments.md new file mode 100644 index 000000000..bfd221988 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0014-allow-neutral-arguments.md @@ -0,0 +1,62 @@ +--- +parent: Decisions +nav_order: 14 +--- +# Allow "neutral" arguments + +## Context and Problem Statement + +Sometimes, one wants to write down an argument, which is neither pro nor con. + +## Considered Options + +* Neutral, because … +* OK, because … +* Good/bad, because … +* Interesting, because … +* Indifferent, because … + +## Decision Outcome + +Chosen option: "Neutral, because …", because + +* it fits best. +* it is consistent to patterns: +/0/- + +## Pros and Cons of the Options + +### Neutral, because … + +In the following, a full pro/con list is given: + +Example: + +* The proposed solution is good, because it resolves the force 1. +* The proposed solution is good, because it addresses decision driver 2. +* The proposed solution is good, because it mitigates the technical risk / dept with respect to decision driver 4. +* The proposed solution is good, because it removes architectural smell … +* The proposed solution is good, because it addresses stakeholder concern … +* The proposed solution is good, because it has a positive effect on the performance (quality property). +* The proposed solution is neutral, because it is indifferent to decision driver 2. +* The proposed solution is bad, because it does not address decision driver 3. +* The proposed solution is bad, because it has a negative effect on the maintainability (quality property). + +Shorter example for pros and cons: + +* The proposed solution is good with respect to resolving the force 1. +* The considered option is a good solution, because it resolves force1 and the non-resolving of force 1 is OK, … + +### OK, because … + +Real world example: <https://github.com/island-is/island.is/blob/main/handbook/technical-overview/adr/0005-error-tracking-and-monitoring.md> + +```markdown +### Bugsnag + +- Good, because it offers a Slack integration for faster feedback. +- Good, because it offers a Github integration to link to possible commits and PRs. +- Good, because it offers bot front-side/server-side/serverless error tracking. +- OK, because it was ranked the **\#5** as the best Javascript (client-side) error logging service in a community survey. +- Bad, because it's expensive. (**\$199/mo** for **450k events** and **15 collaborators**) +- Bad, because it's pricing includes a fixed set of collaborators. +``` diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0015-include-consulting-informed-of-raci.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0015-include-consulting-informed-of-raci.md new file mode 100644 index 000000000..b028fbb7c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0015-include-consulting-informed-of-raci.md @@ -0,0 +1,44 @@ +--- +parent: Decisions +nav_order: 15 +--- +# Include "Consulted" and "Informed" of RACI + +## Context and Problem Statement + +We noticed an intersection between MADR and [RACI](https://en.wikipedia.org/wiki/Responsibility_assignment_matrix), and felt the need to add a "consulted" and "informed" field in addition to "deciders". +Would it be beneficial to "upstream" these fields to MADR? + +MADR Issue: [#62](https://github.com/adr/madr/issues/62). + +## Decision Drivers + +* MADR should contain fields important to the ADR decision process +* MADR template should be easy to understand +* MADR should be lightweight + +## Considered Options + +* Include "Consulted" and "Informed" of RACI +* Include all fields of RACI +* Do not include anything of RACI + +## Decision Outcome + +Chosen option: "Include 'Consulted' and 'Informed' of RACI", because comes out best (see below). + +## Pros and Cons of the Options + +### Include "Consulted" and "Informed" of RACI + +* Good, because these two roles of RACI are well understood. +* Good, because we make these fields optional, thus it keeps MADR still lightweight. +* Bad, because it adds two additional fields + +### Include all fields of RACI + +This would add "Responsible", "Accountable", "Consulted", and "Informed" + +* Good, because complete RACI would be included +* Bad, because get confused about who is "accountable" and who is "responsible". +* Bad, because if decisions are mostly taken by consensus in small committees, then there might not be an "accountable" person. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0016-outcome-before-detailed-pros-cons.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0016-outcome-before-detailed-pros-cons.md new file mode 100644 index 000000000..9caa315e8 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0016-outcome-before-detailed-pros-cons.md @@ -0,0 +1,74 @@ +--- +parent: Decisions +nav_order: 16 +--- +# Outcome before Detailed Pros and Cons + +## Context and Problem Statement + +MADR aims to list pros and cons of each option. +Where should that list be placed? + +## Decision Drivers + +* Most important information should be above the fold. +* MADR should be easy to write. +* MADR should be easy to read. + +## Considered Options + +* Section "Pros and Cons of the Options" after "Decision Outcome" +* Section "Pros and Cons of the Options" before "Decision Outcome" + +## Decision Outcome + +Chosen option: "Section 'Pros and Cons of the Options' after 'Decision Outcome'", because this keeps the available options and the decision outcome close together. +One gets the decision outcome above the fold and preserve a nice logical flow. +Hence, one can see the "Pros and Cons" section as a sort of "appendix" to the considered options section. +It is almost like the "Considered Options" section implies the following sentence: "For a more detailed analysis of these options, refer to pros and cons". + +## Pros and Cons of the Options + +### Section "Pros and Cons of the Options" after "Decision Outcome" + +Illustration: + +```markdown +## Considered Options + +... + +## Decision Outcome + +... + +## Pros and Cons of the Options + +... +``` + +* Good, because this keeps the available options and the decision outcome close together. +* Bad, because readers might be confused, because the logical flow broken: First comes the result, then the detailed arguments. + +### Section "Pros and Cons of the Options" before "Decision Outcome" + +Illustration: + +```markdown +## Considered Options + +... + +## Pros and Cons of the Options + +... + +## Decision Outcome + +... +``` + +* Good, because the logical flow is kept +* Bad, because the decision outcome is not close to the options +* Bad, because "small" MADRs with the list of options and the decision outcome (and not containing the section "Pros and Cons of the Options") +* Bad, because "small" MADRs with the list of options and the decision outcome (and not containing the section "Pros and Cons of the Options") diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0017-use-same-format-for-outcomes-and-options.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0017-use-same-format-for-outcomes-and-options.md new file mode 100644 index 000000000..bbf876996 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0017-use-same-format-for-outcomes-and-options.md @@ -0,0 +1,26 @@ +--- +parent: Decisions +nav_order: 17 +--- +# Use Same Format for Outcomes and Options + +## Context and Problem Statement + +"Outcome" has "Positive Consequences" and "Negative Consequences" sections. "Options" just have a single list with "Good" and "Bad" prefixes. + +Ticket: [issue#75](https://github.com/adr/madr/issues/75) + +## Decision Drivers + +* Consistent design of MADR +* Allow easy copy and paste + +## Considered Options + +* Section "Consequences" listing positive and negative consequences as "Good, because" and "Bad, because" +* Section "Consequences" listing positive and negative consequences as "Positive, because" and "Negative, because" +* No sections "Consequences", "Positive Consequences", and "Negative Consequences" + +## Decision Outcome + +Chosen option: 'Section "Consequences" listing positive and negative consequences as "Good, because" and "Bad, because"', because resolves all forces. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/madr/0018-use-confirmation-as-heading.md b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0018-use-confirmation-as-heading.md new file mode 100644 index 000000000..230f4f54a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/madr/0018-use-confirmation-as-heading.md @@ -0,0 +1,30 @@ +--- +parent: Decisions +nav_order: 18 +--- +<!-- we need to disable MD025, because we use the different heading "ADR Template" in the homepage (see above) than it is foreseen in the template --> +<!-- markdownlint-disable-next-line MD025 --> +# Use "Confirmation" as Heading + +## Context and Problem Statement + +In MADR, we want to include some sort of check that the decision was implemented. +How to name the heading for the explanation? + +## Decision Drivers + +* Consistent with terms used in IT +* Common word + +## Considered Options + +* "Confirmation" +* "Validation" +* "Verification" + +## Decision Outcome + +Chosen option: "Confirmation", because "validation" is out of scope of the template. +There is a process leading to a "valid" ADR. +The other term "Verification" is often bound to a formal tool or formal procedure. +We wanted to enable also less formal checks. diff --git a/structurizr-dsl/src/test/resources/dsl/decisions/workspace.dsl b/structurizr-dsl/src/test/resources/dsl/decisions/workspace.dsl new file mode 100644 index 000000000..4ae222394 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/decisions/workspace.dsl @@ -0,0 +1,19 @@ +workspace { + + !adrs adrtools com.structurizr.example.ExampleDecisionImporter + + model { + softwareSystem = softwareSystem "Software System" { + !decisions adrtools + + container "Container" { + !decisions madr madr + + component "Component" { + !decisions log4brains log4brains + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/deployment-environment-empty.dsl b/structurizr-dsl/src/test/resources/dsl/deployment-environment-empty.dsl new file mode 100644 index 000000000..11566eb50 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/deployment-environment-empty.dsl @@ -0,0 +1,9 @@ +workspace { + model { + de = deploymentEnvironment "DeploymentEnvironment" + + !element de { + dn = deploymentNode "DeploymentNode" + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/deployment-groups-flat.dsl b/structurizr-dsl/src/test/resources/dsl/deployment-groups-flat.dsl new file mode 100644 index 000000000..287b4abe6 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/deployment-groups-flat.dsl @@ -0,0 +1,36 @@ +workspace { + + model { + softwareSystem = softwareSystem "Software System" { + database = container "DB" + api = container "API" { + -> database "Uses" + } + } + + deploymentEnvironment "WithoutDeploymentGroups" { + deploymentNode "Server 1" { + containerInstance api + containerInstance database + } + deploymentNode "Server 2" { + containerInstance api + containerInstance database + } + } + + deploymentEnvironment "WithDeploymentGroups" { + serviceInstance1 = deploymentGroup "Service Instance 1" + serviceInstance2 = deploymentGroup "Service Instance 2" + deploymentNode "Server 1" { + containerInstance api serviceInstance1 + containerInstance database serviceInstance1 + } + deploymentNode "Server 2" { + containerInstance api serviceInstance2 + containerInstance database serviceInstance2 + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/deployment-groups-hierarchical.dsl b/structurizr-dsl/src/test/resources/dsl/deployment-groups-hierarchical.dsl new file mode 100644 index 000000000..202ea2a71 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/deployment-groups-hierarchical.dsl @@ -0,0 +1,51 @@ +workspace { + + !identifiers hierarchical + + model { + softwareSystem = softwareSystem "Software System" { + database = container "DB" + api = container "API" { + -> database "Uses" + } + } + + deploymentEnvironment "WithoutDeploymentGroups" { + deploymentNode "Server 1" { + containerInstance softwareSystem.api + containerInstance softwareSystem.database + } + deploymentNode "Server 2" { + containerInstance softwareSystem.api + containerInstance softwareSystem.database + } + } + + deploymentEnvironment "WithDeploymentGroups" { + serviceInstance1 = deploymentGroup "Service Instance 1" + serviceInstance2 = deploymentGroup "Service Instance 2" + deploymentNode "Server 1" { + containerInstance softwareSystem.api serviceInstance1 + containerInstance softwareSystem.database serviceInstance1 + } + deploymentNode "Server 2" { + containerInstance softwareSystem.api serviceInstance2 + containerInstance softwareSystem.database serviceInstance2 + } + } + + deploymentEnvironment "WithDeploymentGroupsAgain" { + serviceInstance1 = deploymentGroup "Service Instance 1" + serviceInstance2 = deploymentGroup "Service Instance 2" + deploymentNode "Server 1" { + containerInstance softwareSystem.api serviceInstance1 + containerInstance softwareSystem.database serviceInstance1 + } + deploymentNode "Server 2" { + containerInstance softwareSystem.api serviceInstance2 + containerInstance softwareSystem.database serviceInstance2 + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/deployment-view-animation.dsl b/structurizr-dsl/src/test/resources/dsl/deployment-view-animation.dsl new file mode 100644 index 000000000..a46afbbfd --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/deployment-view-animation.dsl @@ -0,0 +1,75 @@ +workspace { + + model { + ss = softwaresystem "Software System" { + webapp = container "Web Application" { + tag "UI" + } + db = container "Database Schema" { + tag "DB" + } + } + + webapp -> db + + live = deploymentEnvironment "Live" { + dn = deploymentNode "Deployment Node" { + webappInstance = containerInstance webapp + dbInstance = containerInstance db + } + } + } + + views { + deployment ss "Live" { + include * + + // add animation steps via container instance identifiers + animation { + webappInstance + dbInstance + } + } + + deployment ss "Live" { + include * + + // add animation steps via container identifiers + animation { + webapp + db + } + } + + deployment ss "Live" { + include * + + // add animation steps via element expressions + animation { + webapp + webapp-> + } + } + + deployment ss "Live" { + include * + + // add animation steps via element expressions + animation { + webappInstance + webappInstance-> + } + } + + deployment ss "Live" { + include * + + // add animation steps via element expressions + animation { + element.tag==UI + element.tag==DB + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/1.md b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/1.md new file mode 100644 index 000000000..7210af0e9 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/1.md @@ -0,0 +1,3 @@ +## Software System + +Content... \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/1.md b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/1.md new file mode 100644 index 000000000..7af941d8d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/1.md @@ -0,0 +1,3 @@ +## Container + +Content... \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/component/1.md b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/component/1.md new file mode 100644 index 000000000..4f7859729 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/docs/docs/softwaresystem/container/component/1.md @@ -0,0 +1,3 @@ +## Component + +Content... \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/docs/docs/workspace/1.md b/structurizr-dsl/src/test/resources/dsl/docs/docs/workspace/1.md new file mode 100644 index 000000000..ae8e89c20 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/docs/docs/workspace/1.md @@ -0,0 +1,3 @@ +## Workspace + +Content... \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/docs/workspace.dsl b/structurizr-dsl/src/test/resources/dsl/docs/workspace.dsl new file mode 100644 index 000000000..1f2a383f0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/docs/workspace.dsl @@ -0,0 +1,31 @@ +workspace { + + !docs docs/workspace com.structurizr.example.ExampleDocumentationImporter + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" { + !docs docs/softwaresystem + + container "Container" { + !docs docs/softwaresystem/container + + component "Component" { + !docs docs/softwaresystem/container/component/1.md + } + } + } + + user -> softwareSystem "Uses" + } + + views { + systemContext softwareSystem "Diagram1" { + include * + autoLayout + } + + theme default + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-custom-elements.dsl b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-custom-elements.dsl new file mode 100644 index 000000000..4dbbb263b --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-custom-elements.dsl @@ -0,0 +1,19 @@ +workspace { + + model { + a = element "A" + b = softwareSystem "B" + c = element "C" + + a -> b + b -> c + } + + views { + dynamic * { + a -> b + b -> c + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-ordering.dsl b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-ordering.dsl new file mode 100644 index 000000000..45bed69c8 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-ordering.dsl @@ -0,0 +1,19 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + c = softwareSystem "C" + + a -> b + b -> c + } + + views { + dynamic * { + 2: a -> b + 3: b -> c + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-relationships.dsl b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-relationships.dsl new file mode 100644 index 000000000..33103f4b4 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/dynamic-view-with-explicit-relationships.dsl @@ -0,0 +1,37 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + + r1 = a -> b "Sync" { + tags "Sync" + } + + r2 = a -> b "Async" { + tags "Async" + } + } + + views { + systemLandscape { + include * + autoLayout + } + + dynamic * { + r2 "Async" + autoLayout + } + + styles { + relationship "Sync" { + style solid + } + relationship "Async" { + style dashed + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/dynamic.dsl b/structurizr-dsl/src/test/resources/dsl/dynamic.dsl new file mode 100644 index 000000000..774afab65 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/dynamic.dsl @@ -0,0 +1,24 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + + a -> b "Sends data to" + } + + views { + dynamic * { + // with this example, the relationship uses the same description as defined in the static model + a -> b + autoLayout + } + + dynamic * { + // with this example, the relationship description is overriden to describe a particular feature/use case/etc + a -> b "Sends customer data to" + autoLayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/enterprise.dsl b/structurizr-dsl/src/test/resources/dsl/enterprise.dsl new file mode 100644 index 000000000..ea537ae91 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/enterprise.dsl @@ -0,0 +1,9 @@ +workspace { + + model { + enterprise "Name" { + person "User" + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/exclude-implied-relationship.dsl b/structurizr-dsl/src/test/resources/dsl/exclude-implied-relationship.dsl new file mode 100644 index 000000000..ec8370df9 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/exclude-implied-relationship.dsl @@ -0,0 +1,22 @@ +workspace { + + model { + softwareSystem "A" { + a = container "A" + } + + softwareSystem "B" { + b = container "B" + } + + r = a -> b + } + + views { + systemLandscape { + include * + exclude r + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/exclude-relationships.dsl b/structurizr-dsl/src/test/resources/dsl/exclude-relationships.dsl new file mode 100644 index 000000000..463d8d45c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/exclude-relationships.dsl @@ -0,0 +1,20 @@ +workspace { + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" + + user -> softwareSystem "Uses" + } + + views { + systemContext softwareSystem { + include * + exclude "* -> element.tag==Software System" + autolayout + } + + theme default + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/1.dsl b/structurizr-dsl/src/test/resources/dsl/extend/1.dsl new file mode 100644 index 000000000..b3c1cbc0b --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/1.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + user1 = person "User 1" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/2.dsl b/structurizr-dsl/src/test/resources/dsl/extend/2.dsl new file mode 100644 index 000000000..f873c6d4e --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/2.dsl @@ -0,0 +1,7 @@ +workspace extends 1.dsl { + + model { + user2 = person "User 2" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/3.dsl b/structurizr-dsl/src/test/resources/dsl/extend/3.dsl new file mode 100644 index 000000000..d23c96655 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/3.dsl @@ -0,0 +1,7 @@ +workspace extends 2.dsl { + + model { + user3 = person "User 3" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/4.dsl b/structurizr-dsl/src/test/resources/dsl/extend/4.dsl new file mode 100644 index 000000000..a978e2db0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/4.dsl @@ -0,0 +1,9 @@ +workspace extends 3.dsl { + + views { + systemLandscape { + include user1 user2 user3 + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-file.dsl b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-file.dsl new file mode 100644 index 000000000..5465ce720 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-file.dsl @@ -0,0 +1,12 @@ +workspace extends workspace.dsl { + + model { + !element softwareSystem1 { + webapp = container "Web Application" + } + + user -> softwareSystem1 "Uses" + softwareSystem3.webapp -> softwareSystem3.db + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-url.dsl b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-url.dsl new file mode 100644 index 000000000..086f56c12 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-dsl-url.dsl @@ -0,0 +1,12 @@ +workspace extends https://raw.githubusercontent.com/structurizr/dsl/master/src/test/dsl/extend/workspace.dsl { + + model { + !element softwareSystem1 { + webapp = container "Web Application" + } + + user -> softwareSystem1 "Uses" + softwareSystem3.webapp -> softwareSystem3.db + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-file.dsl b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-file.dsl new file mode 100644 index 000000000..e43dd69a7 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-file.dsl @@ -0,0 +1,18 @@ +workspace extends workspace.json { + + model { + // !element with DSL identifier + !element softwareSystem1 { + webapp1 = container "Web Application 1" + } + + // !element with canonical name + !element "SoftwareSystem://Software System 1" { + webapp2 = container "Web Application 2" + } + + user -> softwareSystem1 "Uses" + softwareSystem3.webapp -> softwareSystem3.db + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-url.dsl b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-url.dsl new file mode 100644 index 000000000..d59fa686c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/extend-workspace-from-json-url.dsl @@ -0,0 +1,18 @@ +workspace extends https://raw.githubusercontent.com/structurizr/dsl/master/src/test/dsl/extend/workspace.json { + + model { + // !element with DSL identifier + !element softwareSystem1 { + webapp1 = container "Web Application 1" + } + + // !element with canonical name + !element "SoftwareSystem://Software System 1" { + webapp2 = container "Web Application 2" + } + + user -> softwareSystem1 "Uses" + softwareSystem3.webapp -> softwareSystem3.db + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/workspace.dsl b/structurizr-dsl/src/test/resources/dsl/extend/workspace.dsl new file mode 100644 index 000000000..29861779d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/workspace.dsl @@ -0,0 +1,18 @@ +workspace { + + !identifiers hierarchical + + model { + user = person "User" + + softwareSystem1 = softwareSystem "Software System 1" + + softwareSystem "Software System 2" + + softwareSystem3 = softwareSystem "Software System 3" { + webapp = container "Web Application" + db = container "Database" + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/extend/workspace.json b/structurizr-dsl/src/test/resources/dsl/extend/workspace.json new file mode 100644 index 000000000..09a9f179a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/extend/workspace.json @@ -0,0 +1,73 @@ +{ + "id" : 0, + "name" : "Name", + "description" : "Description", + "properties" : { + "structurizr.dsl" : "d29ya3NwYWNlIHsKCiAgICAhaWRlbnRpZmllcnMgaGllcmFyY2hpY2FsCgogICAgbW9kZWwgewogICAgICAgIHVzZXIgPSBwZXJzb24gIlVzZXIiCgogICAgICAgIHNvZnR3YXJlU3lzdGVtMSA9IHNvZnR3YXJlU3lzdGVtICJTb2Z0d2FyZSBTeXN0ZW0gMSIKCiAgICAgICAgc29mdHdhcmVTeXN0ZW0gIlNvZnR3YXJlIFN5c3RlbSAyIgoKICAgICAgICBzb2Z0d2FyZVN5c3RlbTMgPSBzb2Z0d2FyZVN5c3RlbSAiU29mdHdhcmUgU3lzdGVtIDMiIHsKICAgICAgICAgICAgd2ViYXBwID0gY29udGFpbmVyICJXZWIgQXBwbGljYXRpb24iCiAgICAgICAgICAgIGRiID0gY29udGFpbmVyICJEYXRhYmFzZSIKICAgICAgICB9CiAgICB9Cgp9Cg==" + }, + "configuration" : { }, + "model" : { + "people" : [ { + "id" : "1", + "tags" : "Element,Person", + "properties" : { + "structurizr.dsl.identifier" : "user" + }, + "name" : "User", + "location" : "Unspecified" + } ], + "softwareSystems" : [ { + "id" : "2", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "softwaresystem1" + }, + "name" : "Software System 1", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "3", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "93ea2110-adec-4936-97aa-b55397325115" + }, + "name" : "Software System 2", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "4", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "softwaresystem3" + }, + "name" : "Software System 3", + "location" : "Unspecified", + "containers" : [ { + "id" : "5", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "softwaresystem3.webapp" + }, + "name" : "Web Application", + "documentation" : { } + }, { + "id" : "6", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "softwaresystem3.db" + }, + "name" : "Database", + "documentation" : { } + } ], + "documentation" : { } + } ] + }, + "documentation" : { }, + "views" : { + "configuration" : { + "branding" : { }, + "styles" : { }, + "terminology" : { } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/filteredviews.dsl b/structurizr-dsl/src/test/resources/dsl/filteredviews.dsl new file mode 100644 index 000000000..236be9509 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/filteredviews.dsl @@ -0,0 +1,41 @@ +workspace "FilteredDemo" "This is an example of Filtered views" { + + # This will render the two diagrams at https://structurizr.com/help/filtered-views + + model { + + user = person "Customer" "A description of the user." + sysa = softwareSystem "Software System A" "A description of software system A." + + user -> sysa "Uses for tasks 1 and 2" "" Current + + sysb = softwareSystem "Software System B" "A description of software system B." Future + + user -> sysa "Uses for task 1" "" Future + user -> sysb "Uses for task 2" "" Future + + } + + views { + + systemLandscape FullLandscape "System Landscape, current and future" { + include * + } + + filtered FullLandscape exclude Future CurrentLandscape "The current system landscape." + filtered FullLandscape exclude Current FutureLandscape "The future state system landscape after Software System B is live." + + styles { + element "Software System" { + background #91a437 + shape RoundedBox + } + + element "Person" { + background #6a7b15 + shape Person + } + } + + } +} diff --git a/structurizr-dsl/src/test/resources/dsl/financial-risk-system.dsl b/structurizr-dsl/src/test/resources/dsl/financial-risk-system.dsl new file mode 100644 index 000000000..a5539386f --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/financial-risk-system.dsl @@ -0,0 +1,67 @@ +workspace "Financial Risk System" "This is a simple (incomplete) example C4 model based upon the financial risk system architecture kata, which can be found at http://bit.ly/sa4d-risksystem" { + + model { + businessUser = person "Business User" "A regular business user." + configurationUser = person "Configuration User" "A regular business user who can also configure the parameters used in the risk calculations." + + financialRiskSystem = softwareSystem "Financial Risk System" "Calculates the bank's exposure to risk for product X." "Financial Risk System" + tradeDataSystem = softwareSystem "Trade Data System" "The system of record for trades of type X." + referenceDataSystem = softwareSystem "Reference Data System" "Manages reference data for all counterparties the bank interacts with." + referenceDataSystemV2 = softwareSystem "Reference Data System v2.0" "Manages reference data for all counterparties the bank interacts with." "Future State" + emailSystem = softwareSystem "E-mail system" "The bank's Microsoft Exchange system." + centralMonitoringService = softwareSystem "Central Monitoring Service" "The bank's central monitoring and alerting dashboard." + activeDirectory = softwareSystem "Active Directory" "The bank's authentication and authorisation system." + + businessUser -> financialRiskSystem "Views reports using" + financialRiskSystem -> tradeDataSystem "Gets trade data from" + financialRiskSystem -> referenceDataSystem "Gets counterparty data from" + financialRiskSystem -> referenceDataSystemV2 "Gets counterparty data from" "" "Future State" + configurationUser -> financialRiskSystem "Configures parameters using" + financialRiskSystem -> emailSystem "Sends a notification that a report is ready to" + emailSystem -> businessUser "Sends a notification that a report is ready to" "E-mail message" "Asynchronous" + financialRiskSystem -> centralMonitoringService "Sends critical failure alerts to" "SNMP" "Asynchronous, Alert" + financialRiskSystem -> activeDirectory "Uses for user authentication and authorisation" + } + + views { + + systemContext financialRiskSystem "Context" "An example System Context diagram for the Financial Risk System architecture kata." { + include * + autoLayout + } + + styles { + element "Element" { + color #ffffff + } + element "Software System" { + background #801515 + shape RoundedBox + } + element "Financial Risk System" { + background #550000 + color #ffffff + } + element "Future State" { + opacity 30 + } + element "Person" { + background #d46a6a + shape Person + } + relationship "Relationship" { + dashed false + } + relationship "Asynchronous" { + dashed true + } + relationship "Alert" { + color #ff0000 + } + relationship "Future State" { + opacity 30 + } + } + + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/find-element-hierarchical.dsl b/structurizr-dsl/src/test/resources/dsl/find-element-hierarchical.dsl new file mode 100644 index 000000000..ecc74c77d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/find-element-hierarchical.dsl @@ -0,0 +1,31 @@ +workspace { + + !identifiers hierarchical + + model { + a = softwareSystem "A" { + b = container "B" { + c = component "C" + + !element c { + properties { + "Name1" "Value1" + } + } + } + + !element b.c { + properties { + "Name2" "Value2" + } + } + } + + !element a.b.c { + properties { + "Name3" "Value3" + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/find-element.dsl b/structurizr-dsl/src/test/resources/dsl/find-element.dsl new file mode 100644 index 000000000..a4b0e66cd --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/find-element.dsl @@ -0,0 +1,45 @@ +workspace extends amazon-web-services.dsl { + + model { + + !element "DeploymentNode://Live/Amazon Web Services" { + deploymentNode "New deployment node" { + infrastructureNode "New infrastructure node" { + -> route53 + } + } + } + + !element "DeploymentNode://Live/Amazon Web Services/US-East-1" { + deploymentNode "New deployment node 1" { + infrastructureNode "New infrastructure node 1" { + -> route53 + } + } + } + + !element region { + deploymentNode "New deployment node 2" { + infrastructureNode "New infrastructure node 2" { + -> route53 + } + } + } + + !element live { + deploymentNode "New deployment node 3" { + infrastructureNode "New infrastructure node 3" { + -> route53 + } + } + } + } + + views { + deployment * "Live" { + include * + autolayout lr + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/find-elements-in-flat-group.dsl b/structurizr-dsl/src/test/resources/dsl/find-elements-in-flat-group.dsl new file mode 100644 index 000000000..a64eccbc5 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/find-elements-in-flat-group.dsl @@ -0,0 +1,17 @@ +workspace { + + model { + user = person "User" + + group = group "Group" { + softwareSystem "A" + softwareSystem "B" + softwareSystem "C" + } + + !elements group { + user -> this "Uses" + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/find-elements-in-nested-group.dsl b/structurizr-dsl/src/test/resources/dsl/find-elements-in-nested-group.dsl new file mode 100644 index 000000000..daae8b271 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/find-elements-in-nested-group.dsl @@ -0,0 +1,34 @@ +workspace { + + model { + properties { + "structurizr.groupSeparator" "/" + } + + user1 = person "User 1" + user2 = person "User 2" + + department1 = group "Department 1" { + team1 = group "Team 1" { + softwareSystem "A" + } + + team2 = group "Team 2" { + softwareSystem "B" + } + + team3 = group "Team 3" { + softwareSystem "C" + } + } + + !elements department1 { + user1 -> this "Uses" + } + + !elements team1 { + user2 -> this "Uses" + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/getting-started-short.dsl b/structurizr-dsl/src/test/resources/dsl/getting-started-short.dsl new file mode 100644 index 000000000..ddfcf56e1 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/getting-started-short.dsl @@ -0,0 +1,17 @@ +workspace { + + model { + user = person "User" "A user of my software system." + softwareSystem = softwareSystem "Software System" "My software system." + + user -> softwareSystem "Uses" + } + + views { + systemContext softwareSystem { + include * + autoLayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/getting-started.dsl b/structurizr-dsl/src/test/resources/dsl/getting-started.dsl new file mode 100644 index 000000000..3706c0796 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/getting-started.dsl @@ -0,0 +1,19 @@ +workspace { + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" + + user -> softwareSystem "Uses" + } + + views { + systemContext softwareSystem { + include * + autolayout + } + + theme default + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/group-url.dsl b/structurizr-dsl/src/test/resources/dsl/group-url.dsl new file mode 100644 index 000000000..64d36d9c7 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/group-url.dsl @@ -0,0 +1,11 @@ +workspace { + + model { + softwareSystem "Software System" { + group "Name" { + url "https://example.com" + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/group-without-brace.dsl b/structurizr-dsl/src/test/resources/dsl/group-without-brace.dsl new file mode 100644 index 000000000..71647fe6a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/group-without-brace.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + group "Name" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/groups-nested.dsl b/structurizr-dsl/src/test/resources/dsl/groups-nested.dsl new file mode 100644 index 000000000..6a355bb33 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/groups-nested.dsl @@ -0,0 +1,48 @@ +workspace { + + model { + properties { + structurizr.groupSeparator / + } + + group "Organisation" { + group "Department A" { + a = softwareSystem "A" { + group "Capability 1" { + group "Service A" { + container "A API" { + group "a-api.jar" { + component "API Endpoint" { + group "API Layer" + } + component "Repository" { + group "Data Layer" + } + } + } + container "A Database" + } + group "Service B" { + container "B API" + container "B Database" + } + } + } + } + + group "Department B" { + b = softwareSystem "B" + } + + c = softwareSystem "C" + } + } + + views { + systemLandscape { + include * + autolayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/groups.dsl b/structurizr-dsl/src/test/resources/dsl/groups.dsl new file mode 100644 index 000000000..d38ce2d46 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/groups.dsl @@ -0,0 +1,51 @@ +workspace { + + model { + softwareSystem = softwareSystem "Software System" { + service1 = group "Service 1" { + service1Api = container "Service 1 API" + service1Database = container "Service 1 Database" + + service1Api -> service1Database "Reads from and writes to" + } + service2 = group "Service 2" { + service2Api = container "Service 2 API" + service2Database = container "Service 2 Database" + + service2Api -> service2Database "Reads from and writes to" + } + } + + live = deploymentEnvironment "Live" { + group "Servers" { + deploymentNode "Server 1" { + group "Service 1" { + containerInstance service1Api + containerInstance service1Database + } + } + deploymentNode "Server 2" { + group "Service 2" { + containerInstance service2Api + containerInstance service2Database + } + } + } + } + + service1Api -> service2Api "Uses" + } + + views { + container softwareSystem { + include service1 service2 + autolayout + } + + deployment softwareSystem live { + include service1 service2 + autolayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-1.dsl b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-1.dsl new file mode 100644 index 000000000..54af32462 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-1.dsl @@ -0,0 +1,40 @@ + workspace { + + !identifiers hierarchical + + model { + ss = softwareSystem "Software System" + + deploymentEnvironment "Live" { + + dn = deploymentNode "DN" { + dn1 = deploymentNode "DN1" { + softwareSystemInstance ss + } + + dn2 = deploymentNode "DN2" { + softwareSystemInstance ss + } + + dn1 -> dn2 + } + + dn1 = deploymentNode "DN1" { + softwareSystemInstance ss + } + + dn2 = deploymentNode "DN2" { + softwareSystemInstance ss + } + + dn1 -> dn2 + } + } + + views { + deployment * "Live" { + include * + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-2.dsl b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-2.dsl new file mode 100644 index 000000000..ae26c3d9c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-2.dsl @@ -0,0 +1,18 @@ +workspace { + + !identifiers hierarchical + + model { + ss = softwareSystem "SS" + + live = deploymentEnvironment "Environment" { + dn = deploymentNode "DN1" { + ss = deploymentNode "DN2" { + softwareSystemInstance ss + } + } + } + + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-3.dsl b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-3.dsl new file mode 100644 index 000000000..97d3b7d37 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-and-deployment-nodes-3.dsl @@ -0,0 +1,22 @@ +workspace { + + !identifiers hierarchical + + model { + ss = softwareSystem "SS" { + c = container "C" + } + + live = deploymentEnvironment "Environment" { + dn = deploymentNode "DN1" { + ss = deploymentNode "DN2" { + c = deploymentNode "DN3" { + containerInstance ss.c + } + } + } + } + + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-when-unassigned.dsl b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-when-unassigned.dsl new file mode 100644 index 000000000..067a22cd8 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers-when-unassigned.dsl @@ -0,0 +1,19 @@ +workspace { + + !identifiers hierarchical + + model { + softwareSystem "A" { + container "B" { + component "C" + } + } + + deploymentEnvironment "Environment" { + deploymentNode "Deployment Node" { + infrastructureNode "Infrastructure Node" + } + } + + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers.dsl b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers.dsl new file mode 100644 index 000000000..503f6b91d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/hierarchical-identifiers.dsl @@ -0,0 +1,16 @@ +workspace { + + !identifiers hierarchical + + model { + a = person "A" + b = softwareSystem "B"{ + c = container "C" { + d = component "D" + a = component "A" + + d -> a + } + } + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/identifiers.dsl b/structurizr-dsl/src/test/resources/dsl/identifiers.dsl new file mode 100644 index 000000000..d4a32f218 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/identifiers.dsl @@ -0,0 +1,14 @@ +workspace { + + !identifiers hierarchical + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" { + container = container "Container" { + rel = user -> this "Uses" + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-view.dsl b/structurizr-dsl/src/test/resources/dsl/image-view.dsl new file mode 100644 index 000000000..c443c4b61 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-view.dsl @@ -0,0 +1,9 @@ +workspace { + + views { + image * "Image" { + image image.png + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/diagram.dot b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.dot new file mode 100644 index 000000000..3f2b18926 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.dot @@ -0,0 +1 @@ +digraph G {Hello->World} diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/diagram.mmd b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.mmd new file mode 100644 index 000000000..498140e3e --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.mmd @@ -0,0 +1,2 @@ +flowchart TD + Start --> Stop \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/diagram.puml b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.puml new file mode 100644 index 000000000..1da6ac585 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/diagram.puml @@ -0,0 +1,3 @@ +@startuml +Bob -> Alice : hello +@enduml \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/image.png b/structurizr-dsl/src/test/resources/dsl/image-views/image.png new file mode 100644 index 000000000..9ecfbfd32 Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/image-views/image.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/image.svg b/structurizr-dsl/src/test/resources/dsl/image-views/image.svg new file mode 100644 index 000000000..fe686329a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/image.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="us-ascii" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="120px" preserveAspectRatio="none" style="width:113px;height:120px;background:#FFFFFF;" version="1.1" viewBox="0 0 113 120" width="113px" zoomAndPan="magnify"><defs/><g><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="26" x2="26" y1="36.2969" y2="85.4297"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="82" x2="82" y1="36.2969" y2="85.4297"/><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="43" x="5" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="29" x="12" y="24.9951">Bob</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="43" x="5" y="84.4297"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="29" x="12" y="104.4248">Bob</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="49" x="58" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="35" x="65" y="24.9951">Alice</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="49" x="58" y="84.4297"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="35" x="65" y="104.4248">Alice</text><polygon fill="#181818" points="70.5,63.4297,80.5,67.4297,70.5,71.4297,74.5,67.4297" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="26.5" x2="76.5" y1="67.4297" y2="67.4297"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="30" x="33.5" y="62.3638">hello</text><!--SRC=[SyfFKj2rKt3CoKnELR1Io4ZDoSa70000]--></g></svg> \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-file.dsl b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-file.dsl new file mode 100644 index 000000000..3cfeed7e9 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-file.dsl @@ -0,0 +1,32 @@ +workspace { + + views { + properties { + "plantuml.url" "http://localhost:7777" + "mermaid.url" "http://localhost:8888" + "mermaid.compress" "false" + "kroki.url" "http://localhost:9999" + } + + image * "plantuml" { + plantuml diagram.puml + } + + image * "mermaid" { + mermaid diagram.mmd + } + + image * "kroki" { + kroki graphviz diagram.dot + } + + image * "png" { + image image.png + } + + image * "svg" { + image image.svg + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-source.dsl b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-source.dsl new file mode 100644 index 000000000..c342857ce --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-source.dsl @@ -0,0 +1,34 @@ +workspace { + + views { + properties { + "plantuml.url" "http://localhost:7777" + "mermaid.url" "http://localhost:8888" + "mermaid.compress" "false" + "kroki.url" "http://localhost:9999" + } + + image * "plantuml" { + plantuml """ + @startuml + Bob -> Alice : hello + @enduml + """ + } + + image * "mermaid" { + mermaid """ + flowchart TD + Start --> Stop + """ + } + + image * "kroki" { + kroki graphviz """ + digraph G {Hello->World} + + """ + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-url.dsl b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-url.dsl new file mode 100644 index 000000000..f42f657ac --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/image-views/workspace-via-url.dsl @@ -0,0 +1,35 @@ +workspace { + + views { + properties { + "plantuml.url" "http://localhost:7777" + "plantuml.format" "svg" + "mermaid.url" "http://localhost:8888" + "mermaid.format" "svg" + "mermaid.compress" "false" + "kroki.url" "http://localhost:9999" + "kroki.format" "svg" + } + + image * "plantuml" { + plantuml https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.puml + } + + image * "mermaid" { + mermaid https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.mmd + } + + image * "kroki" { + kroki graphviz https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/diagram.dot + } + + image * "png" { + image https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.png + } + + image * "svg" { + image https://raw.githubusercontent.com/structurizr/java/master/structurizr-dsl/src/test/resources/dsl/image-views/image.svg + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include-directory.dsl b/structurizr-dsl/src/test/resources/dsl/include-directory.dsl new file mode 100644 index 000000000..d402b5e98 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include-directory.dsl @@ -0,0 +1,14 @@ +workspace { + + model { + !var SOFTWARE_SYSTEM_NAME "Software System 1" + !include include/model/software-system/model.dsl + + !var SOFTWARE_SYSTEM_NAME "Software System 2" + !include include/model/software-system + + !var SOFTWARE_SYSTEM_NAME "Software System 3" + !include include/model + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include-file.dsl b/structurizr-dsl/src/test/resources/dsl/include-file.dsl new file mode 100644 index 000000000..83d8d93db --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include-file.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + !include include/model.dsl + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include-implied-relationship.dsl b/structurizr-dsl/src/test/resources/dsl/include-implied-relationship.dsl new file mode 100644 index 000000000..86757f95c --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include-implied-relationship.dsl @@ -0,0 +1,23 @@ +workspace { + + model { + softwareSystem "A" { + a = container "A" + } + + softwareSystem "B" { + b = container "B" + } + + r = a -> b + } + + views { + systemLandscape { + include * + exclude *->* + include r + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include-url.dsl b/structurizr-dsl/src/test/resources/dsl/include-url.dsl new file mode 100644 index 000000000..e8e813e37 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include-url.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + !include https://raw.githubusercontent.com/structurizr/java/refs/heads/master/structurizr-dsl/src/test/resources/dsl/include/model.dsl + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include/docs/section.md b/structurizr-dsl/src/test/resources/dsl/include/docs/section.md new file mode 100644 index 000000000..fa8d5e722 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include/docs/section.md @@ -0,0 +1,3 @@ +## Heading + +Text... \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include/model.dsl b/structurizr-dsl/src/test/resources/dsl/include/model.dsl new file mode 100644 index 000000000..71e89755d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include/model.dsl @@ -0,0 +1 @@ +softwareSystem = softwareSystem "Software System" \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/include/model/software-system/model.dsl b/structurizr-dsl/src/test/resources/dsl/include/model/software-system/model.dsl new file mode 100644 index 000000000..a040bae35 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/include/model/software-system/model.dsl @@ -0,0 +1,3 @@ +softwareSystem "${SOFTWARE_SYSTEM_NAME}" { + !docs ../../docs +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/iso-8859.dsl b/structurizr-dsl/src/test/resources/dsl/iso-8859.dsl new file mode 100644 index 000000000..caad37c01 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/iso-8859.dsl @@ -0,0 +1,5 @@ +workspace { + model { + softwareSystem "Namé" + } +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/logo.png b/structurizr-dsl/src/test/resources/dsl/logo.png new file mode 100644 index 000000000..763d19bf5 Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/logo.png differ diff --git a/structurizr-dsl/src/test/resources/dsl/multi-line-with-error.dsl b/structurizr-dsl/src/test/resources/dsl/multi-line-with-error.dsl new file mode 100644 index 000000000..8c3290044 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/multi-line-with-error.dsl @@ -0,0 +1,12 @@ +workspace { + + model { + softwareSystem = \ + softwareSystem \ + "Software \ + System" { + component "Component" // components not permitted inside software systems + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/multi-line.dsl b/structurizr-dsl/src/test/resources/dsl/multi-line.dsl new file mode 100644 index 000000000..2fc5b87c6 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/multi-line.dsl @@ -0,0 +1,10 @@ +workspace { + + model { + softwareSystem = \ + softwareSystem \ + "Software \ + System" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/multiple-model-tokens.dsl b/structurizr-dsl/src/test/resources/dsl/multiple-model-tokens.dsl new file mode 100644 index 000000000..d75ee05f3 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/multiple-model-tokens.dsl @@ -0,0 +1,11 @@ +workspace { + + model { + person "User 1" + } + + model { + person "User 2" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/multiple-view-tokens.dsl b/structurizr-dsl/src/test/resources/dsl/multiple-view-tokens.dsl new file mode 100644 index 000000000..23c1a878f --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/multiple-view-tokens.dsl @@ -0,0 +1,19 @@ +workspace { + + model { + person "User 1" + } + + views { + systemLandscape { + include * + } + } + + views { + systemLandscape { + include * + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/multiple-workspace-tokens.dsl b/structurizr-dsl/src/test/resources/dsl/multiple-workspace-tokens.dsl new file mode 100644 index 000000000..391d88d81 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/multiple-workspace-tokens.dsl @@ -0,0 +1,15 @@ +workspace { + + model { + person "User 1" + } + +} + +workspace { + + model { + person "User 2" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/no-relationship.dsl b/structurizr-dsl/src/test/resources/dsl/no-relationship.dsl new file mode 100644 index 000000000..fdf96ed8a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/no-relationship.dsl @@ -0,0 +1,88 @@ +workspace { + + !identifiers hierarchical + + model { + ss = softwareSystem "Software System" { + ui = container "UI" "Description" "JavaScript and React" + backend = container "Backend" "Description" "Spring Boot" + + ui -> backend "Makes API requests to" "JSON/HTTPS" + } + + // ui -> backend + one = deploymentEnvironment "One" { + deploymentNode "Developer's Computer" { + deploymentNode "Web Browser" { + instanceOf ss.ui + } + instanceOf ss.backend + } + } + + // ui -> loadbalancer -> backend + // configured via container identifiers + two = deploymentEnvironment "Two" { + deploymentNode "User's Computer" { + deploymentNode "Web Browser" { + instanceOf ss.ui + } + } + dc = deploymentNode "Data Center" { + loadBalancer = infrastructureNode "Load Balancer" + deploymentNode "Server" { + instanceOf ss.backend + } + } + + ss.ui -/> ss.backend { + ss.ui -> dc.loadBalancer + dc.loadBalancer -> ss.backend "Forwards API requests to" "" + } + } + + // ui -> loadbalancer -> backend + // configured via container instance identifiers + three = deploymentEnvironment "Three" { + computer = deploymentNode "User's Computer" { + webbrowser = deploymentNode "Web Browser" { + ui = instanceOf ss.ui + } + } + datacenter = deploymentNode "Data Center" { + loadbalancer = infrastructureNode "Load Balancer" + server = deploymentNode "Server" { + backend = instanceOf ss.backend + } + } + + computer.webbrowser.ui -/> datacenter.server.backend { + computer.webbrowser.ui -> datacenter.loadbalancer + datacenter.loadbalancer -> datacenter.server.backend "Forwards API requests to" "" + } + } + } + + views { + container ss { + include * + autolayout lr + } + + deployment ss one { + include * + autolayout lr + } + + deployment ss two { + include * + autolayout lr + } + + deployment ss three { + include * + autolayout lr + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/parallel1.dsl b/structurizr-dsl/src/test/resources/dsl/parallel1.dsl new file mode 100644 index 000000000..70fc06138 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/parallel1.dsl @@ -0,0 +1,33 @@ +workspace { + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" { + webapp = container "Web Application" + bus = container "Message Bus" + app1 = container "App 1" + app2 = container "App 2" + } + + user -> webapp "Updates details" + webapp -> bus "Sends update event" + bus -> app1 "Broadcasts update event" + bus -> app2 "Broadcasts update event" + } + + views { + dynamic softwareSystem { + user -> webapp + webapp -> bus + { + bus -> app1 + } + { + bus -> app2 + } + + autoLayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/parallel2.dsl b/structurizr-dsl/src/test/resources/dsl/parallel2.dsl new file mode 100644 index 000000000..8d7d39e94 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/parallel2.dsl @@ -0,0 +1,34 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + c = softwareSystem "C" + d = softwareSystem "D" + e = softwareSystem "E" + + a -> b + b -> c + b -> d + b -> e + } + + views { + + dynamic * { + a -> b "Makes a request to" + { + { + b -> c "Gets data from" + } + { + b -> d "Gets data from" + } + } + b -> e "Sends data to" + + autoLayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/parallel3.dsl b/structurizr-dsl/src/test/resources/dsl/parallel3.dsl new file mode 100644 index 000000000..0fafca38e --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/parallel3.dsl @@ -0,0 +1,28 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + c = softwareSystem "C" + d = softwareSystem "D" + e = softwareSystem "E" + + a -> b + b -> c + b -> d + b -> e + } + + views { + + dynamic * { + 1: a -> b "Makes a request to" + 2: b -> c "Gets data from" + 2: b -> d "Gets data from" + 3: b -> e "Sends data to" + + autoLayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/plugin-with-parameters.dsl b/structurizr-dsl/src/test/resources/dsl/plugin-with-parameters.dsl new file mode 100644 index 000000000..4314c7539 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/plugin-with-parameters.dsl @@ -0,0 +1,7 @@ +workspace { + + !plugin com.example.ExampleStructurizrDslPlugin { + name Java + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/plugin-without-parameters.dsl b/structurizr-dsl/src/test/resources/dsl/plugin-without-parameters.dsl new file mode 100644 index 000000000..2831b60dc --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/plugin-without-parameters.dsl @@ -0,0 +1,5 @@ +workspace { + + !plugin com.example.ExampleStructurizrDslPlugin + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/plugins/structurizr-dsl-plugin-1.0.0.jar b/structurizr-dsl/src/test/resources/dsl/plugins/structurizr-dsl-plugin-1.0.0.jar new file mode 100644 index 000000000..42234ce4b Binary files /dev/null and b/structurizr-dsl/src/test/resources/dsl/plugins/structurizr-dsl-plugin-1.0.0.jar differ diff --git a/structurizr-dsl/src/test/resources/dsl/relationship-already-exists.dsl b/structurizr-dsl/src/test/resources/dsl/relationship-already-exists.dsl new file mode 100644 index 000000000..59030d423 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/relationship-already-exists.dsl @@ -0,0 +1,13 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" { + c = container "C" + } + + c -> a + b -> a + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/relationship-without-identifier.dsl b/structurizr-dsl/src/test/resources/dsl/relationship-without-identifier.dsl new file mode 100644 index 000000000..b07a91125 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/relationship-without-identifier.dsl @@ -0,0 +1,9 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + a -> b + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/script-external-with-parameters.dsl b/structurizr-dsl/src/test/resources/dsl/script-external-with-parameters.dsl new file mode 100644 index 000000000..47002cedf --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/script-external-with-parameters.dsl @@ -0,0 +1,7 @@ +workspace { + + !script test.groovy { + "name" "Groovy" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/script-external.dsl b/structurizr-dsl/src/test/resources/dsl/script-external.dsl new file mode 100644 index 000000000..4e2d8988a --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/script-external.dsl @@ -0,0 +1,7 @@ +workspace { + + !script test.groovy + !script test.kts + !script test.rb + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/script-in-dynamic-view.dsl b/structurizr-dsl/src/test/resources/dsl/script-in-dynamic-view.dsl new file mode 100644 index 000000000..f29f4e950 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/script-in-dynamic-view.dsl @@ -0,0 +1,16 @@ +workspace { + + model { + user = person "User" + softwareSystem = softwareSystem "Software System" + } + + views { + dynamic * "key" { + !script groovy { + view.description = "Groovy" + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/script-inline.dsl b/structurizr-dsl/src/test/resources/dsl/script-inline.dsl new file mode 100644 index 000000000..3325479af --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/script-inline.dsl @@ -0,0 +1,42 @@ +workspace { + + !script groovy { + println("Hello from Groovy"); + workspace.model.addPerson("Groovy"); + } + + !script kotlin { + println("Hello from Kotlin"); + workspace.model.addPerson("Kotlin"); + } + + !script ruby { + puts "Hello from Ruby" + workspace.model.addPerson("Ruby"); + } + + model { + user = person "User" { + !script groovy { + element.addTags("Groovy") + } + } + + softwareSystem "Software System" { + user -> this { + !script groovy { + relationship.addTags("Groovy") + } + } + } + } + + views { + systemLandscape { + !script groovy { + view.description = "Groovy" + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/shapes.dsl b/structurizr-dsl/src/test/resources/dsl/shapes.dsl new file mode 100644 index 000000000..4f408498d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/shapes.dsl @@ -0,0 +1,89 @@ +workspace "Shapes" "An example of all shapes available in Structurizr." { + + model { + softwareSystem "Box" "" "Box" + softwareSystem "RoundedBox" "" "RoundedBox" + softwareSystem "Diamond" "" "Diamond" + softwareSystem "Circle" "" "Circle" + softwareSystem "Ellipse" "" "Ellipse" + softwareSystem "Hexagon" "" "Hexagon" + softwareSystem "Folder" "" "Folder" + softwareSystem "Cylinder" "" "Cylinder" + softwareSystem "Pipe" "" "Pipe" + softwareSystem "WebBrowser" "" "Web Browser" + softwareSystem "Mobile Device Portrait" "" "Mobile Device Portrait" + softwareSystem "Mobile Device Landscape" "" "Mobile Device Landscape" + softwareSystem "Component" "" "Component" + person "Person" + softwareSystem "Robot" "" "Robot" + } + + views { + systemLandscape "shapes" "An example of all shapes available in Structurizr." { + include * + } + + styles { + element "Element" { + width "650" + height "400" + background "#438dd5" + color "#ffffff" + fontSize "34" + metadata "false" + description "false" + } + element "Box" { + shape "Box" + } + element "RoundedBox" { + shape "RoundedBox" + } + element "Diamond" { + shape "Diamond" + } + element "Circle" { + shape "Circle" + } + element "Ellipse" { + shape "Ellipse" + } + element "Hexagon" { + shape "Hexagon" + } + element "Folder" { + shape "Folder" + } + element "Cylinder" { + shape "Cylinder" + } + element "Pipe" { + shape "Pipe" + } + element "Web Browser" { + shape "WebBrowser" + } + element "Mobile Device Portrait" { + shape "MobileDevicePortrait" + width "400" + height "650" + } + element "Mobile Device Landscape" { + shape "MobileDeviceLandscape" + } + element "Component" { + shape "Component" + } + element "Person" { + shape "Person" + width "550" + } + element "Robot" { + shape "Robot" + width "550" + } + } + + } + +} diff --git a/structurizr-dsl/src/test/resources/dsl/source-child.dsl b/structurizr-dsl/src/test/resources/dsl/source-child.dsl new file mode 100644 index 000000000..54ca1a6de --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/source-child.dsl @@ -0,0 +1,7 @@ +workspace extends source-parent.dsl { + + model { + b = softwareSystem "B" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/source-not-retained.dsl b/structurizr-dsl/src/test/resources/dsl/source-not-retained.dsl new file mode 100644 index 000000000..a09c05b91 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/source-not-retained.dsl @@ -0,0 +1,11 @@ +workspace { + + properties { + structurizr.dsl.source false + } + + model { + a = softwareSystem "A" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/source-parent.dsl b/structurizr-dsl/src/test/resources/dsl/source-parent.dsl new file mode 100644 index 000000000..a19e355ac --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/source-parent.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + a = softwareSystem "A" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/spring-petclinic/workspace.dsl b/structurizr-dsl/src/test/resources/dsl/spring-petclinic/workspace.dsl new file mode 100644 index 000000000..8809a0700 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/spring-petclinic/workspace.dsl @@ -0,0 +1,103 @@ +workspace "Spring PetClinic" "A C4 model of the Spring PetClinic sample app (https://github.com/spring-projects/spring-petclinic/)" { + + // this example requires an environment variable as follows: + // - Name: SPRING_PETCLINIC_HOME + // - Value: the full path to the location of the spring-petclinic example (e.g. /Users/simon/spring-petclinic) + + !identifiers hierarchical + + model { + clinicEmployee = person "Clinic Employee" "An employee of the clinic." + springPetClinic = softwareSystem "Spring PetClinic" "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." { + relationalDatabaseSchema = container "Relational Database Schema" { + description "Stores information regarding the veterinarians, the clients, and their pets." + technology "Relational Database Schema" + url "https://github.com/spring-projects/spring-petclinic/tree/main/src/main/resources/db" + tag "Relational Database Schema" + } + + webApplication = container "Web Application" { + description "Allows employees to view and manage information regarding the veterinarians, the clients, and their pets." + technology "Java and Spring" + + !components { + classes "${SPRING_PETCLINIC_HOME}/target/spring-petclinic-3.4.0-SNAPSHOT.jar" + source "${SPRING_PETCLINIC_HOME}/src/main/java" + filter include fqn-regex "org.springframework.samples.petclinic..*" + strategy { + technology "Spring MVC Controller" + matcher annotation "org.springframework.stereotype.Controller" + filter exclude fqn-regex ".*.CrashController" + url prefix-src "https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java" + forEach { + clinicEmployee -> this "Uses" + tag "Spring MVC Controller" + group "Web Controllers" + } + } + strategy { + technology "Spring Data Repository" + matcher name-suffix "Repository" + description first-sentence + url prefix-src "https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java" + forEach { + -> relationalDatabaseSchema "Reads from and writes to" + tag "Spring Data Repository" + group "Data Repositories" + } + } + } + } + } + } + + views { + systemContext springPetClinic "SystemContext" { + include * + autolayout + } + + container springPetClinic "Containers" { + include * + autolayout + } + + component springPetClinic.webApplication "Components" { + include * + autolayout + } + + styles { + element "Person" { + shape person + background #519823 + color #FFFFFF + } + + element "Software System" { + background #6CB33E + color #FFFFFF + } + + element "Container" { + background #91D366 + color #FFFFFF + } + + element "Relational Database Schema" { + shape cylinder + } + + element "Spring MVC Controller" { + background #D4F3C0 + color #000000 + } + + element "Spring Data Repository" { + background #95D46C + color #000000 + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/static-view-animation.dsl b/structurizr-dsl/src/test/resources/dsl/static-view-animation.dsl new file mode 100644 index 000000000..e68edd0d5 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/static-view-animation.dsl @@ -0,0 +1,32 @@ +workspace { + + model { + a = softwareSystem "A" + b = softwareSystem "B" + + a -> b + } + + views { + systemLandscape { + include * + + // add animation steps via element identifiers + animation { + a + b + } + } + + systemLandscape { + include * + + // add animation steps via element expressions + animation { + a + a-> + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test-with-parameters.groovy b/structurizr-dsl/src/test/resources/dsl/test-with-parameters.groovy new file mode 100644 index 000000000..fa7d24643 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test-with-parameters.groovy @@ -0,0 +1,4 @@ +package dsl + +println("Hello from " + name); +workspace.model.addPerson(name); \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test.dsl b/structurizr-dsl/src/test/resources/dsl/test.dsl new file mode 100644 index 000000000..2073a6fc1 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test.dsl @@ -0,0 +1,436 @@ +!const ORGANISATION_NAME "Organisation" +!const GROUP_NAME "Group" + +!var name abc +!var name ABC + +workspace "Name" "Description" { + + /* + multi-line comment + */ + + /** + multi-line comment + */ + + /* multi-line comment on single line */ + + /* multi-line comment + on two lines */ + + # single line comment + // single line comment + + model { + !impliedRelationships false + !impliedRelationships "com.structurizr.model.CreateImpliedRelationshipsUnlessSameRelationshipExistsStrategy" + !impliedRelationships true + + // single line comment with long line split character \ + properties { + "Name" "Value" + } + + box1 = element "Box 1" "Metadata" "Description" "Tag" + box2 = element "Box 2" "Metadata" "Description" "Tag" + box1 -> box2 + + user = person "User" "Description" "Tag" { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + "Technical Debt" "Tech debt is high due to delivering feature X rapidly." "High" + } + } + + group "${ORGANISATION_NAME} - ${GROUP_NAME}" { + softwareSystem = softwareSystem "Software System" "Description" "Tag" { + webApplication = container "Web Application" "Description" "Technology" "Tag" { + homePageController = component "HomePageController" "Description" "Spring MVC Controller" "Tag" { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + } + + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + + !elements "element.parent==webApplication && element.technology==Spring MVC Controller" { + tag "Tag 1" + tags "Tag 2, Tag 3" + url "https://example.com" + properties { + "type" "Spring MVC Controller" + } + perspectives { + "Owner" "Team A" + } + } + } + + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + } + + softwareSystem "E-mail System" "Description" "Tag" + } + + user -> HomePageController "Visits" "HTTPS" "Tag" + + developmentEnvironment = deploymentEnvironment "Development" { + deploymentNode "Amazon Web Services" "Description" "Technology" "Tag" { + softwareSystemInstance softwareSystem { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + healthCheck "Check 1" "https://example.com/health" + healthCheck "Check 2" "https://example.com/health" 60 + healthCheck "Check 2" "https://example.com/health" 120 1000 + } + instanceOf softwareSystem { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + healthCheck "Check 1" "https://example.com/health" + healthCheck "Check 2" "https://example.com/health" 60 + healthCheck "Check 2" "https://example.com/health" 120 1000 + } + } + } + + deploymentEnvironment "Live" { + deploymentNode "Amazon Web Services" "Description" "Technology" "Tag" { + + infrastructureNode "Elastic Load Balancer" "Description" "Technology" "Tag" { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + } + + deploymentNode "Amazon Web Services - EC2" "Description" "Technology" "Tag" { + containerInstance webApplication { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + healthCheck "Check 1" "https://example.com/health" + healthCheck "Check 2" "https://example.com/health" 60 + healthCheck "Check 2" "https://example.com/health" 120 1000 + } + instanceOf webApplication { + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + healthCheck "Check 1" "https://example.com/health" + healthCheck "Check 2" "https://example.com/health" 60 + healthCheck "Check 2" "https://example.com/health" 120 1000 + } + } + + url "https://structurizr.com" + properties { + "Name" "Value" + } + perspectives { + "Security" "A description..." + } + + } + } + + !relationships "*->*" { + tag "Tag 1" + tags "Tag 2, Tag 3" + url "https://example.com" + properties { + name value + } + perspectives { + name value + } + } + } + + views { + + custom "CustomDiagram" "Title" "Description" { + title "Title" + description "Description" + + include box1 box2 + + animation { + box1 + box2 + } + + autolayout + + properties { + "Name" "Value" + } + + default + } + + systemLandscape "SystemLandscape" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + systemContext softwareSystem "SystemContext" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + container softwareSystem "Containers" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + component webApplication "Components" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + filtered "SystemLandscape" include "Element,Relationship" "Filtered1" + + filtered "SystemLandscape" include "Element,Relationship" "Filtered2" { + title "Filtered view" + description "Description" + + properties { + "Name" "Value" + } + + default + } + + dynamic webApplication "Dynamic" "Description" { + title "Title" + description "Description" + + user -> homePageController "Requests via web browser" + homePageController -> user { + url "https://structurizr.com" + properties { + "Name" "Value" + } + } + + autoLayout + + properties { + "Name" "Value" + } + + default + } + + deployment * developmentEnvironment "Deployment-Development" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + deployment * "Live" "Deployment-Live" "Description" { + title "Title" + description "Description" + + include * + autoLayout + + properties { + "Name" "Value" + } + + default + } + + image * { + light { + image logo.png + } + dark { + image logo.png + } + image logo.png + } + + styles { + element "Element" { + shape roundedbox + icon logo.png + iconPosition left + width 450 + height 300 + background #ffffff + color #000000 + colour #000000 + stroke #777777 + fontSize 24 + border solid + opacity 50 + metadata false + description false + properties { + "Name" "Value" + } + } + + relationship "Relationship" { + thickness 2 + color #777777 + colour #777777 + dashed true + routing curved + jump true + fontSize 24 + width 400 + position 50 + opacity 50 + properties { + "Name" "Value" + } + } + + light { + element "Element" { + background #ffffff + } + + relationship "Relationship" { + color #777777 + } + } + + dark { + element "Element" { + background #000000 + } + + relationship "Relationship" { + color #777777 + } + } + + theme https://example.com/theme1 + themes https://example.com/theme2 https://example.com/theme3 + } + + theme https://example.com/theme1 + themes https://example.com/theme2 https://example.com/theme3 + + branding { + logo logo.png + font "Example" https://example/com/font + } + + terminology { + person "Person" + softwareSystem "Software System" + container "Container" + component "Component" + deploymentNode "Deployment Node" + infrastructureNode "Infrastructure Node" + relationship "Relationship" + metadata angle + } + + properties { + "Name" "Value" + } + } + + configuration { + users { + user1@example.com read + user2@example.com write + } + + visibility public + scope softwaresystem + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test.groovy b/structurizr-dsl/src/test/resources/dsl/test.groovy new file mode 100644 index 000000000..eefdea320 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test.groovy @@ -0,0 +1,4 @@ +package dsl + +println("Hello from Groovy"); +workspace.model.addPerson("Groovy"); \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test.js b/structurizr-dsl/src/test/resources/dsl/test.js new file mode 100644 index 000000000..7b6a7297d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test.js @@ -0,0 +1,2 @@ +print("Hello from JavaScript"); +workspace.model.addPerson("JavaScript"); \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test.kts b/structurizr-dsl/src/test/resources/dsl/test.kts new file mode 100644 index 000000000..ba1ad34fe --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test.kts @@ -0,0 +1,2 @@ +println("Hello from Kotlin"); +workspace.model.addPerson("Kotlin"); \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/test.rb b/structurizr-dsl/src/test/resources/dsl/test.rb new file mode 100644 index 000000000..8b17cc3d4 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/test.rb @@ -0,0 +1,2 @@ +puts "Hello from JRuby" +workspace.model.addPerson("Ruby") \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/text-block.dsl b/structurizr-dsl/src/test/resources/dsl/text-block.dsl new file mode 100644 index 000000000..1b442a781 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/text-block.dsl @@ -0,0 +1,32 @@ +workspace { + + views { + properties { + "plantuml.url" "https://plantuml.com/plantuml" + } + + !const SOURCE """ + class MyClass + """ + + !var STYLES """ + <style> + root { + BackgroundColor: #ffffff; + } + </style> + """ + + image * "image" { + plantuml """ + @startuml + + ${STYLES} + + ${SOURCE} + @enduml + """ + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/this.dsl b/structurizr-dsl/src/test/resources/dsl/this.dsl new file mode 100644 index 000000000..acc5f9af5 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/this.dsl @@ -0,0 +1,71 @@ +workspace { + + model { + custom = element "Element" + + s = softwareSystem "Software System" { + custom -> this + + c = container "Container" { + custom -> this + + component "Component" { + custom -> this + } + } + } + + live = deploymentEnvironment "live" { + deploymentNode "Live" { + in = infrastructureNode "Infrastructure Node" { + custom -> this + } + + dn = deploymentNode "Deployment Node" { + in -> this + + softwareSystemInstance s { + in -> this + } + + containerInstance c { + in -> this + } + } + } + } + } + + views { + systemLandscape { + include * + include custom + autolayout + } + + systemContext s { + include * + include custom + autolayout + } + + container s { + include * + include custom + autolayout + } + + component c { + include * + include custom + autolayout + } + + deployment * live { + include * + include custom + autolayout + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-after-workspace.dsl b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-after-workspace.dsl new file mode 100644 index 000000000..b63613317 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-after-workspace.dsl @@ -0,0 +1,4 @@ +workspace { +} + +hello world \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-before-workspace.dsl b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-before-workspace.dsl new file mode 100644 index 000000000..1144a9886 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-before-workspace.dsl @@ -0,0 +1,4 @@ +hello world + +workspace { +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-in-workspace.dsl b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-in-workspace.dsl new file mode 100644 index 000000000..2ab0050d4 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/unexpected-tokens-in-workspace.dsl @@ -0,0 +1,5 @@ +workspace { + + softwareSystem "Name" + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/utf8.dsl b/structurizr-dsl/src/test/resources/dsl/utf8.dsl new file mode 100644 index 000000000..9a00ab44d --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/utf8.dsl @@ -0,0 +1,7 @@ +workspace { + + model { + user = person "你好 Usér 🙂" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/views-without-keys.dsl b/structurizr-dsl/src/test/resources/dsl/views-without-keys.dsl new file mode 100644 index 000000000..ec9074d36 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/views-without-keys.dsl @@ -0,0 +1,18 @@ +workspace { + + model { + person "User" + } + + views { + systemLandscape { + } + + systemLandscape { + } + + systemLandscape { + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/workspace-properties.dsl b/structurizr-dsl/src/test/resources/dsl/workspace-properties.dsl new file mode 100644 index 000000000..15b7ff9c7 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/workspace-properties.dsl @@ -0,0 +1,7 @@ +workspace { + + properties { + "structurizr.dslEditor" "false" + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/workspace-with-bom-model.dsl b/structurizr-dsl/src/test/resources/dsl/workspace-with-bom-model.dsl new file mode 100644 index 000000000..e54770cfa --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/workspace-with-bom-model.dsl @@ -0,0 +1,6 @@ + model { + user = person "User" "A user of my software system." + softwareSystem = softwareSystem "Software System" "My software system, code-named \"X\"." + + user -> softwareSystem "Uses" + } \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/dsl/workspace-with-bom.dsl b/structurizr-dsl/src/test/resources/dsl/workspace-with-bom.dsl new file mode 100644 index 000000000..b0f7a64b0 --- /dev/null +++ b/structurizr-dsl/src/test/resources/dsl/workspace-with-bom.dsl @@ -0,0 +1,24 @@ +workspace "Getting Started" "This is a model of my software system." { + + !include workspace-with-bom-model.dsl + + views { + systemContext softwareSystem "SystemContext" "An example of a System Context diagram." { + include * + autoLayout + } + + styles { + element "Software System" { + background #1168bd + color #ffffff + } + element "Person" { + shape person + background #08427b + color #ffffff + } + } + } + +} \ No newline at end of file diff --git a/structurizr-dsl/src/test/resources/themes/theme.json b/structurizr-dsl/src/test/resources/themes/theme.json new file mode 100644 index 000000000..4f9774996 --- /dev/null +++ b/structurizr-dsl/src/test/resources/themes/theme.json @@ -0,0 +1,11 @@ +{ + "name" : "Theme", + "elements" : [ { + "tag" : "Tag", + "background" : "#ff0000" + } ], + "relationships" : [ { + "tag" : "Tag", + "color" : "#00ff00" + } ] +} \ No newline at end of file diff --git a/structurizr-export/README.md b/structurizr-export/README.md new file mode 100644 index 000000000..7ad1a2da5 --- /dev/null +++ b/structurizr-export/README.md @@ -0,0 +1,9 @@ +# structurizr-export + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-export.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-export) + +This library provides the ability to export the model and views defined in a Structurizr workspace to a number of formats, +including PlantUML and C4-PlantUML, Mermaid, DOT, WebSequenceDiagrams, and Ilograph. + +- [Structurizr DSL demo page](https://structurizr.com/dsl) (demo of export formats) +- [Documentation](https://docs.structurizr.com/export) diff --git a/structurizr-export/build.gradle b/structurizr-export/build.gradle new file mode 100644 index 000000000..f908d3fb5 --- /dev/null +++ b/structurizr-export/build.gradle @@ -0,0 +1,9 @@ +dependencies { + + api project(':structurizr-core') + + testImplementation project(':structurizr-client') + +} + +description = 'Export Structurizr models and views to external formats' diff --git a/structurizr-export/src/main/java/com/structurizr/export/AbstractDiagramExporter.java b/structurizr-export/src/main/java/com/structurizr/export/AbstractDiagramExporter.java new file mode 100644 index 000000000..f982fd5f1 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/AbstractDiagramExporter.java @@ -0,0 +1,851 @@ +package com.structurizr.export; + +import com.structurizr.Workspace; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public abstract class AbstractDiagramExporter extends AbstractExporter implements DiagramExporter { + + protected static final String GROUP_SEPARATOR_PROPERTY_NAME = "structurizr.groupSeparator"; + + protected final ColorScheme colorScheme; + + private Object frame = null; + + public AbstractDiagramExporter() { + this(ColorScheme.Light); + } + + public AbstractDiagramExporter(ColorScheme colorScheme) { + this.colorScheme = colorScheme != null ? colorScheme : ColorScheme.Light; + } + + /** + * Exports all views in the workspace. + * + * @param workspace the workspace containing the views to be written + * @return a collection of diagram definitions, one per view + */ + public final Collection<Diagram> export(Workspace workspace) { + if (workspace == null) { + throw new IllegalArgumentException("A workspace must be provided."); + } + + Collection<Diagram> diagrams = new ArrayList<>(); + + for (CustomView view : workspace.getViews().getCustomViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (SystemLandscapeView view : workspace.getViews().getSystemLandscapeViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (SystemContextView view : workspace.getViews().getSystemContextViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (ContainerView view : workspace.getViews().getContainerViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (ComponentView view : workspace.getViews().getComponentViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (DynamicView view : workspace.getViews().getDynamicViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + for (DeploymentView view : workspace.getViews().getDeploymentViews()) { + Diagram diagram = export(view); + if (diagram != null) { + diagrams.add(diagram); + } + } + + return diagrams; + } + + public Diagram export(ModelView view) { + if (view instanceof SystemLandscapeView) { + return export((SystemLandscapeView)view); + } else if (view instanceof SystemContextView) { + return export((SystemContextView)view); + } else if (view instanceof ContainerView) { + return export((ContainerView)view); + } else if (view instanceof ComponentView) { + return export((ComponentView)view); + } else if (view instanceof DynamicView) { + return export((DynamicView)view); + } else if (view instanceof DeploymentView) { + return export((DeploymentView)view); + } else if (view instanceof CustomView) { + return export((CustomView)view); + } else { + throw new RuntimeException(view.getClass().getName() + " is not supported"); + } + } + + public Diagram export(CustomView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + private Diagram export(CustomView view, Integer animationStep) { + this.frame = animationStep; + + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + List<GroupableElement> elements = getGroupableElements(view, null); + writeElements(view, elements, writer); + + if (!elements.isEmpty()) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + public Diagram export(SystemLandscapeView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + private Diagram export(SystemLandscapeView view, Integer animationStep) { + this.frame = animationStep; + + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + List<GroupableElement> elements = getGroupableElements(view, null); + writeElements(view, elements, writer); + + if (!elements.isEmpty()) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + public Diagram export(SystemContextView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + private Diagram export(SystemContextView view, Integer animationStep) { + this.frame = animationStep; + + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + List<GroupableElement> elements = getGroupableElements(view, null); + writeElements(view, elements, writer); + + if (!elements.isEmpty()) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + public Diagram export(ContainerView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + public Diagram export(ContainerView view, Integer animationStep) { + this.frame = animationStep; + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + boolean elementsWritten = false; + for (ElementView elementView : view.getElements()) { + if (!(elementView.getElement() instanceof Container)) { + writeElement(view, elementView.getElement(), writer); + elementsWritten = true; + } + } + + if (elementsWritten) { + writer.writeLine(); + } + + List<SoftwareSystem> softwareSystems = getBoundarySoftwareSystems(view); + for (SoftwareSystem softwareSystem : softwareSystems) { + startSoftwareSystemBoundary(view, softwareSystem, writer); + + List<GroupableElement> scopedElements = getGroupableElements(view, softwareSystem); + writeElements(view, scopedElements, writer); + + endSoftwareSystemBoundary(view, writer); + } + + writeRelationships(view, writer); + + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + protected List<SoftwareSystem> getBoundarySoftwareSystems(ModelView view) { + List<SoftwareSystem> softwareSystems = new ArrayList<>(view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof Container).map(c -> ((Container)c).getSoftwareSystem()).collect(Collectors.toSet())); + softwareSystems.sort(Comparator.comparing(Element::getId)); + + return softwareSystems; + } + + public Diagram export(ComponentView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + public Diagram export(ComponentView view, Integer animationStep) { + this.frame = animationStep; + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + List<CustomElement> customElements = getCustomElements(view); + for (CustomElement customElement : customElements) { + writeElement(view, customElement, writer); + } + if (!customElements.isEmpty()) { + writer.writeLine(); + } + + List<Person> people = getPeople(view); + for (Person person : people) { + writeElement(view, person, writer); + } + if (!people.isEmpty()) { + writer.writeLine(); + } + + List<SoftwareSystem> softwareSystems = getSoftwareSystems(view); + for (SoftwareSystem softwareSystem : softwareSystems) { + writeElement(view, softwareSystem, writer); + } + if (!softwareSystems.isEmpty()) { + writer.writeLine(); + } + + List<Container> boundaryContainers = getBoundaryContainers(view); + Set<SoftwareSystem> boundarySoftwareSystems = boundaryContainers.stream().map(Container::getSoftwareSystem).collect(Collectors.toCollection(LinkedHashSet::new)); + for (SoftwareSystem softwareSystem : boundarySoftwareSystems) { + + startSoftwareSystemBoundary(view, softwareSystem, writer); + + for (Container container : boundaryContainers) { + if (container.getSoftwareSystem() == softwareSystem) { + startContainerBoundary(view, container, writer); + + List<GroupableElement> scopedElements = getGroupableElements(view, container); + writeElements(view, scopedElements, writer); + + endContainerBoundary(view, writer); + } + } + + for (Container container : getContainers(view)) { + if (container.getSoftwareSystem() == softwareSystem) { + writeElement(view, container, writer); + } + } + + endSoftwareSystemBoundary(view, writer); + } + + writeRelationships(view, writer); + + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + protected List<CustomElement> getCustomElements(ModelView view) { + return view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof CustomElement).map(c -> ((CustomElement) c)).distinct().sorted(Comparator.comparing(Element::getId)).collect(Collectors.toList()); + } + + protected List<Person> getPeople(ModelView view) { + return view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof Person).map(c -> ((Person) c)).distinct().sorted(Comparator.comparing(Element::getId)).collect(Collectors.toList()); + } + + protected List<SoftwareSystem> getSoftwareSystems(ModelView view) { + return view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof SoftwareSystem).map(c -> ((SoftwareSystem) c)).distinct().sorted(Comparator.comparing(Element::getId)).collect(Collectors.toList()); + } + + protected List<Container> getBoundaryContainers(ModelView view) { + return view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof Component).map(c -> ((Component) c).getContainer()).distinct().sorted(Comparator.comparing(Element::getId)).collect(Collectors.toList()); + } + + protected List<Container> getContainers(ModelView view) { + return view.getElements().stream().map(ElementView::getElement).filter(e -> e instanceof Container).map(c -> ((Container) c)).distinct().sorted(Comparator.comparing(Element::getId)).collect(Collectors.toList()); + } + + public Diagram export(DynamicView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view)) { + LinkedHashSet<String> orders = new LinkedHashSet<>(); + for (RelationshipView relationshipView : view.getRelationships()) { + orders.add(relationshipView.getOrder()); + } + + for (String order : orders) { + Diagram frame = export(view, order); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + public Diagram export(DynamicView view, String order) { + this.frame = order; + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + boolean elementsWritten = false; + + Element element = view.getElement(); + + if (element == null) { + // dynamic view with no scope + List<GroupableElement> elements = getGroupableElements(view, null); + writeElements(view, elements, writer); + + if (!elements.isEmpty()) { + elementsWritten = true; + } + } else { + if (element instanceof SoftwareSystem) { + // dynamic view with software system scope + List<SoftwareSystem> softwareSystems = getBoundarySoftwareSystems(view); + for (SoftwareSystem softwareSystem : softwareSystems) { + startSoftwareSystemBoundary(view, softwareSystem, writer); + + List<GroupableElement> scopedElements = getGroupableElements(view, softwareSystem); + writeElements(view, scopedElements, writer); + + endSoftwareSystemBoundary(view, writer); + } + + for (ElementView elementView : view.getElements()) { + if (elementView.getElement().getParent() == null) { + writeElement(view, elementView.getElement(), writer); + elementsWritten = true; + } + } + } else if (element instanceof Container) { + // dynamic view with container scope + List<CustomElement> customElements = getCustomElements(view); + for (CustomElement customElement : customElements) { + writeElement(view, customElement, writer); + } + if (!customElements.isEmpty()) { + writer.writeLine(); + } + + List<Person> people = getPeople(view); + for (Person person : people) { + writeElement(view, person, writer); + } + if (!people.isEmpty()) { + writer.writeLine(); + } + + List<SoftwareSystem> softwareSystems = getSoftwareSystems(view); + for (SoftwareSystem softwareSystem : softwareSystems) { + writeElement(view, softwareSystem, writer); + } + if (!softwareSystems.isEmpty()) { + writer.writeLine(); + } + + List<Container> boundaryContainers = getBoundaryContainers(view); + Set<SoftwareSystem> boundarySoftwareSystems = boundaryContainers.stream().map(Container::getSoftwareSystem).collect(Collectors.toCollection(LinkedHashSet::new)); + for (SoftwareSystem softwareSystem : boundarySoftwareSystems) { + + startSoftwareSystemBoundary(view, softwareSystem, writer); + + for (Container container : boundaryContainers) { + if (container.getSoftwareSystem() == softwareSystem) { + startContainerBoundary(view, container, writer); + + List<GroupableElement> scopedElements = getGroupableElements(view, container); + writeElements(view, scopedElements, writer); + + endContainerBoundary(view, writer); + } + } + + for (Container container : getContainers(view)) { + if (container.getSoftwareSystem() == softwareSystem) { + writeElement(view, container, writer); + } + } + + endSoftwareSystemBoundary(view, writer); + } + + for (ElementView elementView : view.getElements()) { + if (!(elementView.getElement().getParent() instanceof Container)) { + writeElement(view, elementView.getElement(), writer); + elementsWritten = true; + } + } + } + } + + if (elementsWritten) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + public Diagram export(DeploymentView view) { + Diagram diagram = export(view, null); + + if (isAnimationSupported(view) && !view.getAnimations().isEmpty()) { + for (Animation animation : view.getAnimations()) { + Diagram frame = export(view, animation.getOrder()); + diagram.addFrame(frame); + } + } + + diagram.setLegend(createLegend(view)); + return diagram; + } + + public Diagram export(DeploymentView view, Integer animationStep) { + this.frame = animationStep; + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + List<GroupableElement> elements = getGroupableElements(view, null); + writeElements(view, elements, writer); + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + protected List<String> findGroups(ModelView view, List<GroupableElement> elements) { + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + boolean nested = !StringUtils.isNullOrEmpty(groupSeparator); + + elements.sort(Comparator.comparing(Element::getId)); + + Set<String> groupsAsSet = new HashSet<>(); + for (GroupableElement element : elements) { + String group = element.getGroup(); + + if (!StringUtils.isNullOrEmpty(group)) { + groupsAsSet.add(group); + + if (nested) { + while (group.contains(groupSeparator)) { + group = group.substring(0, group.lastIndexOf(groupSeparator)); + groupsAsSet.add(group); + } + } + } + } + + List<String> groupsAsList = new ArrayList<>(groupsAsSet); + Collections.sort(groupsAsList); + + return groupsAsList; + } + + protected void writeElements(ModelView view, List<GroupableElement> elements, IndentingWriter writer) { + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + boolean nested = !StringUtils.isNullOrEmpty(groupSeparator); + List<String> groupsAsList = findGroups(view, elements); + + // first render grouped elements + if (!groupsAsList.isEmpty()) { + if (nested) { + String context = ""; + + for (String group : groupsAsList) { + int groupCount = group.split(Pattern.quote(groupSeparator)).length; + int contextCount = context.split(Pattern.quote(groupSeparator)).length; + + if (groupCount > contextCount) { + // moved from a to a/b + // - increase padding + writer.indent(); + } else if (groupCount == contextCount) { + // moved from a/b to a/c + // - close off previous subgraph + if (context.length() > 1) { + endGroupBoundary(view, writer); + } + } else { + // moved from a/b/c to a/b or a + // - close off previous subgraphs + // - close off current subgraph + for (int i = 0; i < (contextCount - groupCount); i++) { + endGroupBoundary(view, writer); + writer.outdent(); + } + endGroupBoundary(view, writer); + } + + startGroupBoundary(view, group, writer); + + for (GroupableElement element : elements) { + if (group.equals(element.getGroup())) { + write(view, element, writer); + } + } + + context = group; + } + + int contextCount = context.split(Pattern.quote(groupSeparator)).length; + for (int i = 0; i < contextCount; i++) { + endGroupBoundary(view, writer); + + if (i < contextCount-1) { + writer.outdent(); + } + } + } else { + for (String group : groupsAsList) { + startGroupBoundary(view, group, writer); + + for (GroupableElement element : elements) { + if (group.equals(element.getGroup())) { + write(view, element, writer); + } + } + + endGroupBoundary(view, writer); + } + } + } + + // then render ungrouped elements + for (GroupableElement element : elements) { + if (StringUtils.isNullOrEmpty(element.getGroup())) { + write(view, element, writer); + } + } + } + + protected Collection<RelationshipView> getRelationshipsInView(ModelView view) { + if (view instanceof DynamicView) { + return view.getRelationships(); + } else { + return view.getRelationships().stream().sorted(Comparator.comparing(rv -> rv.getRelationship().getId())).collect(Collectors.toList()); + } + } + + protected void writeRelationships(ModelView view, IndentingWriter writer) { + Collection<RelationshipView> relationshipList = getRelationshipsInView(view); + + for (RelationshipView relationshipView : relationshipList) { + writeRelationship(view, relationshipView, writer); + } + + if (!relationshipList.isEmpty()) { + writer.writeLine(); + } + } + + protected abstract void writeHeader(ModelView view, IndentingWriter writer); + protected abstract void writeFooter(ModelView view, IndentingWriter writer); + + protected abstract void startGroupBoundary(ModelView view, String group, IndentingWriter writer); + protected abstract void endGroupBoundary(ModelView view, IndentingWriter writer); + + protected abstract void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer); + protected abstract void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer); + + protected abstract void startContainerBoundary(ModelView view, Container container, IndentingWriter writer); + protected abstract void endContainerBoundary(ModelView view, IndentingWriter writer); + + protected abstract void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer); + protected abstract void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer); + + private void write(ModelView view, Element element, IndentingWriter writer) { + if (view instanceof DeploymentView && element instanceof DeploymentNode) { + writeDeploymentNode((DeploymentView)view, (DeploymentNode)element, writer); + } else { + writeElement(view, element, writer); + } + } + + private void writeDeploymentNode(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + startDeploymentNodeBoundary(view, deploymentNode, writer); + + List<GroupableElement> elements = new ArrayList<>(); + + List<DeploymentNode> children = new ArrayList<>(deploymentNode.getChildren()); + children.sort(Comparator.comparing(DeploymentNode::getName)); + for (DeploymentNode child : children) { + if (view.isElementInView(child)) { + elements.add(child); + } + } + + List<InfrastructureNode> infrastructureNodes = new ArrayList<>(deploymentNode.getInfrastructureNodes()); + infrastructureNodes.sort(Comparator.comparing(InfrastructureNode::getName)); + for (InfrastructureNode infrastructureNode : infrastructureNodes) { + if (view.isElementInView(infrastructureNode)) { + elements.add(infrastructureNode); + } + } + + List<SoftwareSystemInstance> softwareSystemInstances = new ArrayList<>(deploymentNode.getSoftwareSystemInstances()); + softwareSystemInstances.sort(Comparator.comparing(SoftwareSystemInstance::getName)); + for (SoftwareSystemInstance softwareSystemInstance : softwareSystemInstances) { + if (view.isElementInView(softwareSystemInstance)) { + elements.add(softwareSystemInstance); + } + } + + List<ContainerInstance> containerInstances = new ArrayList<>(deploymentNode.getContainerInstances()); + containerInstances.sort(Comparator.comparing(ContainerInstance::getName)); + for (ContainerInstance containerInstance : containerInstances) { + if (view.isElementInView(containerInstance)) { + elements.add(containerInstance); + } + } + + writeElements(view, elements, writer); + + endDeploymentNodeBoundary(view, writer); + } + + protected abstract void writeElement(ModelView view, Element element, IndentingWriter writer); + protected abstract void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer); + + protected boolean isAnimationSupported(ModelView view) { + return false; + } + + protected boolean isVisible(ModelView view, Element element) { + if (frame != null) { + Set<String> elementIds = new HashSet<>(); + + if (view instanceof StaticView) { + int step = (int)frame; + if (step > 0) { + StaticView staticView = (StaticView) view; + staticView.getAnimations().stream().filter(a -> a.getOrder() <= step).forEach(a -> { + elementIds.addAll(a.getElements()); + }); + + return elementIds.contains(element.getId()); + } + } else if (view instanceof DeploymentView) { + int step = (int)frame; + if (step > 0) { + DeploymentView deploymentView = (DeploymentView) view; + deploymentView.getAnimations().stream().filter(a -> a.getOrder() <= step).forEach(a -> { + elementIds.addAll(a.getElements()); + }); + + return elementIds.contains(element.getId()); + } + } else if (view instanceof DynamicView) { + String order = (String)frame; + view.getRelationships().stream().filter(rv -> order.equals(rv.getOrder())).forEach(rv -> { + elementIds.add(rv.getRelationship().getSourceId()); + elementIds.add(rv.getRelationship().getDestinationId()); + }); + + return elementIds.contains(element.getId()); + } + } + + return true; + } + + protected boolean isVisible(ModelView view, RelationshipView relationshipView) { + if (view instanceof DynamicView && frame != null) { + return frame.equals(relationshipView.getOrder()); + } + + return true; + } + + protected abstract Diagram createDiagram(ModelView view, String definition); + + protected Legend createLegend(ModelView view) { + return null; + } + + protected String getViewOrViewSetProperty(ModelView view, String name, String defaultValue) { + ViewSet views = view.getViewSet(); + + return + view.getProperties().getOrDefault(name, + views.getConfiguration().getProperties().getOrDefault(name, defaultValue) + ); + } + + @Override + protected ElementStyle findElementStyle(ModelView view, Element element) { + return view.getViewSet().getConfiguration().getStyles().findElementStyle(element, colorScheme); + } + + protected ElementStyle findElementStyle(ModelView view, String tag) { + return view.getViewSet().getConfiguration().getStyles().findElementStyle(tag, colorScheme); + } + + @Override + protected RelationshipStyle findRelationshipStyle(ModelView view, Relationship relationship) { + return view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship, colorScheme); + } + + protected List<GroupableElement> getGroupableElements(ModelView view, Element parent) { + List<GroupableElement> elements = new ArrayList<>(); + + if (view instanceof CustomView) { + for (ElementView elementView : view.getElements()) { + elements.add((CustomElement)elementView.getElement()); + } + } else if (view instanceof SystemLandscapeView) { + for (ElementView elementView : view.getElements()) { + elements.add((GroupableElement)elementView.getElement()); + } + } else if (view instanceof SystemContextView) { + for (ElementView elementView : view.getElements()) { + elements.add((GroupableElement)elementView.getElement()); + } + } else if (view instanceof ContainerView) { + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof Container) { + elements.add((StaticStructureElement) elementView.getElement()); + } + } + } else if (view instanceof ComponentView) { + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof Component) { + elements.add((StaticStructureElement) elementView.getElement()); + } + } + } else if (view instanceof DynamicView) { + DynamicView dynamicView = (DynamicView)view; + Element element = dynamicView.getElement(); + if (element == null) { + for (ElementView elementView : view.getElements()) { + elements.add((StaticStructureElement) elementView.getElement()); + } + } else if (element instanceof SoftwareSystem) { + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof Container) { + elements.add((StaticStructureElement) elementView.getElement()); + } + } + } else if (element instanceof Container) { + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof Component) { + elements.add((StaticStructureElement) elementView.getElement()); + } + } + } + } else if (view instanceof DeploymentView) { + for (ElementView elementView : view.getElements()) { + if (elementView.getElement() instanceof DeploymentNode && elementView.getElement().getParent() == null) { + elements.add((DeploymentNode)elementView.getElement()); + } + } + } + + if (parent != null) { + return elements.stream().filter(e -> e.getParent() == parent).collect(Collectors.toList()); + } else { + return elements; + } + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/AbstractExporter.java b/structurizr-export/src/main/java/com/structurizr/export/AbstractExporter.java new file mode 100644 index 000000000..d58d573d6 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/AbstractExporter.java @@ -0,0 +1,119 @@ +package com.structurizr.export; + +import com.structurizr.Workspace; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +public abstract class AbstractExporter { + + protected String breakText(int maxWidth, int fontSize, String s) { + if (StringUtils.isNullOrEmpty(s)) { + return ""; + } + + StringBuilder buf = new StringBuilder(); + + double characterWidth = fontSize * 0.6; + int maxCharacters = (int)(maxWidth / characterWidth); + + if (s.length() < maxCharacters) { + return s; + } + + String[] words = s.split(" "); + String line = null; + for (String word : words) { + if (line == null) { + line = word; + } else { + if ((line.length() + word.length() + 1) < maxCharacters) { + line += " "; + line += word; + } else { + buf.append(line); + buf.append("<br />"); + line = word; + } + } + } + + if (line != null) { + buf.append(line); + } + + return buf.toString(); + } + + protected String typeOf(Workspace workspace, Element e, boolean includeMetadataSymbols) { + return typeOf(workspace.getViews().getConfiguration(), e, includeMetadataSymbols); + } + + protected String typeOf(ModelView view, Element e, boolean includeMetadataSymbols) { + return typeOf(view.getViewSet().getConfiguration(), e, includeMetadataSymbols); + } + + private String typeOf(Configuration configuration, Element e, boolean includeMetadataSymbols) { + String type = ""; + + if (e instanceof Person) { + type = configuration.getTerminology().findTerminology(e); + } else if (e instanceof SoftwareSystem) { + type = configuration.getTerminology().findTerminology(e); + } else if (e instanceof Container) { + Container container = (Container)e; + type = configuration.getTerminology().findTerminology(e) + (hasValue(container.getTechnology()) ? ": " + container.getTechnology() : ""); + } else if (e instanceof Component) { + Component component = (Component)e; + type = configuration.getTerminology().findTerminology(e) + (hasValue(component.getTechnology()) ? ": " + component.getTechnology() : ""); + } else if (e instanceof DeploymentNode) { + DeploymentNode deploymentNode = (DeploymentNode)e; + type = configuration.getTerminology().findTerminology(e) + (hasValue(deploymentNode.getTechnology()) ? ": " + deploymentNode.getTechnology() : ""); + } else if (e instanceof InfrastructureNode) { + InfrastructureNode infrastructureNode = (InfrastructureNode)e; + type = configuration.getTerminology().findTerminology(e) + (hasValue(infrastructureNode.getTechnology()) ? ": " + infrastructureNode.getTechnology() : ""); + } else if (e instanceof CustomElement) { + type = ((CustomElement)e).getMetadata(); + } + + if (StringUtils.isNullOrEmpty(type)) { + return type; + } + + if (includeMetadataSymbols) { + if (configuration.getMetadataSymbols() == null) { + configuration.setMetadataSymbols(MetadataSymbols.SquareBrackets); + } + + switch (configuration.getMetadataSymbols()) { + case RoundBrackets: + return "(" + type + ")"; + case CurlyBrackets: + return "{" + type + "}"; + case AngleBrackets: + return "<" + type + ">"; + case DoubleAngleBrackets: + return "<<" + type + ">>"; + case None: + return type; + default: + return "[" + type + "]"; + } + } else { + return type; + } + } + + protected boolean hasValue(String s) { + return !StringUtils.isNullOrEmpty(s); + } + + protected ElementStyle findElementStyle(ModelView view, Element element) { + return view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + } + + protected RelationshipStyle findRelationshipStyle(ModelView view, Relationship relationship) { + return view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/AbstractWorkspaceExporter.java b/structurizr-export/src/main/java/com/structurizr/export/AbstractWorkspaceExporter.java new file mode 100644 index 000000000..6190149ea --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/AbstractWorkspaceExporter.java @@ -0,0 +1,4 @@ +package com.structurizr.export; + +public abstract class AbstractWorkspaceExporter extends AbstractExporter implements WorkspaceExporter { +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/Diagram.java b/structurizr-export/src/main/java/com/structurizr/export/Diagram.java new file mode 100644 index 000000000..271b7b925 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/Diagram.java @@ -0,0 +1,51 @@ +package com.structurizr.export; + +import com.structurizr.view.View; + +import java.util.ArrayList; +import java.util.List; + +public abstract class Diagram { + + private View view; + private String definition; + + private List<Diagram> frames = new ArrayList<>(); + private Legend legend; + + public Diagram(View view, String definition) { + this.view = view; + this.definition = definition; + } + + public String getKey() { + return view.getKey(); + } + + public View getView() { + return view; + } + + public String getDefinition() { + return definition; + } + + public void addFrame(Diagram frame) { + frames.add(frame); + } + + public List<Diagram> getFrames() { + return new ArrayList<>(frames); + } + + public Legend getLegend() { + return legend; + } + + public void setLegend(Legend legend) { + this.legend = legend; + } + + public abstract String getFileExtension(); + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/DiagramExporter.java b/structurizr-export/src/main/java/com/structurizr/export/DiagramExporter.java new file mode 100644 index 000000000..73a43ad5b --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/DiagramExporter.java @@ -0,0 +1,17 @@ +package com.structurizr.export; + +import com.structurizr.Workspace; + +import java.util.Collection; + +public interface DiagramExporter extends Exporter { + + /** + * Exports all views in the workspace. + * + * @param workspace the workspace containing the views to be written + * @return a collection of diagram definitions, one per view + */ + Collection<Diagram> export(Workspace workspace); + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/Exporter.java b/structurizr-export/src/main/java/com/structurizr/export/Exporter.java new file mode 100644 index 000000000..45bf1b263 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/Exporter.java @@ -0,0 +1,4 @@ +package com.structurizr.export; + +public interface Exporter { +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/IndentType.java b/structurizr-export/src/main/java/com/structurizr/export/IndentType.java new file mode 100644 index 000000000..16534cb5c --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/IndentType.java @@ -0,0 +1,8 @@ +package com.structurizr.export; + +public enum IndentType { + + Spaces, + Tabs + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/IndentingWriter.java b/structurizr-export/src/main/java/com/structurizr/export/IndentingWriter.java new file mode 100644 index 000000000..214fb00e2 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/IndentingWriter.java @@ -0,0 +1,69 @@ +package com.structurizr.export; + +public final class IndentingWriter { + + private int indent = 0; + private IndentType indentType = IndentType.Spaces; + private int indentQuantity = 2; + + private final StringBuilder buf = new StringBuilder(); + + public IndentingWriter() { + } + + public void setIndentType(IndentType indentType) { + this.indentType = indentType; + } + + public void setIndentQuantity(int indentQuantity) { + this.indentQuantity = indentQuantity; + } + + public void indent() { + indent++; + } + + public void outdent() { + indent--; + } + + private String padding() { + StringBuilder buf = new StringBuilder(); + + for (int i = 0; i < indent * indentQuantity; i++) { + if (indentType == IndentType.Spaces) { + buf.append(" "); + } else { + buf.append("\t"); + } + } + + return buf.toString(); + } + + public void writeLine() { + buf.append("\n"); + } + + public void writeLine(String content) { + buf.append(String.format("%s%s\n", padding(), content.replace("\n", "\\n"))); + } + + public void replace(String before, String after) { + int start = buf.indexOf(before); + int end = start + before.length(); + + buf.replace(start, end, after); + } + + @Override + public String toString() { + String s = buf.toString(); + if (s.endsWith("\n")) { + s = s.substring(0, s.length()-1); + } + + return s; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/Legend.java b/structurizr-export/src/main/java/com/structurizr/export/Legend.java new file mode 100644 index 000000000..49e552381 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/Legend.java @@ -0,0 +1,15 @@ +package com.structurizr.export; + +public final class Legend { + + private final String definition; + + public Legend(String definition) { + this.definition = definition; + } + + public String getDefinition() { + return definition; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExport.java b/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExport.java new file mode 100644 index 000000000..1f25745fd --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExport.java @@ -0,0 +1,17 @@ +package com.structurizr.export; + +public abstract class WorkspaceExport { + + private String definition; + + public WorkspaceExport(String definition) { + this.definition = definition; + } + + public String getDefinition() { + return definition; + } + + public abstract String getFileExtension(); + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExporter.java b/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExporter.java new file mode 100644 index 000000000..bd59cb3a5 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/WorkspaceExporter.java @@ -0,0 +1,15 @@ +package com.structurizr.export; + +import com.structurizr.Workspace; + +public interface WorkspaceExporter extends Exporter { + + /** + * Exports the entire workspace to a single String. + * + * @param workspace the workspace to be exported + * @return a String export of the workspace + */ + WorkspaceExport export(Workspace workspace); + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/dot/DOTDiagram.java b/structurizr-export/src/main/java/com/structurizr/export/dot/DOTDiagram.java new file mode 100644 index 000000000..a66af0ea9 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/dot/DOTDiagram.java @@ -0,0 +1,17 @@ +package com.structurizr.export.dot; + +import com.structurizr.export.Diagram; +import com.structurizr.view.ModelView; + +public class DOTDiagram extends Diagram { + + public DOTDiagram(ModelView view, String definition) { + super(view, definition); + } + + @Override + public String getFileExtension() { + return "dot"; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/dot/DOTExporter.java b/structurizr-export/src/main/java/com/structurizr/export/dot/DOTExporter.java new file mode 100644 index 000000000..708ea4b46 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/dot/DOTExporter.java @@ -0,0 +1,423 @@ +package com.structurizr.export.dot; + +import com.structurizr.export.AbstractDiagramExporter; +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +/** + * Exports Structurizr views to Graphviz DOT definitions. + */ +public class DOTExporter extends AbstractDiagramExporter { + + private static final int DEFAULT_WIDTH = 450; + private static final int DEFAULT_HEIGHT = 300; + private static final String DEFAULT_FONT = "Arial"; + + private int clusterInternalMargin = 25; + + public DOTExporter() { + } + + public void setClusterInternalMargin(int clusterInternalMargin) { + this.clusterInternalMargin = clusterInternalMargin; + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + String title = view.getTitle(); + if (StringUtils.isNullOrEmpty(title)) { + title = view.getName(); + } + + String description = view.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + description = ""; + } else { + description = String.format("<br /><font point-size=\"24\">%s</font>", description); + } + + String fontName = DEFAULT_FONT; + Font font = view.getViewSet().getConfiguration().getBranding().getFont(); + if (font != null) { + fontName = font.getName(); + } + + RankDirection rankDirection = RankDirection.TopBottom; + + if (view.getAutomaticLayout() != null) { + switch (view.getAutomaticLayout().getRankDirection()) { + case TopBottom: + rankDirection = RankDirection.TopBottom; + break; + case BottomTop: + rankDirection = RankDirection.BottomTop; + break; + case LeftRight: + rankDirection = RankDirection.LeftRight; + break; + case RightLeft: + rankDirection = RankDirection.RightLeft; + break; + } + } + + writer.writeLine("digraph {"); + writer.indent(); + writer.writeLine("compound=true"); + writer.writeLine(String.format("graph [fontname=\"%s\", rankdir=%s, ranksep=1.0, nodesep=1.0]", fontName, rankDirection.getCode())); + writer.writeLine(String.format("node [fontname=\"%s\", shape=box, margin=\"0.4,0.3\"]", fontName)); + writer.writeLine(String.format("edge [fontname=\"%s\"]", fontName)); + writer.writeLine(String.format("label=<<br /><font point-size=\"34\">%s</font>%s>", title, description)); + writer.writeLine(); + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + String color = "#cccccc"; + + String groupName = group; + + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + if (!StringUtils.isNullOrEmpty(groupSeparator)) { + groupName = group.substring(group.lastIndexOf(groupSeparator) + groupSeparator.length()); + } + + // is there a style for the group? + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group:" + group); + + if (elementStyle == null || StringUtils.isNullOrEmpty(elementStyle.getColor())) { + // no, so is there a default group style? + elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group"); + } + + if (elementStyle != null && !StringUtils.isNullOrEmpty(elementStyle.getColor())) { + color = elementStyle.getColor(); + } + + writer.writeLine("subgraph \"cluster_group_" + groupName + "\" {"); + + writer.indent(); + writer.writeLine("margin=" + clusterInternalMargin); + writer.writeLine(String.format("label=<<font point-size=\"24\"><br />%s</font>>", groupName)); + writer.writeLine("labelloc=b"); + writer.writeLine(String.format("color=\"%s\"", color)); + writer.writeLine(String.format("fontcolor=\"%s\"", color)); + writer.writeLine("fillcolor=\"#ffffff\""); + writer.writeLine("style=\"dashed\""); + writer.writeLine(); + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + String color; + if (softwareSystem.equals(view.getSoftwareSystem())) { + color = "#444444"; + } else { + color = "#cccccc"; + } + + writer.writeLine(String.format("subgraph cluster_%s {", softwareSystem.getId())); + writer.indent(); + writer.writeLine("margin=" + clusterInternalMargin); + writer.writeLine(String.format("label=<<font point-size=\"24\"><br />%s</font><br /><font point-size=\"19\">%s</font>>", softwareSystem.getName(), typeOf(view, softwareSystem, true))); + writer.writeLine("labelloc=b"); + writer.writeLine(String.format("color=\"%s\"", color)); + writer.writeLine(String.format("fontcolor=\"%s\"", color)); + writer.writeLine(String.format("fillcolor=\"%s\"", color)); + writer.writeLine(); + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + String color = "#444444"; + if (view instanceof ComponentView) { + if (container.equals(((ComponentView)view).getContainer())) { + color = "#444444"; + } else { + color = "#cccccc"; + } + } else if (view instanceof DynamicView) { + if (container.equals(((DynamicView)view).getElement())) { + color = "#444444"; + } else { + color = "#cccccc"; + } + } + + writer.writeLine(String.format("subgraph cluster_%s {", container.getId())); + writer.indent(); + writer.writeLine("margin=" + clusterInternalMargin); + writer.writeLine(String.format("label=<<font point-size=\"24\"><br />%s</font><br /><font point-size=\"19\">%s</font>>", container.getName(), typeOf(view, container, true))); + writer.writeLine("labelloc=b"); + writer.writeLine(String.format("color=\"%s\"", color)); + writer.writeLine(String.format("fontcolor=\"%s\"", color)); + writer.writeLine(String.format("fillcolor=\"%s\"", color)); + writer.writeLine(); + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(deploymentNode); + + writer.writeLine(String.format("subgraph cluster_%s {", deploymentNode.getId())); + writer.indent(); + writer.writeLine("margin=" + clusterInternalMargin); + writer.writeLine(String.format("label=<<font point-size=\"24\">%s</font><br /><font point-size=\"19\">%s</font>>", deploymentNode.getName(), typeOf(view, deploymentNode, true))); + writer.writeLine("labelloc=b"); + writer.writeLine(String.format("color=\"%s\"", elementStyle.getStroke())); + writer.writeLine(String.format("fontcolor=\"%s\"", elementStyle.getColor())); + writer.writeLine("fillcolor=\"#ffffff\""); + writer.writeLine(); + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + + if (elementStyle.getWidth() == null) { + elementStyle.setWidth(DEFAULT_WIDTH); + } + + if (elementStyle.getHeight() == null) { + elementStyle.setHeight(DEFAULT_HEIGHT); + } + + int nameFontSize = elementStyle.getFontSize() + 10; + int metadataFontSize = elementStyle.getFontSize() - 5; + int descriptionFontSize = elementStyle.getFontSize(); + + + String shape = shapeOf(view, element); + String name = element.getName(); + String description = element.getDescription(); + String type = typeOf(view, element, true); + + if (element instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)element; + name = elementInstance.getElement().getName(); + description = elementInstance.getElement().getDescription(); + type = typeOf(view, elementInstance.getElement(), true); + shape = shapeOf(view, elementInstance.getElement()); + } + + if (StringUtils.isNullOrEmpty(name)) { + name = ""; + } else { + name = String.format("<font point-size=\"%s\">%s</font>", nameFontSize, breakText(elementStyle.getWidth(), nameFontSize, escape(name))); + } + + if (StringUtils.isNullOrEmpty(description) || false == elementStyle.getDescription()) { + description = ""; + } else { + description = String.format("<br /><br /><font point-size=\"%s\">%s</font>", descriptionFontSize, breakText(elementStyle.getWidth(), descriptionFontSize, escape(description))); + } + + if (StringUtils.isNullOrEmpty(type) || false == elementStyle.getMetadata()) { + type = ""; + } else { + type = String.format("<br /><font point-size=\"%s\">%s</font>", metadataFontSize, type); + } + + writer.writeLine(String.format("%s [id=%s,shape=%s, label=<%s%s%s>, style=filled, color=\"%s\", fillcolor=\"%s\", fontcolor=\"%s\"]", + element.getId(), + element.getId(), + shape, + name, + type, + description, + elementStyle.getStroke(), + elementStyle.getBackground(), + elementStyle.getColor() + )); + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + Element source; + Element destination; + + RelationshipStyle relationshipStyle = view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationshipView.getRelationship()); + relationshipStyle.setWidth(400); + int descriptionFontSize = relationshipStyle.getFontSize(); + int metadataFontSize = relationshipStyle.getFontSize() - 5; + + String description = relationshipView.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + description = relationshipView.getRelationship().getDescription(); + } + + if (!StringUtils.isNullOrEmpty(relationshipView.getOrder())) { + description = relationshipView.getOrder() + ". " + description; + } + + if (StringUtils.isNullOrEmpty(description)) { + description = ""; + } else { + description = breakText(relationshipStyle.getWidth(), descriptionFontSize, description); + description = String.format("<font point-size=\"%s\">%s</font>", descriptionFontSize, description); + } + + String technology = relationshipView.getRelationship().getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } else { + technology = String.format("<br /><font point-size=\"%s\">[%s]</font>", metadataFontSize, technology); + } + + String clusterConfig = ""; + + if (relationshipView.getRelationship().getSource() instanceof DeploymentNode || relationshipView.getRelationship().getDestination() instanceof DeploymentNode) { + source = relationshipView.getRelationship().getSource(); + if (source instanceof DeploymentNode) { + source = findElementInside((DeploymentNode)source, view); + } + + destination = relationshipView.getRelationship().getDestination(); + if (destination instanceof DeploymentNode) { + destination = findElementInside((DeploymentNode)destination, view); + } + + if (source != null && destination != null) { + + if (relationshipView.getRelationship().getSource() instanceof DeploymentNode) { + clusterConfig += ",ltail=cluster_" + relationshipView.getRelationship().getSource().getId(); + } + + if (relationshipView.getRelationship().getDestination() instanceof DeploymentNode) { + clusterConfig += ",lhead=cluster_" + relationshipView.getRelationship().getDestination().getId(); + } + } + } else { + source = relationshipView.getRelationship().getSource(); + destination = relationshipView.getRelationship().getDestination(); + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + source = relationshipView.getRelationship().getDestination(); + destination = relationshipView.getRelationship().getSource(); + } + } + + boolean solid = relationshipStyle.getStyle() == LineStyle.Solid || false == relationshipStyle.getDashed(); + + writer.writeLine(String.format("%s -> %s [id=%s, label=<%s%s>, style=\"%s\", color=\"%s\", fontcolor=\"%s\"%s]", + source.getId(), + destination.getId(), + relationshipView.getId(), + description, + technology, + solid ? "solid" : "dashed", + relationshipStyle.getColor(), + relationshipStyle.getColor(), + clusterConfig + )); + } + + private String escape(String s) { + if (StringUtils.isNullOrEmpty(s)) { + return s; + } else { + return s.replaceAll("\"", "\\\\\""); + } + } + + private String shapeOf(ModelView view, Element element) { + if (element instanceof DeploymentNode) { + return "node"; + } + + Shape shape = view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getShape(); + switch(shape) { + case Circle: + return "circle"; + case Component: + return "component"; + case Cylinder: + return "cylinder"; + case Ellipse: + return "ellipse"; + case Folder: + return "folder"; + case Hexagon: + return "hexagon"; + case Diamond: + return "diamond"; + default: + return "rect"; + } + } + + private Element findElementInside(DeploymentNode deploymentNode, ModelView view) { + for (SoftwareSystemInstance softwareSystemInstance : deploymentNode.getSoftwareSystemInstances()) { + if (view.isElementInView(softwareSystemInstance)) { + return softwareSystemInstance; + } + } + + for (ContainerInstance containerInstance : deploymentNode.getContainerInstances()) { + if (view.isElementInView(containerInstance)) { + return containerInstance; + } + } + + for (InfrastructureNode infrastructureNode : deploymentNode.getInfrastructureNodes()) { + if (view.isElementInView(infrastructureNode)) { + return infrastructureNode; + } + } + + if (deploymentNode.hasChildren()) { + for (DeploymentNode child : deploymentNode.getChildren()) { + Element element = findElementInside(child, view); + + if (element != null) { + return element; + } + } + } + + return null; + } + + @Override + protected Diagram createDiagram(ModelView view, String definition) { + return new DOTDiagram(view, definition); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/dot/README.md b/structurizr-export/src/main/java/com/structurizr/export/dot/README.md new file mode 100644 index 000000000..f528413dc --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/dot/README.md @@ -0,0 +1,6 @@ +# DOT (Graphviz) + +The [DOTExporter](DOTExporter.java) class provides a way to export views to +diagram definitions that are compatible with [Graphviz](https://graphviz.org). + +See https://docs.structurizr.com/export for more. \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/dot/RankDirection.java b/structurizr-export/src/main/java/com/structurizr/export/dot/RankDirection.java new file mode 100644 index 000000000..e79fef327 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/dot/RankDirection.java @@ -0,0 +1,23 @@ +package com.structurizr.export.dot; + +/** + * The various rank directions used by Graphviz. + */ +enum RankDirection { + + TopBottom("TB"), + BottomTop("BT"), + LeftRight("LR"), + RightLeft("RL"); + + private String code; + + RankDirection(String code) { + this.code = code; + } + + String getCode() { + return code; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographExporter.java b/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographExporter.java new file mode 100644 index 000000000..9a80784e1 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographExporter.java @@ -0,0 +1,437 @@ +package com.structurizr.export.ilograph; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractWorkspaceExporter; +import com.structurizr.export.IndentingWriter; +import com.structurizr.export.WorkspaceExport; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Exports a Structurizr workspace to the Ilograph definition language, for use with https://app.ilograph.com/ + */ +public class IlographExporter extends AbstractWorkspaceExporter { + + public static final String ILOGRAPH_IMPORTS = "ilograph.imports"; + public static final String ILOGRAPH_ICON = "ilograph.icon"; + + public WorkspaceExport export(Workspace workspace) { + IndentingWriter writer = new IndentingWriter(); + + // Ilograph imports can be specified in the form: + // + // AWS:ilograph/aws + // + // Which gets exported as: + // + // imports: + // - from: ilograph/aws + // namespace: AWS + String commaSeparatedListOfImports = workspace.getProperties().get(ILOGRAPH_IMPORTS); + if (!StringUtils.isNullOrEmpty(commaSeparatedListOfImports)) { + writer.writeLine("imports:"); + + String[] ilographImports = commaSeparatedListOfImports.split(","); + for (String ilographImport : ilographImports) { + String[] parts = ilographImport.split(":"); + if (parts.length == 2) { + String namespace = parts[0]; + String from = parts[1]; + + writer.writeLine("- from: " + from); + writer.indent(); + writer.writeLine("namespace: " + namespace); + writer.outdent(); + } + } + writer.writeLine(); + } + + writer.writeLine("resources:"); + writer.indent(); + + Model model = workspace.getModel(); + List<GroupableElement> elements = new ArrayList<>(); + + List<CustomElement> customElements = new ArrayList<>(model.getCustomElements()); + customElements.sort(Comparator.comparing(CustomElement::getId)); + for (CustomElement customElement : customElements) { + writeElement(writer, workspace, customElement); + elements.add(customElement); + } + + List<Person> people = new ArrayList<>(model.getPeople()); + people.sort(Comparator.comparing(Person::getId)); + for (Person person : people) { + writeElement(writer, workspace, person); + elements.add(person); + } + + List<SoftwareSystem> softwareSystems = new ArrayList<>(model.getSoftwareSystems()); + softwareSystems.sort(Comparator.comparing(SoftwareSystem::getId)); + for (SoftwareSystem softwareSystem : softwareSystems) { + writeElement(writer, workspace, softwareSystem); + elements.add(softwareSystem); + + if (!softwareSystem.getContainers().isEmpty()) { + writer.indent(); + writer.writeLine("children:"); + writer.indent(); + + List<Container> containers = new ArrayList<>(softwareSystem.getContainers()); + containers.sort(Comparator.comparing(Container::getId)); + for (Container container : containers) { + writeElement(writer, workspace, container); + elements.add(container); + + if (!container.getComponents().isEmpty()) { + writer.indent(); + writer.writeLine("children:"); + writer.indent(); + + List<Component> components = new ArrayList<>(container.getComponents()); + components.sort(Comparator.comparing(Component::getId)); + for (Component component : components) { + writeElement(writer, workspace, component); + elements.add(component); + } + + writer.outdent(); + writer.outdent(); + } + + } + + writer.outdent(); + writer.outdent(); + } + } + + List<DeploymentNode> deploymentNodes = new ArrayList<>(model.getDeploymentNodes()); + deploymentNodes.sort(Comparator.comparing(DeploymentNode::getId)); + for (DeploymentNode deploymentNode : deploymentNodes) { + writeDeploymentNode(workspace, deploymentNode, writer); + } + + Set<Relationship> relationships = new LinkedHashSet<>(); + Set<Class> elementTypes = new HashSet<>(); + + elementTypes.add(CustomElement.class); + elementTypes.add(Person.class); + elementTypes.add(SoftwareSystem.class); + for (GroupableElement element : elements) { + List<Relationship> sortedRelationships = new ArrayList<>(element.getRelationships()); + sortedRelationships.sort(Comparator.comparing(Relationship::getId)); + for (Relationship relationship : sortedRelationships) { + if (include(relationship, elementTypes)) { + relationships.add(relationship); + } + } + } + + elementTypes.add(Container.class); + for (GroupableElement element : elements) { + List<Relationship> sortedRelationships = new ArrayList<>(element.getRelationships()); + sortedRelationships.sort(Comparator.comparing(Relationship::getId)); + for (Relationship relationship : sortedRelationships) { + if (include(relationship, elementTypes)) { + relationships.add(relationship); + } + } + } + + elementTypes.add(Component.class); + for (GroupableElement element : elements) { + List<Relationship> sortedRelationships = new ArrayList<>(element.getRelationships()); + sortedRelationships.sort(Comparator.comparing(Relationship::getId)); + for (Relationship relationship : sortedRelationships) { + if (include(relationship, elementTypes)) { + relationships.add(relationship); + } + } + } + + writer.outdent(); + + writeRelationshipsForStaticStructurePerspective(workspace.getViews().getConfiguration(), relationships, writer); + + for (DynamicView dynamicView : workspace.getViews().getDynamicViews()) { + writeDynamicView(dynamicView, writer); + } + + Set<String> deploymentEnvironments = new HashSet<>(); + for (DeploymentNode deploymentNode : model.getDeploymentNodes()) { + deploymentEnvironments.add(deploymentNode.getEnvironment()); + } + List<String> sortedDeploymentEnvironments = new ArrayList<>(deploymentEnvironments); + sortedDeploymentEnvironments.sort(Comparator.comparing(String::toString)); + for (String deploymentEnvironment : sortedDeploymentEnvironments) { + writeDeploymentEnvironment(workspace, deploymentEnvironment, writer); + } + + return new IlographWorkspaceExport(writer.toString()); + } + + private void writeDeploymentNode(Workspace workspace, DeploymentNode deploymentNode, IndentingWriter writer) { + writeElement(writer, workspace, deploymentNode); + + boolean hasChildren = !deploymentNode.getChildren().isEmpty() || !deploymentNode.getInfrastructureNodes().isEmpty() || !deploymentNode.getSoftwareSystemInstances().isEmpty() || !deploymentNode.getContainerInstances().isEmpty(); + + if (hasChildren) { + writer.indent(); + writer.writeLine("children:"); + writer.indent(); + } + + List<DeploymentNode> deploymentNodes = new ArrayList<>(deploymentNode.getChildren()); + deploymentNodes.sort(Comparator.comparing(DeploymentNode::getId)); + for (DeploymentNode child : deploymentNodes) { + writeDeploymentNode(workspace, child, writer); + } + + List<InfrastructureNode> infrastructureNodes = new ArrayList<>(deploymentNode.getInfrastructureNodes()); + infrastructureNodes.sort(Comparator.comparing(InfrastructureNode::getId)); + for (InfrastructureNode infrastructureNode : infrastructureNodes) { + writeElement(writer, workspace, infrastructureNode); + } + + List<SoftwareSystemInstance> softwareSystemInstances = new ArrayList<>(deploymentNode.getSoftwareSystemInstances()); + softwareSystemInstances.sort(Comparator.comparing(SoftwareSystemInstance::getId)); + for (SoftwareSystemInstance softwareSystemInstance : softwareSystemInstances) { + writeElement(writer, workspace, softwareSystemInstance); + } + + List<ContainerInstance> containerInstances = new ArrayList<>(deploymentNode.getContainerInstances()); + containerInstances.sort(Comparator.comparing(ContainerInstance::getId)); + for (ContainerInstance containerInstance : containerInstances) { + writeElement(writer, workspace, containerInstance); + } + + writer.outdent(); + writer.outdent(); + } + + private void writeElement(IndentingWriter writer, Workspace workspace, Element element) { + writer.writeLine(String.format("- id: \"%s\"", element.getId())); + + String name; + String type; + String description; + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().findElementStyle(element); + + if (element instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)element; + name = elementInstance.getElement().getName(); + type = typeOf(workspace, elementInstance.getElement(), true); + description = elementInstance.getElement().getDescription(); + } else { + name = element.getName(); + type = typeOf(workspace, element, true); + description = element.getDescription(); + } + + writer.indent(); + writer.writeLine(String.format("name: \"%s\"", name)); + writer.writeLine(String.format("subtitle: \"%s\"", type)); + + if (!StringUtils.isNullOrEmpty(description)) { + writer.writeLine(String.format("description: \"%s\"", description)); + } + + if (element instanceof DeploymentNode) { + writer.writeLine(String.format("backgroundColor: \"%s\"", "#ffffff")); + } else { + writer.writeLine(String.format("backgroundColor: \"%s\"", elementStyle.getBackground())); + } + writer.writeLine(String.format("color: \"%s\"", elementStyle.getColor())); + + String icon = elementStyle.getProperties().get(ILOGRAPH_ICON); + if (StringUtils.isNullOrEmpty(icon)) { + icon = elementStyle.getIcon(); + } + if (!StringUtils.isNullOrEmpty(icon)) { + writer.writeLine(String.format("icon: \"%s\"", icon)); + } + + writer.writeLine(); + writer.outdent(); + } + + private void writeRelationshipsForStaticStructurePerspective(Configuration configuration, Collection<Relationship> relationships, IndentingWriter writer) { + writer.writeLine("perspectives:"); + writer.indent(); + writer.writeLine("- name: Static Structure"); + writer.indent(); + writer.writeLine("relations:"); + writer.indent(); + + for (Relationship relationship : relationships) { + RelationshipStyle relationshipStyle = configuration.getStyles().findRelationshipStyle(relationship); + + writer.writeLine(String.format("- from: \"%s\"", relationship.getSourceId())); + writer.indent(); + writer.writeLine(String.format("to: \"%s\"", relationship.getDestinationId())); + + if (!StringUtils.isNullOrEmpty(relationship.getDescription())) { + writer.writeLine(String.format("label: \"%s\"", relationship.getDescription())); + } + + if (!StringUtils.isNullOrEmpty(relationship.getTechnology())) { + writer.writeLine(String.format("description: \"%s\"", relationship.getTechnology())); + } + + if (!StringUtils.isNullOrEmpty(relationshipStyle.getColor())) { + writer.writeLine(String.format("color: \"%s\"", relationshipStyle.getColor())); + } + + writer.writeLine(); + writer.outdent(); + } + + writer.outdent(); + writer.outdent(); + writer.outdent(); + } + + private void writeDynamicView(DynamicView dynamicView, IndentingWriter writer) { + String scope = dynamicView.getName(); + scope = scope.substring("Dynamic View".length()); + if (scope.startsWith(": ")) { + scope = scope.substring(2); + } + writer.indent(); + writer.writeLine("- name: Dynamic: " + scope); + writer.indent(); + writer.writeLine("sequence:"); + + int count = 0; + for (RelationshipView relationshipView : dynamicView.getRelationships()) { + Relationship relationship = relationshipView.getRelationship(); + RelationshipStyle relationshipStyle = dynamicView.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship); + + if (count == 0) { + writer.indent(); + writer.writeLine(String.format("start: \"%s\"", relationship.getSourceId())); + writer.writeLine("steps:"); + writer.writeLine(String.format("- to: \"%s\"", relationship.getDestinationId())); + } else { + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + writer.writeLine(String.format("- to: \"%s\"", relationship.getSourceId())); + } else { + writer.writeLine(String.format("- to: \"%s\"", relationship.getDestinationId())); + } + } + + writer.indent(); + if (!StringUtils.isNullOrEmpty(relationshipView.getDescription())) { + writer.writeLine(String.format("label: \"%s. %s\"", relationshipView.getOrder(), relationshipView.getDescription())); + } else if (!StringUtils.isNullOrEmpty(relationship.getDescription())) { + writer.writeLine(String.format("label: \"%s. %s\"", relationshipView.getOrder(), relationship.getDescription())); + } + + if (!StringUtils.isNullOrEmpty(relationship.getTechnology())) { + writer.writeLine(String.format("description: \"%s\"", relationship.getTechnology())); + } + + if (!StringUtils.isNullOrEmpty(relationshipStyle.getColor())) { + writer.writeLine(String.format("color: \"%s\"", relationshipStyle.getColor())); + } + + writer.outdent(); + + writer.writeLine(); + + count++; + } + + writer.outdent(); + writer.outdent(); + writer.outdent(); + } + + private void writeDeploymentEnvironment(Workspace workspace, String deploymentEnvironment, IndentingWriter writer) { + writer.indent(); + writer.writeLine("- name: Deployment - " + deploymentEnvironment); + writer.indent(); + writer.writeLine("relations:"); + + List<DeploymentNode> topLevelDeploymentNodes = workspace.getModel().getDeploymentNodes().stream().filter(dn -> dn.getEnvironment().equals(deploymentEnvironment)).sorted(Comparator.comparing(DeploymentNode::getId)).collect(Collectors.toList()); + List<Element> deploymentElementsInEnvironment = new ArrayList<>(topLevelDeploymentNodes); + for (DeploymentNode deploymentNode : topLevelDeploymentNodes) { + deploymentElementsInEnvironment.addAll(findAllChildren(deploymentNode)); + } + + Collection<Relationship> relationships = findRelationships(deploymentElementsInEnvironment); + writer.indent(); + + for (Relationship relationship : relationships) { + RelationshipStyle relationshipStyle = workspace.getViews().getConfiguration().getStyles().findRelationshipStyle(relationship); + + writer.writeLine(String.format("- from: \"%s\"", relationship.getSourceId())); + writer.indent(); + writer.writeLine(String.format("to: \"%s\"", relationship.getDestinationId())); + + if (!StringUtils.isNullOrEmpty(relationship.getDescription())) { + writer.writeLine(String.format("label: \"%s\"", relationship.getDescription())); + } + + if (!StringUtils.isNullOrEmpty(relationship.getTechnology())) { + writer.writeLine(String.format("description: \"%s\"", relationship.getTechnology())); + } + + if (!StringUtils.isNullOrEmpty(relationshipStyle.getColor())) { + writer.writeLine(String.format("color: \"%s\"", relationshipStyle.getColor())); + } + + writer.outdent(); + } + + writer.outdent(); + writer.outdent(); + writer.outdent(); + } + + private Collection<Element> findAllChildren(DeploymentNode deploymentNode) { + List<Element> deploymentElements = new ArrayList<>(); + + List<DeploymentNode> deploymentNodes = new ArrayList<>(deploymentNode.getChildren()); + deploymentNodes.sort(Comparator.comparing(DeploymentNode::getId)); + for (DeploymentNode child : deploymentNodes) { + deploymentElements.addAll(findAllChildren(child)); + } + + deploymentElements.addAll(deploymentNode.getSoftwareSystemInstances().stream().sorted(Comparator.comparing(SoftwareSystemInstance::getId)).collect(Collectors.toList())); + deploymentElements.addAll(deploymentNode.getContainerInstances().stream().sorted(Comparator.comparing(ContainerInstance::getId)).collect(Collectors.toList())); + deploymentElements.addAll(deploymentNode.getInfrastructureNodes().stream().sorted(Comparator.comparing(InfrastructureNode::getId)).collect(Collectors.toList())); + + return deploymentElements; + } + + private Collection<Relationship> findRelationships(Collection<Element> elements) { + List<Relationship> relationships = new ArrayList<>(); + + for (Element element : elements) { + List<Relationship> sortedRelationships = new ArrayList<>(element.getRelationships()); + sortedRelationships.sort(Comparator.comparing(Relationship::getId)); + for (Relationship relationship : sortedRelationships) { + if (elements.contains(relationship.getSource()) && elements.contains(relationship.getDestination())) { + relationships.add(relationship); + } + } + } + + return relationships; + } + + private boolean include(Relationship relationship, Set<Class> elementTypes) { + Element source = relationship.getSource(); + Element destination = relationship.getDestination(); + + return elementTypes.contains(source.getClass()) && elementTypes.contains(destination.getClass()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographWorkspaceExport.java b/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographWorkspaceExport.java new file mode 100644 index 000000000..a1ae4942c --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/ilograph/IlographWorkspaceExport.java @@ -0,0 +1,16 @@ +package com.structurizr.export.ilograph; + +import com.structurizr.export.WorkspaceExport; + +public class IlographWorkspaceExport extends WorkspaceExport { + + public IlographWorkspaceExport(String definition) { + super(definition); + } + + @Override + public String getFileExtension() { + return "idl"; + } + +} diff --git a/structurizr-export/src/main/java/com/structurizr/export/ilograph/README.md b/structurizr-export/src/main/java/com/structurizr/export/ilograph/README.md new file mode 100644 index 000000000..22ddfa09b --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/ilograph/README.md @@ -0,0 +1,7 @@ +# Ilograph + +The [IlographExporter](IlographExporter.java) class provides a way to export the software architecture model +to the YAML format used by [Ilograph](https://www.ilograph.com), which provides an interactive way to explore +a hierarchical dataset (which the C4 model is). + +See https://docs.structurizr.com/export for more. \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagram.java b/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagram.java new file mode 100644 index 000000000..9863eef19 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagram.java @@ -0,0 +1,17 @@ +package com.structurizr.export.mermaid; + +import com.structurizr.export.Diagram; +import com.structurizr.view.ModelView; + +public class MermaidDiagram extends Diagram { + + public MermaidDiagram(ModelView view, String definition) { + super(view, definition); + } + + @Override + public String getFileExtension() { + return "mmd"; + } + +} diff --git a/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagramExporter.java b/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagramExporter.java new file mode 100644 index 000000000..3082176a3 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/mermaid/MermaidDiagramExporter.java @@ -0,0 +1,397 @@ +package com.structurizr.export.mermaid; + +import com.structurizr.export.AbstractDiagramExporter; +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static java.lang.String.format; + +/** + * Exports diagram definitions that can be used to create diagrams + * using mermaid (https://mermaidjs.github.io). + * + * System landscape, system context, container, component, dynamic and deployment diagrams are supported. + * Deployment node -> deployment node relationships are not rendered. + */ +public class MermaidDiagramExporter extends AbstractDiagramExporter { + + public static final String MERMAID_TITLE_PROPERTY = "mermaid.title"; + public static final String MERMAID_SEQUENCE_DIAGRAM_PROPERTY = "mermaid.sequenceDiagram"; + public static final String MERMAID_ICONS_PROPERTY = "mermaid.icons"; + + private int groupId = 0; + + public MermaidDiagramExporter() { + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + groupId = 0; + String direction = "TB"; + + if (view.getAutomaticLayout() != null) { + switch (view.getAutomaticLayout().getRankDirection()) { + case TopBottom: + direction = "TB"; + break; + case BottomTop: + direction = "BT"; + break; + case LeftRight: + direction = "LR"; + break; + case RightLeft: + direction = "RL"; + break; + } + } + + writer.writeLine("graph " + direction); + writer.indent(); + writer.writeLine("linkStyle default fill:#ffffff"); + writer.writeLine(); + + String viewTitle = " "; + if (includeTitle(view)) { + viewTitle = view.getTitle(); + if (StringUtils.isNullOrEmpty(viewTitle)) { + viewTitle = view.getName(); + } + } + + writer.writeLine("subgraph diagram [\"" + viewTitle + "\"]"); + writer.indent(); + writer.writeLine("style diagram fill:#ffffff,stroke:#ffffff"); + writer.writeLine(); + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("end"); + writer.outdent(); + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + groupId++; + + String groupName = group; + + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + if (!StringUtils.isNullOrEmpty(groupSeparator)) { + groupName = group.substring(group.lastIndexOf(groupSeparator) + groupSeparator.length()); + } + + String color = "#cccccc"; + + // is there a style for the group? + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group:" + group); + + if (elementStyle == null || StringUtils.isNullOrEmpty(elementStyle.getColor())) { + // no, so is there a default group style? + elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group"); + } + + if (elementStyle != null && !StringUtils.isNullOrEmpty(elementStyle.getColor())) { + color = elementStyle.getColor(); + } + + writer.writeLine(String.format("subgraph group%s [\"" + groupName + "\"]", groupId)); + writer.indent(); + writer.writeLine(String.format("style group%s fill:#ffffff,stroke:%s,color:%s,stroke-dasharray:5", groupId, color, color)); + writer.writeLine(); + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("end"); + writer.writeLine(); + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(softwareSystem); + String color = elementStyle.getStroke(); + + writer.writeLine(String.format("subgraph %s [\"%s\"]", softwareSystem.getId(), softwareSystem.getName())); + writer.indent(); + writer.writeLine(String.format("style %s fill:#ffffff,stroke:%s,color:%s", softwareSystem.getId(), color, color)); + writer.writeLine(); + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("end"); + writer.writeLine(); + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(container); + String color = elementStyle.getStroke(); + + writer.writeLine(String.format("subgraph %s [\"%s\"]", container.getId(), container.getName())); + writer.indent(); + writer.writeLine(String.format("style %s fill:#ffffff,stroke:%s,color:%s", container.getId(), color, color)); + writer.writeLine(); + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("end"); + writer.writeLine(); + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(deploymentNode); + + writer.writeLine(String.format("subgraph %s [\"%s\"]", deploymentNode.getId(), deploymentNode.getName())); + writer.indent(); + writer.writeLine(String.format("style %s fill:#ffffff,stroke:%s,color:%s", deploymentNode.getId(), elementStyle.getStroke(), elementStyle.getColor())); + writer.writeLine(); + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("end"); + writer.writeLine(); + } + + @Override + public Diagram export(DynamicView view) { + if (renderAsSequenceDiagram(view)) { + IndentingWriter writer = new IndentingWriter(); + writer.writeLine("sequenceDiagram"); + writer.writeLine(); + writer.indent(); + + Set<Element> elements = new LinkedHashSet<>(); + for (RelationshipView relationshipView : view.getRelationships()) { + elements.add(relationshipView.getRelationship().getSource()); + elements.add(relationshipView.getRelationship().getDestination()); + } + + for (Element element : elements) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + String shape = "participant"; + if (elementStyle.getShape() == Shape.Person) { + shape = "actor"; + } + + String type = typeOf(view, element, true); + + if (StringUtils.isNullOrEmpty(type) || false == elementStyle.getMetadata()) { + type = ""; + } else { + type = "<br />" + type; + } + + writer.writeLine(String.format("%s %s as %s%s", shape, element.getId(), element.getName(), type)); + } + + writer.writeLine(); + + for (RelationshipView relationshipView : view.getRelationships()) { + Relationship relationship = relationshipView.getRelationship(); + RelationshipStyle style = view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship); + + String description = relationshipView.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + description = relationship.getDescription(); + } + + String sourceId = relationship.getSourceId(); + String destinationId = relationship.getDestinationId(); + + if (relationshipView.isResponse()) { + sourceId = relationship.getDestinationId(); + destinationId = relationship.getSourceId(); + } + + String technology = !StringUtils.isNullOrEmpty(relationship.getTechnology()) ? "<br />[" + relationship.getTechnology() + "]" : ""; + + String arrow; + + if (!relationshipView.isResponse()) { + arrow = "->>"; + } else { + arrow = "-->>"; + } + + writer.writeLine(String.format("%s%s%s: %s%s", + sourceId, + arrow, + destinationId, + description, + technology)); + } + + return createDiagram(view, writer.toString()); + } else { + return super.export(view); + } + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + + String name = element.getName(); + String description = element.getDescription(); + String type = typeOf(view, element, true); + String icon = ""; + + if (element instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)element; + name = elementInstance.getElement().getName(); + description = elementInstance.getElement().getDescription(); + type = typeOf(view, elementInstance.getElement(), true); + } + + String nodeOpeningSymbol = "["; + String nodeClosingSymbol = "]"; + + if (elementStyle.getShape() == Shape.RoundedBox) { + nodeOpeningSymbol = "("; + nodeClosingSymbol = ")"; + } else if (elementStyle.getShape() == Shape.Cylinder) { + nodeOpeningSymbol = "[("; + nodeClosingSymbol = ")]"; + } + + if (StringUtils.isNullOrEmpty(description) || false == elementStyle.getDescription()) { + description = ""; + } else { + description = String.format("<div style='font-size: 80%%; margin-top:10px'>%s</div>", lines(description)); + } + + if (false == elementStyle.getMetadata()) { + type = ""; + } else { + type = String.format("<div style='font-size: 70%%; margin-top: 0px'>%s</div>", type); + } + + if ("true".equals(getViewOrViewSetProperty(view, MERMAID_ICONS_PROPERTY, "false")) && elementStyleHasSupportedIcon(elementStyle)) { + icon = "<div><img src='" + elementStyle.getIcon() + "' style='max-height: 50px; margin: auto; margin-top:10px'/></div>"; + } + + writer.writeLine(format("%s%s\"<div style='font-weight: bold'>%s</div>%s%s%s\"%s", + element.getId(), + nodeOpeningSymbol, + name, + type, + description, + icon, + nodeClosingSymbol + )); + + if (!StringUtils.isNullOrEmpty(element.getUrl())) { + writer.writeLine(format("click %s %s \"%s\"", element.getId(), element.getUrl(), element.getUrl())); + } + + if (element instanceof StaticStructureElementInstance) { + Element e = ((StaticStructureElementInstance)element).getElement(); + writer.writeLine(format("style %s fill:%s,stroke:%s,color:%s", element.getId(), elementStyle.getBackground(), elementStyle.getStroke(), elementStyle.getColor())); + } else { + writer.writeLine(format("style %s fill:%s,stroke:%s,color:%s", element.getId(), elementStyle.getBackground(), elementStyle.getStroke(), elementStyle.getColor())); + } + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + Relationship relationship = relationshipView.getRelationship(); + RelationshipStyle style = view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship); + + Element source = relationship.getSource(); + Element destination = relationship.getDestination(); + + if (source instanceof DeploymentNode || destination instanceof DeploymentNode) { + return; + } + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + source = relationship.getDestination(); + destination = relationship.getSource(); + } + + boolean solid = style.getStyle() == LineStyle.Solid || false == style.getDashed(); + // solid: A-- text -->B + // dotted: A-. text .->B + + String description = relationshipView.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + description = relationshipView.getRelationship().getDescription(); + } + + if (!StringUtils.isNullOrEmpty(relationshipView.getOrder())) { + description = relationshipView.getOrder() + ". " + description; + } + + writer.writeLine( + format("%s-%s \"<div>%s</div><div style='font-size: 70%%'>%s</div>\" %s->%s", + source.getId(), + solid ? "-" : ".", + lines(description), + !StringUtils.isNullOrEmpty(relationship.getTechnology()) ? "[" + relationship.getTechnology() + "]" : "", + solid ? "-" : ".", + destination.getId() + ) + ); + } + + private String lines(final String text) { + StringBuilder buf = new StringBuilder(); + if (text != null) { + final String[] words = text.trim().split("\\s+"); + + final StringBuilder line = new StringBuilder(); + for (final String word : words) { + if (line.length() == 0) { + line.append(word); + } else if (line.length() + word.length() + 1 < 30) { + line.append(' ').append(word); + } else { + buf.append(line.toString()); + buf.append("<br />"); + line.setLength(0); + line.append(word); + } + } + if (line.length() > 0) { + buf.append(line.toString()); + } + } + + return buf.toString(); + } + + @Override + protected Diagram createDiagram(ModelView view, String definition) { + return new MermaidDiagram(view, definition); + } + + protected boolean includeTitle(ModelView view) { + return "true".equals(getViewOrViewSetProperty(view, MERMAID_TITLE_PROPERTY, "true")); + } + + protected boolean renderAsSequenceDiagram(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, MERMAID_SEQUENCE_DIAGRAM_PROPERTY, "false")); + } + + private boolean elementStyleHasSupportedIcon(ElementStyle elementStyle) { + return !StringUtils.isNullOrEmpty(elementStyle.getIcon()) && elementStyle.getIcon().startsWith("http"); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/mermaid/README.md b/structurizr-export/src/main/java/com/structurizr/export/mermaid/README.md new file mode 100644 index 000000000..73b43c1c4 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/mermaid/README.md @@ -0,0 +1,6 @@ +# Mermaid + +The [MermaidDiagramExporter](MermaidDiagramExporter.java) provides a way to export views that are compatible with the +[Mermaid](https://mermaid-js.github.io/) diagramming tool. + +See https://docs.structurizr.com/export for more. \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/AbstractPlantUMLExporter.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/AbstractPlantUMLExporter.java new file mode 100644 index 000000000..d078aa65e --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/AbstractPlantUMLExporter.java @@ -0,0 +1,294 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.AbstractDiagramExporter; +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.ModelView; +import com.structurizr.view.Shape; + +import javax.imageio.IIOException; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +import static java.lang.String.format; + +public abstract class AbstractPlantUMLExporter extends AbstractDiagramExporter { + + protected static final int DEFAULT_FONT_SIZE = 24; + + public static final String PLANTUML_TITLE_PROPERTY = "plantuml.title"; + public static final String PLANTUML_INCLUDES_PROPERTY = "plantuml.includes"; + public static final String PLANTUML_ANIMATION_PROPERTY = "plantuml.animation"; + public static final String PLANTUML_SEQUENCE_DIAGRAM_PROPERTY = "plantuml.sequenceDiagram"; + + public static final String DIAGRAM_TITLE_TAG = "Diagram:Title"; + public static final String DIAGRAM_DESCRIPTION_TAG = "Diagram:Description"; + + private final Map<String, String> skinParams = new LinkedHashMap<>(); + + protected Map<String, String> getSkinParams() { + return skinParams; + } + + public void addSkinParam(String name, String value) { + skinParams.put(name, value); + } + + public void clearSkinParams() { + skinParams.clear(); + } + + public AbstractPlantUMLExporter() { + this(ColorScheme.Light); + } + + public AbstractPlantUMLExporter(ColorScheme colorScheme) { + super(colorScheme); + } + + String plantUMLShapeOf(ModelView view, Element element) { + Shape shape = findElementStyle(view, element).getShape(); + + return plantUMLShapeOf(shape); + } + + String plantUMLShapeOf(Shape shape) { + switch(shape) { + case Person: + case Robot: + return "person"; + case Component: + return "component"; + case Cylinder: + return "database"; + case Folder: + return "folder"; + case Ellipse: + case Circle: + return "storage"; + case Hexagon: + return "hexagon"; + case Pipe: + return "queue"; + default: + return "rectangle"; + } + } + + String plantumlSequenceType(ModelView view, Element element) { + Shape shape = findElementStyle(view, element).getShape(); + + switch(shape) { + case Box: + return "participant"; + case Person: + return "actor"; + case Cylinder: + return "database"; + case Folder: + return "collections"; + case Ellipse: + case Circle: + return "entity"; + default: + return "participant"; + } + } + + String idOf(ModelItem modelItem) { + if (modelItem instanceof Element) { + Element element = (Element)modelItem; + if (element.getParent() == null) { + if (element instanceof DeploymentNode) { + DeploymentNode dn = (DeploymentNode)element; + return filter(dn.getEnvironment()) + "." + id(dn); + } else { + return id(element); + } + } else { + return idOf(element.getParent()) + "." + id(modelItem); + } + } + + return id(modelItem); + } + + private String id(ModelItem modelItem) { + if (modelItem instanceof Person) { + return id((Person)modelItem); + } else if (modelItem instanceof SoftwareSystem) { + return id((SoftwareSystem)modelItem); + } else if (modelItem instanceof Container) { + return id((Container)modelItem); + } else if (modelItem instanceof Component) { + return id((Component)modelItem); + } else if (modelItem instanceof DeploymentNode) { + return id((DeploymentNode)modelItem); + } else if (modelItem instanceof InfrastructureNode) { + return id((InfrastructureNode)modelItem); + } else if (modelItem instanceof SoftwareSystemInstance) { + return id((SoftwareSystemInstance)modelItem); + } else if (modelItem instanceof ContainerInstance) { + return id((ContainerInstance)modelItem); + } + + return modelItem.getId(); + } + + private String id(Person person) { + return filter(person.getName()); + } + + private String id(SoftwareSystem softwareSystem) { + return filter(softwareSystem.getName()); + } + + private String id(Container container) { + return filter(container.getName()); + } + + private String id(Component component) { + return filter(component.getName()); + } + + private String id(DeploymentNode deploymentNode) { + return filter(deploymentNode.getName()); + } + + private String id(InfrastructureNode infrastructureNode) { + return filter(infrastructureNode.getName()); + } + + private String id(SoftwareSystemInstance softwareSystemInstance) { + return filter(softwareSystemInstance.getName()) + "_" + softwareSystemInstance.getInstanceId(); + } + + private String id(ContainerInstance containerInstance) { + return filter(containerInstance.getName()) + "_" + containerInstance.getInstanceId(); + } + + private String filter(String s) { + return s.replaceAll("(?U)\\W", ""); + } + + protected boolean includeTitle(ModelView view) { + return "true".equals(getViewOrViewSetProperty(view, PLANTUML_TITLE_PROPERTY, "true")); + } + + @Override + protected boolean isAnimationSupported(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, PLANTUML_ANIMATION_PROPERTY, "false")); + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + writer.writeLine("@startuml"); + + if (includeTitle(view)) { + ElementStyle titleStyle = findElementStyle(view, DIAGRAM_TITLE_TAG); + ElementStyle descriptionStyle = findElementStyle(view, DIAGRAM_DESCRIPTION_TAG); + + String title = view.getTitle(); + if (StringUtils.isNullOrEmpty(title)) { + title = view.getName(); + } + + String description = view.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + writer.writeLine( + String.format( + "title <size:%s>%s</size>", + titleStyle != null ? titleStyle.getFontSize() : DEFAULT_FONT_SIZE, + title + ) + ); + } else { + writer.writeLine( + String.format( + "title <size:%s>%s</size>\\n<size:%s>%s</size>", + titleStyle != null ? titleStyle.getFontSize() : DEFAULT_FONT_SIZE, + title, + descriptionStyle != null ? descriptionStyle.getFontSize() : DEFAULT_FONT_SIZE, + description + ) + ); + } + } + + writer.writeLine(); + writer.writeLine("set separator none"); + } + + protected void writeSkinParams(IndentingWriter writer) { + if (!skinParams.isEmpty()) { + writer.writeLine(); + writer.writeLine("skinparam {"); + writer.indent(); + for (final String name : skinParams.keySet()) { + writer.writeLine(format("%s %s", name, skinParams.get(name))); + } + writer.outdent(); + writer.writeLine("}"); + } + + writer.writeLine(); + } + + protected void writeIncludes(ModelView view, IndentingWriter writer) { + String commaSeparatedIncludes = getViewOrViewSetProperty(view, PLANTUML_INCLUDES_PROPERTY, ""); + if (!StringUtils.isNullOrEmpty(commaSeparatedIncludes)) { + String[] includes = commaSeparatedIncludes.split(","); + + for (String include : includes) { + if (!StringUtils.isNullOrEmpty(include)) { + include = include.trim(); + writer.writeLine("!include " + include); + } + } + writer.writeLine(); + } + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + writer.writeLine("@enduml"); + } + + @Override + protected Diagram createDiagram(ModelView view, String definition) { + return new PlantUMLDiagram(view, definition); + } + + protected boolean isSupportedIcon(String icon) { + return !StringUtils.isNullOrEmpty(icon) && icon.startsWith("http"); + } + + protected double calculateIconScale(String iconUrl, int maxIconSize) { + double scale = 0.5; + + try { + URL url = new URL(iconUrl); + BufferedImage bi = ImageIO.read(url); + + int width = bi.getWidth(); + int height = bi.getHeight(); + + scale = ((double)maxIconSize) / Math.max(width, height); + } catch (UnsupportedOperationException | UnsatisfiedLinkError | IIOException e) { + // This is a known issue on native builds since AWT packages aren't available. + // So we just swallow the error and use the default scale + } catch (Exception e) { + e.printStackTrace(); + } + + return scale; + } + +} diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/C4PlantUMLExporter.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/C4PlantUMLExporter.java new file mode 100644 index 000000000..bba5d53c5 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/C4PlantUMLExporter.java @@ -0,0 +1,680 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.*; + +import static java.lang.String.format; + +public class C4PlantUMLExporter extends AbstractPlantUMLExporter { + + private static final String STRUCTURIZR_PROPERTY_NAME = "structurizr."; + + public static final String C4PLANTUML_LEGEND_PROPERTY = "c4plantuml.legend"; + public static final String C4PLANTUML_STEREOTYPES_PROPERTY = "c4plantuml.stereotypes"; + public static final String C4PLANTUML_TAGS_PROPERTY = "c4plantuml.tags"; + public static final String C4PLANTUML_STANDARD_LIBRARY_PROPERTY = "c4plantuml.stdlib"; + public static final String C4PLANTUML_SPRITE = "c4plantuml.sprite"; + public static final String C4PLANTUML_SHADOW = "c4plantuml.shadow"; + + private static final int MAX_ICON_SIZE = 30; + + /** + * <p>Set this property to <code>true</code> by calling {@link Configuration#addProperty(String, String)} in your + * {@link ViewSet} in order to have all {@link ModelItem#getProperties()} for {@link Component}s + * being printed in the PlantUML diagrams.</p> + * + * <p>The default value is <code>false</code>.</p> + * + * @see ViewSet#getConfiguration() + * @see Configuration#getProperties() + */ + public static final String C4PLANTUML_ELEMENT_PROPERTIES_PROPERTY = "c4plantuml.elementProperties"; + + /** + * <p>Set this property to <code>true</code> by calling {@link Configuration#addProperty(String, String)} in your + * {@link ViewSet} in order to have all {@link ModelItem#getProperties()} for {@link Relationship}s being + * printed in the PlantUML diagrams.</p> + * + * <p>The default value is <code>false</code>.</p> + * + * @see ViewSet#getConfiguration() + * @see Configuration#getProperties() + */ + public static final String C4PLANTUML_RELATIONSHIP_PROPERTIES_PROPERTY = "c4plantuml.relationshipProperties"; + + private int groupId = 0; + + public C4PlantUMLExporter() { + } + + public C4PlantUMLExporter(ColorScheme colorScheme) { + super(colorScheme); + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + super.writeHeader(view, writer); + groupId = 0; + + if (!renderAsSequenceDiagram(view)) { + if (view.getAutomaticLayout() != null) { + switch (view.getAutomaticLayout().getRankDirection()) { + case LeftRight: + writer.writeLine("left to right direction"); + break; + default: + writer.writeLine("top to bottom direction"); + break; + } + } else { + writer.writeLine("top to bottom direction"); + } + } + + writeSkinParams(writer); + + writer.writeLine("<style>"); + writer.indent(); + + writer.writeLine("root {"); + writer.indent(); + if (colorScheme == ColorScheme.Dark) { + writer.writeLine("BackgroundColor: " + Styles.DEFAULT_BACKGROUND_DARK); + writer.writeLine("FontColor: " + Styles.DEFAULT_COLOR_DARK); + } else { + writer.writeLine("BackgroundColor: " + Styles.DEFAULT_BACKGROUND_LIGHT); + writer.writeLine("FontColor: " + Styles.DEFAULT_COLOR_LIGHT); + } + Font font = view.getViewSet().getConfiguration().getBranding().getFont(); + if (font != null) { + String fontName = font.getName(); + if (!StringUtils.isNullOrEmpty(fontName)) { + writer.writeLine("FontName: " + fontName); + } + } + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine("</style>"); + writer.writeLine(); + + if (renderAsSequenceDiagram(view)) { + if (usePlantUMLStandardLibrary(view)) { + writer.writeLine("!include <C4/C4_Sequence>"); + } else { + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Sequence.puml"); + } + } else { + if (usePlantUMLStandardLibrary(view)) { + writer.writeLine("!include <C4/C4>"); + writer.writeLine("!include <C4/C4_Context>"); + } else { + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4.puml"); + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml"); + } + + if (view.getElements().stream().map(ElementView::getElement).anyMatch(e -> e instanceof Container || e instanceof ContainerInstance)) { + if (usePlantUMLStandardLibrary(view)) { + writer.writeLine("!include <C4/C4_Container>"); + } else { + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml"); + } + } + + if (view.getElements().stream().map(ElementView::getElement).anyMatch(e -> e instanceof Component)) { + if (usePlantUMLStandardLibrary(view)) { + writer.writeLine("!include <C4/C4_Component>"); + } else { + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml"); + } + } + + if (view instanceof DeploymentView) { + if (usePlantUMLStandardLibrary(view)) { + writer.writeLine("!include <C4/C4_Deployment>"); + } else { + writer.writeLine("!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Deployment.puml"); + } + } + } + writer.writeLine(); + + writeIncludes(view, writer); + + if (includeTags(view)) { + Map<String,ElementStyle> elementStyles = new HashMap<>(); + Map<String,RelationshipStyle> relationshipStyles = new HashMap<>(); + Map<String,ElementStyle> boundaryStyles = new HashMap<>(); + + // elements + for (ElementView elementView : view.getElements()) { + Element element = elementView.getElement(); + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + + elementStyles.put(elementStyle.getTag(), elementStyle); + } + + // relationships + for (RelationshipView relationshipView : view.getRelationships()) { + Relationship relationship = relationshipView.getRelationship(); + RelationshipStyle relationshipStyle = view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship); + + relationshipStyles.put(relationshipStyle.getTag(), relationshipStyle); + } + + if (renderAsSequenceDiagram(view)) { + // no boundaries, do nothing + } else { + // boundaries + List<Element> boundaryElements = new ArrayList<>(); + if (view instanceof ContainerView) { + boundaryElements.addAll(getBoundarySoftwareSystems(view)); + } else if (view instanceof ComponentView) { + boundaryElements.addAll(getBoundaryContainers(view)); + } else if (view instanceof DynamicView) { + DynamicView dynamicView = (DynamicView) view; + if (dynamicView.getElement() instanceof SoftwareSystem) { + boundaryElements.addAll(getBoundarySoftwareSystems(view)); + } else if (dynamicView.getElement() instanceof Container) { + boundaryElements.addAll(getBoundaryContainers(view)); + } + } + + for (Element boundaryElement : boundaryElements) { + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(boundaryElement); + boundaryStyles.put(elementStyle.getTag(), elementStyle); + } + } + + if (!elementStyles.isEmpty()) { + for (String tagList : elementStyles.keySet()) { + ElementStyle elementStyle = elementStyles.get(tagList); + tagList = tagList.replaceFirst("Element,", ""); + + String sprite = ""; + if (isSupportedIcon(elementStyle.getIcon())) { + double scale = calculateIconScale(elementStyle.getIcon(), MAX_ICON_SIZE); + sprite = "img:" + elementStyle.getIcon() + "{scale=" + scale + "}"; + } + sprite = elementStyle.getProperties().getOrDefault(C4PLANTUML_SPRITE, sprite); + + int borderThickness = 1; + if (elementStyle.getStrokeWidth() != null) { + borderThickness = elementStyle.getStrokeWidth(); + } + + String line = String.format("AddElementTag(\"%s\", $bgColor=\"%s\", $borderColor=\"%s\", $fontColor=\"%s\", $sprite=\"%s\", $shadowing=\"%s\", $borderStyle=\"%s\", $borderThickness=\"%s\")", + tagList, + elementStyle.getBackground(), + elementStyle.getStroke(), + elementStyle.getColor(), + sprite, + elementStyle.getProperties().getOrDefault(C4PLANTUML_SHADOW, ""), + elementStyle.getBorder().toString().toLowerCase(), + borderThickness + ); + + line = line.replace(", $borderThickness=\"1\")", ")"); + writer.writeLine(line); + } + + writer.writeLine(); + } + + if (!relationshipStyles.isEmpty()) { + for (String tagList : relationshipStyles.keySet()) { + RelationshipStyle relationshipStyle = relationshipStyles.get(tagList); + tagList = tagList.replaceFirst("Relationship,", ""); + + String lineStyle = "\"\""; + if (relationshipStyle.getStyle() == LineStyle.Dashed) { + lineStyle = "DashedLine()"; + } else if (relationshipStyle.getStyle() == LineStyle.Dotted) { + lineStyle = "DottedLine()"; + } + + writer.writeLine(String.format("AddRelTag(\"%s\", $textColor=\"%s\", $lineColor=\"%s\", $lineStyle = %s)", + tagList, + relationshipStyle.getColor(), + relationshipStyle.getColor(), + lineStyle + )); + } + + writer.writeLine(); + } + + if (!boundaryStyles.isEmpty()) { + for (String tagList : boundaryStyles.keySet()) { + ElementStyle elementStyle = boundaryStyles.get(tagList); + tagList = tagList.replaceFirst("Element,", ""); + + int borderThickness = 1; + if (elementStyle.getStrokeWidth() != null) { + borderThickness = elementStyle.getStrokeWidth(); + } + + String line = String.format("AddBoundaryTag(\"%s\", $bgColor=\"%s\", $borderColor=\"%s\", $fontColor=\"%s\", $shadowing=\"%s\", $borderStyle=\"%s\", $borderThickness=\"%s\")", + tagList, + "#ffffff", + elementStyle.getStroke(), + elementStyle.getStroke(), + elementStyle.getProperties().getOrDefault(C4PLANTUML_SHADOW, ""), + elementStyle.getBorder().toString().toLowerCase(), + borderThickness + ); + + line = line.replace(", $borderThickness=\"1\")", ")"); + writer.writeLine(line); + } + + writer.writeLine(); + } + } + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + writer.writeLine("SHOW_LEGEND(" + includeLegend(view) + ")"); + writer.writeLine((includeStereotypes(view) ? "show" : "hide") + " stereotypes"); + + super.writeFooter(view, writer); + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + groupId++; + String groupName = group; + + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + if (!StringUtils.isNullOrEmpty(groupSeparator)) { + groupName = group.substring(group.lastIndexOf(groupSeparator) + groupSeparator.length()); + } + + String color = "#cccccc"; + int borderThickness = 1; +// String icon = ""; + + ElementStyle elementStyleForGroup = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group:" + group); + ElementStyle elementStyleForAllGroups = view.getViewSet().getConfiguration().getStyles().findElementStyle("Group"); + + if (elementStyleForGroup != null && !StringUtils.isNullOrEmpty(elementStyleForGroup.getColor())) { + color = elementStyleForGroup.getColor(); + } else if (elementStyleForAllGroups != null && !StringUtils.isNullOrEmpty(elementStyleForAllGroups.getColor())) { + color = elementStyleForAllGroups.getColor(); + } + + if (elementStyleForGroup != null && !StringUtils.isNullOrEmpty(elementStyleForGroup.getStroke())) { + color = elementStyleForGroup.getStroke(); + } else if (elementStyleForAllGroups != null && !StringUtils.isNullOrEmpty(elementStyleForAllGroups.getStroke())) { + color = elementStyleForAllGroups.getStroke(); + } + + if (elementStyleForGroup != null && elementStyleForGroup.getStrokeWidth() != null) { + borderThickness = elementStyleForGroup.getStrokeWidth(); + } else if (elementStyleForAllGroups != null && elementStyleForAllGroups.getStrokeWidth() != null) { + borderThickness = elementStyleForAllGroups.getStrokeWidth(); + } + + +// todo: $sprite doesn't seem to be supported for boundary styles +// if (elementStyleForGroup != null && elementStyleHasSupportedIcon(elementStyleForGroup)) { +// icon = elementStyleForGroup.getIcon(); +// } else if (elementStyleForAllGroups != null && elementStyleHasSupportedIcon(elementStyleForAllGroups)) { +// icon = elementStyleForAllGroups.getColor(); +// } +// +// if (!StringUtils.isNullOrEmpty(icon)) { +// double scale = calculateIconScale(icon); +// icon = "\\n\\n<img:" + icon + "{scale=" + scale + "}>"; +// } + + String line = String.format("AddBoundaryTag(\"%s\", $borderColor=\"%s\", $fontColor=\"%s\", $borderStyle=\"%s\", $borderThickness=\"%s\")", + group, + color, + color, + Border.Dashed.toString().toLowerCase(), + borderThickness); + + line = line.replace(", $borderThickness=\"1\")", ")"); + writer.writeLine(line); + + writer.writeLine(String.format("Boundary(group_%s, \"%s\", $tags=\"%s\") {", groupId, groupName, group)); + writer.indent(); + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + writer.writeLine(String.format("System_Boundary(\"%s_boundary\", \"%s\", $tags=\"%s\") {", idOf(softwareSystem), softwareSystem.getName(), tagsOf(view, softwareSystem))); + writer.indent(); + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + writer.writeLine(String.format("Container_Boundary(\"%s_boundary\", \"%s\", $tags=\"%s\") {", idOf(container), container.getName(), tagsOf(view,container))); + writer.indent(); + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + String url = deploymentNode.getUrl(); + if (StringUtils.isNullOrEmpty(url)) { + url = ""; + } + + if (Boolean.TRUE.toString().equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_ELEMENT_PROPERTIES_PROPERTY, Boolean.FALSE.toString()))) { + addProperties(view, writer, deploymentNode); + } + + String technology = deploymentNode.getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } + String description = deploymentNode.getDescription(); + if (StringUtils.isNullOrEmpty(description)) { + description = ""; + } + + // Deployment_Node(alias, label, ?type, ?descr, ?sprite, ?tags, ?link) + writer.writeLine( + format("Deployment_Node(%s, \"%s\", $type=\"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\") {", + idOf(deploymentNode), + deploymentNode.getName() + (!"1".equals(deploymentNode.getInstances()) ? " (x" + deploymentNode.getInstances() + ")" : ""), + technology, + description, + tagsOf(view, deploymentNode), + url + ) + ); + writer.indent(); + + if (!isVisible(view, deploymentNode)) { + writer.writeLine("hide " + idOf(deploymentNode)); + } + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + public Diagram export(CustomView view) { + return null; + } + + @Override + public Diagram export(DynamicView view, String order) { + if (renderAsSequenceDiagram(view)) { + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + boolean elementsWritten = false; + + Set<Element> elements = new LinkedHashSet<>(); + for (RelationshipView relationshipView : view.getRelationships()) { + elements.add(relationshipView.getRelationship().getSource()); + elements.add(relationshipView.getRelationship().getDestination()); + } + + for (Element element : elements) { + writeElement(view, element, writer); + elementsWritten = true; + } + + if (elementsWritten) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } else { + return super.export(view, order); + } + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + if (element instanceof CustomElement) { + return; + } + + Element elementToWrite = element; + ElementStyle elementStyle = view.getViewSet().getConfiguration().getStyles().findElementStyle(element); + String id = idOf(element); + + String url = element.getUrl(); + if (StringUtils.isNullOrEmpty(url)) { + url = ""; + } + + if (Boolean.TRUE.toString().equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_ELEMENT_PROPERTIES_PROPERTY, Boolean.FALSE.toString()))) { + addProperties(view, writer, element); + } + + if (element instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance)element; + element = elementInstance.getElement(); + + if (StringUtils.isNullOrEmpty(url)) { + url = element.getUrl(); + if (StringUtils.isNullOrEmpty(url)) { + url = ""; + } + } + } + + String name = element.getName(); + String description = element.getDescription(); + + if (StringUtils.isNullOrEmpty(description)) { + description = ""; + } + + if (element instanceof Person) { + Person person = (Person)element; + + // Person(alias, label, ?descr, ?sprite, ?tags, ?link, ?type) + writer.writeLine( + String.format("Person(%s, \"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\")", + id, name, description, tagsOf(view, elementToWrite), url) + ); + } else if (element instanceof SoftwareSystem) { + SoftwareSystem softwareSystem = (SoftwareSystem)element; + + // System(alias, label, ?descr, ?sprite, ?tags, ?link, ?type) + writer.writeLine( + String.format("System(%s, \"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\")", + id, name, description, tagsOf(view, elementToWrite), url) + ); + } else if (element instanceof Container) { + Container container = (Container)element; + String shape = ""; + if (elementStyle.getShape() == Shape.Cylinder) { + shape = "Db"; + } else if (elementStyle.getShape() == Shape.Pipe) { + shape = "Queue"; + } + + String technology = container.getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } + + // Container(alias, label, ?techn, ?descr, ?sprite, ?tags, ?link) + writer.writeLine( + String.format("Container%s(%s, \"%s\", $techn=\"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\")", + shape, id, name, technology, description, tagsOf(view, elementToWrite), url) + ); + } else if (element instanceof Component) { + Component component = (Component)element; + String shape = ""; + + if (elementStyle.getShape() == Shape.Cylinder) { + shape = "Db"; + } else if (elementStyle.getShape() == Shape.Pipe) { + shape = "Queue"; + } + + String technology = component.getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } + + // Component(alias, label, ?techn, ?descr, ?sprite, ?tags, ?link) + writer.writeLine( + String.format("Component%s(%s, \"%s\", $techn=\"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\")", + shape, id, name, technology, description, tagsOf(view, elementToWrite), url) + ); + } else if (element instanceof InfrastructureNode) { + InfrastructureNode infrastructureNode = (InfrastructureNode)element; + String technology = infrastructureNode.getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } + + // Deployment_Node(alias, label, ?type, ?descr, ?sprite, ?tags, ?link) + writer.writeLine( + String.format("Deployment_Node(%s, \"%s\", $type=\"%s\", $descr=\"%s\", $tags=\"%s\", $link=\"%s\")", + idOf(infrastructureNode), name, technology, description, tagsOf(view, elementToWrite), url) + ); + } + + if (!isVisible(view, elementToWrite)) { + writer.writeLine("hide " + id); + } + } + + private String tagsOf(ModelView view, Element element) { + if (includeTags(view)) { + return view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getTag().replaceFirst("Element,", ""); + } else { + return ""; + } + } + + private String tagsOf(ModelView view, Relationship relationship) { + if (includeTags(view)) { + return view.getViewSet().getConfiguration().getStyles().findRelationshipStyle(relationship).getTag().replaceFirst("Relationship,", ""); + } else { + return ""; + } + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + Relationship relationship = relationshipView.getRelationship(); + Element source = relationship.getSource(); + Element destination = relationship.getDestination(); + + if (source instanceof CustomElement || destination instanceof CustomElement) { + return; + } + + if (Boolean.TRUE.toString().equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_RELATIONSHIP_PROPERTIES_PROPERTY, Boolean.FALSE.toString()))) { + addProperties(view, writer, relationship); + } + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + source = relationship.getDestination(); + destination = relationship.getSource(); + } + + String description = ""; + + if (!StringUtils.isNullOrEmpty(relationshipView.getOrder())) { + description = relationshipView.getOrder() + ": "; + } + + description += (hasValue(relationshipView.getDescription()) ? relationshipView.getDescription() : hasValue(relationshipView.getRelationship().getDescription()) ? relationshipView.getRelationship().getDescription() : ""); + + String technology = relationship.getTechnology(); + if (StringUtils.isNullOrEmpty(technology)) { + technology = ""; + } + + String url = relationship.getUrl(); + if (StringUtils.isNullOrEmpty(url)) { + url = ""; + } + + // Rel(from, to, label, ?techn, ?descr, ?sprite, ?tags, ?link) + writer.writeLine( + format("Rel(%s, %s, \"%s\", $techn=\"%s\", $tags=\"%s\", $link=\"%s\")", + idOf(source), idOf(destination), description, technology, tagsOf(view, relationship), url) + ); + } + + private void addProperties(ModelView view, IndentingWriter writer, ModelItem element) { + Map<String, String> properties = new HashMap<>(); + for (String key : element.getProperties().keySet()) { + // don't include any internal Structurizr properties (e.g. structurizr.dsl.identifier) + if (!key.startsWith(STRUCTURIZR_PROPERTY_NAME)) { + properties.put(key, element.getProperties().get(key)); + } + } + + if (!properties.isEmpty()) { + writer.writeLine("WithoutPropertyHeader()"); + properties.keySet().stream().sorted().forEach(key -> + writer.writeLine(String.format("AddProperty(\"%s\",\"%s\")", key, properties.get(key))) + ); + } + } + + @Override + protected boolean isAnimationSupported(ModelView view) { + return !(view instanceof DynamicView) && super.isAnimationSupported(view); + } + + protected boolean includeLegend(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_LEGEND_PROPERTY, "true")); + } + + protected boolean includeStereotypes(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_STEREOTYPES_PROPERTY, "false")); + } + + protected boolean includeTags(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_TAGS_PROPERTY, "false")); + } + + protected boolean usePlantUMLStandardLibrary(ModelView view) { + return "true".equalsIgnoreCase(getViewOrViewSetProperty(view, C4PLANTUML_STANDARD_LIBRARY_PROPERTY, "true")); + } + + protected boolean renderAsSequenceDiagram(ModelView view) { + return view instanceof DynamicView && "true".equalsIgnoreCase(getViewOrViewSetProperty(view, PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "false")); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyle.java new file mode 100644 index 000000000..ad6cd4f6a --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyle.java @@ -0,0 +1,81 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.view.Border; + +import java.util.Base64; + +import static java.lang.String.format; + +class PlantUMLBoundaryStyle extends PlantUMLStyle { + + private final String background; + private final String color; + private final String stroke; + private final int strokeWidth; + private final String lineStyle; + private final int fontSize; + private final boolean shadow; + + PlantUMLBoundaryStyle(String name, String background, String color, String stroke, int strokeWidth, Border border, int fontSize, boolean shadow) { + super(name); + + this.background = background; + this.color = color; + this.stroke = stroke; + this.strokeWidth = strokeWidth; + + switch (border) { + case Dotted: + this.lineStyle = (strokeWidth) + "-" + (strokeWidth); + break; + case Dashed: + this.lineStyle = (strokeWidth * 5) + "-" + (strokeWidth * 5); + break; + default: + this.lineStyle = "0"; + break; + } + + this.fontSize = fontSize; + this.shadow = shadow; + } + + @Override + String getClassSelector() { + return "Boundary-" + Base64.getEncoder().encodeToString(name.getBytes()); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLBoundaryStyle that = (PlantUMLBoundaryStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("// %s", name)); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("BackgroundColor: %s;", background)); + writer.writeLine(String.format("LineColor: %s;", stroke)); + writer.writeLine(String.format("LineStyle: %s;", lineStyle)); + writer.writeLine(String.format("LineThickness: %s;", strokeWidth)); + writer.writeLine(String.format("FontColor: %s;", color)); + writer.writeLine(String.format("FontSize: %s;", fontSize)); + writer.writeLine("HorizontalAlignment: center;"); + writer.writeLine(String.format("Shadowing: %s;", shadow ? SHADOW_DISTANCE : 0)); + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDeploymentNodeStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDeploymentNodeStyle.java new file mode 100644 index 000000000..18915a0f6 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDeploymentNodeStyle.java @@ -0,0 +1,99 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.view.Border; + +import java.util.Base64; + +import static java.lang.String.format; + +class PlantUMLDeploymentNodeStyle extends PlantUMLStyle { + + private final String background; + private final String color; + private final String stroke; + private final int strokeWidth; + private final String lineStyle; + private final int fontSize; + private final String icon; + private final boolean shadow; + private Integer width; // only used for the legend + + PlantUMLDeploymentNodeStyle(String name, String background, String color, String stroke, int strokeWidth, Border border, int fontSize, String icon, boolean shadow) { + super(name); + + this.background = background; + this.color = color; + this.stroke = stroke; + this.strokeWidth = strokeWidth; + + switch (border) { + case Dotted: + this.lineStyle = (strokeWidth) + "-" + (strokeWidth); + break; + case Dashed: + this.lineStyle = (strokeWidth * 5) + "-" + (strokeWidth * 5); + break; + default: + this.lineStyle = "0"; + break; + } + + this.fontSize = fontSize; + this.icon = icon; + this.shadow = shadow; + } + + public int getFontSize() { + return fontSize; + } + + public String getIcon() { + return icon; + } + + public void setWidth(int width) { + this.width = width; + } + + @Override + String getClassSelector() { + return "DeploymentNode-" + Base64.getEncoder().encodeToString(name.getBytes()); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLDeploymentNodeStyle that = (PlantUMLDeploymentNodeStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("// %s", name)); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("BackgroundColor: %s;", background)); + writer.writeLine(String.format("LineColor: %s;", stroke)); + writer.writeLine(String.format("LineStyle: %s;", lineStyle)); + writer.writeLine(String.format("LineThickness: %s;", strokeWidth)); + writer.writeLine(String.format("FontColor: %s;", color)); + writer.writeLine(String.format("FontSize: %s;", fontSize)); + writer.writeLine("HorizontalAlignment: center;"); + writer.writeLine(String.format("Shadowing: %s;", shadow ? SHADOW_DISTANCE : 0)); + if (width != null) { + writer.writeLine(String.format("MaximumWidth: %s;", width)); + } + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDiagram.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDiagram.java new file mode 100644 index 000000000..b35e854e1 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLDiagram.java @@ -0,0 +1,17 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.Diagram; +import com.structurizr.view.ModelView; + +public class PlantUMLDiagram extends Diagram { + + public PlantUMLDiagram(ModelView view, String definition) { + super(view, definition); + } + + @Override + public String getFileExtension() { + return "puml"; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLElementStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLElementStyle.java new file mode 100644 index 000000000..06da055f0 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLElementStyle.java @@ -0,0 +1,107 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.view.Border; +import com.structurizr.view.Shape; + +import java.util.Base64; + +import static java.lang.String.format; + +class PlantUMLElementStyle extends PlantUMLStyle { + + private final Shape shape; + private int width; + private final String background; + private final String color; + private final String stroke; + private final int strokeWidth; + private final String lineStyle; + private final int fontSize; + private final String icon; + private final boolean shadow; + + PlantUMLElementStyle(String name, Shape shape, int width, String background, String color, String stroke, int strokeWidth, Border border, int fontSize, String icon, boolean shadow) { + super(name); + + this.shape = shape; + this.width = width; + this.background = background; + this.color = color; + this.stroke = stroke; + this.strokeWidth = strokeWidth; + + switch (border) { + case Dotted: + this.lineStyle = (strokeWidth) + "-" + (strokeWidth); + break; + case Dashed: + this.lineStyle = (strokeWidth * 5) + "-" + (strokeWidth * 5); + break; + default: + this.lineStyle = "0"; + break; + } + + this.fontSize = fontSize; + this.icon = icon; + this.shadow = shadow; + } + + Shape getShape() { + return shape; + } + + int getFontSize() { + return fontSize; + } + + String getIcon() { + return icon; + } + + void setWidth(int width) { + this.width = width; + } + + @Override + String getClassSelector() { + return "Element-" + Base64.getEncoder().encodeToString(name.getBytes()); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLElementStyle that = (PlantUMLElementStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("// %s", name)); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("BackgroundColor: %s;", background)); + writer.writeLine(String.format("LineColor: %s;", stroke)); + writer.writeLine(String.format("LineStyle: %s;", lineStyle)); + writer.writeLine(String.format("LineThickness: %s;", strokeWidth)); + if (shape == Shape.RoundedBox) { + writer.writeLine("RoundCorner: 20;"); + } + writer.writeLine(String.format("FontColor: %s;", color)); + writer.writeLine(String.format("FontSize: %s;", fontSize)); + writer.writeLine("HorizontalAlignment: center;"); + writer.writeLine(String.format("Shadowing: %s;", shadow ? SHADOW_DISTANCE : 0)); + writer.writeLine(String.format("MaximumWidth: %s;", width)); + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLGroupStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLGroupStyle.java new file mode 100644 index 000000000..19507abff --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLGroupStyle.java @@ -0,0 +1,81 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.view.Border; + +import java.util.Base64; + +import static java.lang.String.format; + +class PlantUMLGroupStyle extends PlantUMLStyle { + + private final String background; + private final String color; + private final String stroke; + private final int strokeWidth; + private final String lineStyle; + private final int fontSize; + private final boolean shadow; + + PlantUMLGroupStyle(String name, String background, String color, String stroke, int strokeWidth, Border border, int fontSize, boolean shadow) { + super(name); + + this.background = background; + this.color = color; + this.stroke = stroke; + this.strokeWidth = strokeWidth; + + switch (border) { + case Dotted: + this.lineStyle = (strokeWidth) + "-" + (strokeWidth); + break; + case Dashed: + this.lineStyle = (strokeWidth * 5) + "-" + (strokeWidth * 5); + break; + default: + this.lineStyle = "0"; + break; + } + + this.fontSize = fontSize; + this.shadow = shadow; + } + + @Override + String getClassSelector() { + return "Group-" + Base64.getEncoder().encodeToString(name.getBytes()); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLGroupStyle that = (PlantUMLGroupStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("// %s", name)); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("BackgroundColor: %s;", background)); + writer.writeLine(String.format("LineColor: %s;", stroke)); + writer.writeLine(String.format("LineStyle: %s;", lineStyle)); + writer.writeLine(String.format("LineThickness: %s;", strokeWidth)); + writer.writeLine(String.format("FontColor: %s;", color)); + writer.writeLine(String.format("FontSize: %s;", fontSize)); + writer.writeLine("HorizontalAlignment: center;"); + writer.writeLine(String.format("Shadowing: %s;", shadow ? SHADOW_DISTANCE : 0)); + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLLegendStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLLegendStyle.java new file mode 100644 index 000000000..8274cb74e --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLLegendStyle.java @@ -0,0 +1,44 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; + +import static java.lang.String.format; + +class PlantUMLLegendStyle extends PlantUMLStyle { + + PlantUMLLegendStyle() { + super("Element-Transparent"); + } + + @Override + String getClassSelector() { + return "Element-Transparent"; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLLegendStyle that = (PlantUMLLegendStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine("// transparent element for relationships in legend"); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine("BackgroundColor: transparent;"); + writer.writeLine("LineColor: transparent;"); + writer.writeLine("FontColor: transparent;"); + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyle.java new file mode 100644 index 000000000..06379cd53 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyle.java @@ -0,0 +1,71 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.view.LineStyle; + +import java.util.Base64; + +import static java.lang.String.format; + +class PlantUMLRelationshipStyle extends PlantUMLStyle { + + private final String color; + private final String lineStyle; + private final int thickness; + private final int fontSize; + + PlantUMLRelationshipStyle(String name, String color, LineStyle lineStyle, int thickness, int fontSize) { + super(name); + + this.color = color; + this.thickness = thickness; + this.fontSize = fontSize; + + switch (lineStyle) { + case Dotted: + this.lineStyle = (thickness) + "-" + (thickness); + break; + case Dashed: + this.lineStyle = (thickness * 5) + "-" + (thickness * 5); + break; + default: + this.lineStyle = "0"; + break; + } + } + + @Override + String getClassSelector() { + return "Relationship-" + Base64.getEncoder().encodeToString(name.getBytes()); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + PlantUMLRelationshipStyle that = (PlantUMLRelationshipStyle) o; + return getClassSelector().equals(that.getClassSelector()); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("// %s", name)); + writer.writeLine(format(".%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("LineThickness: %s;", thickness)); + writer.writeLine(String.format("LineStyle: %s;", lineStyle)); + writer.writeLine(String.format("LineColor: %s;", color)); + writer.writeLine(String.format("FontColor: %s;", color)); + writer.writeLine(String.format("FontSize: %s;", fontSize)); + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRootStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRootStyle.java new file mode 100644 index 000000000..769b21584 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLRootStyle.java @@ -0,0 +1,52 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.IndentingWriter; +import com.structurizr.util.StringUtils; + +import static java.lang.String.format; + +class PlantUMLRootStyle extends PlantUMLStyle { + + private final String background; + private final String color; + private final String fontName; + + PlantUMLRootStyle(String background, String color, String fontName) { + super(".root"); + + this.background = background; + this.color = color; + this.fontName = fontName; + } + + @Override + String getClassSelector() { + return "root"; + } + + @Override + public boolean equals(Object o) { + return o != null && getClass() == o.getClass(); + } + + @Override + public String toString() { + IndentingWriter writer = new IndentingWriter(); + writer.indent(); + writer.writeLine(format("%s {", getClassSelector())); + writer.indent(); + + writer.writeLine(String.format("BackgroundColor: %s;", background)); + writer.writeLine(String.format("FontColor: %s;", color)); + if (!StringUtils.isNullOrEmpty(fontName)) { + writer.writeLine(String.format("FontName: %s;", fontName)); + } + + writer.outdent(); + writer.writeLine("}"); + writer.outdent(); + writer.writeLine(); + + return writer.toString(); + } +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLStyle.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLStyle.java new file mode 100644 index 000000000..6eaccf324 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/PlantUMLStyle.java @@ -0,0 +1,26 @@ +package com.structurizr.export.plantuml; + +import java.util.Objects; + +abstract class PlantUMLStyle { + + protected static final int SHADOW_DISTANCE = 10; + + protected final String name; + + PlantUMLStyle(String name) { + this.name = name; + } + + String getName() { + return name; + } + + abstract String getClassSelector(); + + @Override + public final int hashCode() { + return Objects.hashCode(getClassSelector()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/README.md b/structurizr-export/src/main/java/com/structurizr/export/plantuml/README.md new file mode 100644 index 000000000..8474c6296 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/README.md @@ -0,0 +1,5 @@ +# PlantUML + +There are two PlantUML exporters in this package - [StructurizrPlantUMLExporter](StructurizrPlantUMLExporter.java) and [C4PlantUMLExporter](C4PlantUMLExporter.java). + +See https://docs.structurizr.com/export for more. diff --git a/structurizr-export/src/main/java/com/structurizr/export/plantuml/StructurizrPlantUMLExporter.java b/structurizr-export/src/main/java/com/structurizr/export/plantuml/StructurizrPlantUMLExporter.java new file mode 100644 index 000000000..79f53c142 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/plantuml/StructurizrPlantUMLExporter.java @@ -0,0 +1,757 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.export.Legend; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.*; + +import static java.lang.String.format; + +public class StructurizrPlantUMLExporter extends AbstractPlantUMLExporter { + + public static final String PLANTUML_SHADOW = "plantuml.shadow"; + + private static final int DEFAULT_WIDTH = 450; + private static final int DEFAULT_HEIGHT = 300; + private static final int DEFAULT_STROKE_WIDTH = 2; + private static final double METADATA_FONT_SIZE_RATIO = 0.7; + private static final int MAX_ICON_SIZE_RATIO = 3; + + private Set<PlantUMLStyle> plantUMLStyles; + + public StructurizrPlantUMLExporter() { + this(ColorScheme.Light); + } + + public StructurizrPlantUMLExporter(ColorScheme colorScheme) { + super(colorScheme); + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + plantUMLStyles = new HashSet<>(); + super.writeHeader(view, writer); + + if (renderAsSequenceDiagram(view)) { + // do nothing + } else { + if (view.getAutomaticLayout() != null) { + switch (view.getAutomaticLayout().getRankDirection()) { + case LeftRight: + writer.writeLine("left to right direction"); + break; + default: + writer.writeLine("top to bottom direction"); + break; + } + + // the default 300px rank separation in the Structurizr UI is equivalent to a default of 60 in PlantUML + writer.writeLine("skinparam ranksep " + view.getAutomaticLayout().getRankSeparation() / (300/60)); + + // the default 300px node separation in the Structurizr UI is equivalent to a default of 30 in PlantUML + writer.writeLine("skinparam nodesep " + view.getAutomaticLayout().getNodeSeparation() / (300/30)); + } else { + writer.writeLine("top to bottom direction"); + } + } + + writer.writeLine("hide stereotype"); + + writeSkinParams(writer); + + writer.writeLine("<style></style>"); + writer.writeLine(); + + String fontName = null; + Font font = view.getViewSet().getConfiguration().getBranding().getFont(); + if (font != null) { + fontName = font.getName(); + if (!StringUtils.isNullOrEmpty(fontName)) { + writer.writeLine("FontName: " + fontName); + } + } + if (colorScheme == ColorScheme.Dark) { + plantUMLStyles.add(new PlantUMLRootStyle( + Styles.DEFAULT_BACKGROUND_DARK, + Styles.DEFAULT_COLOR_DARK, + fontName)); + } else { + plantUMLStyles.add(new PlantUMLRootStyle( + Styles.DEFAULT_BACKGROUND_LIGHT, + Styles.DEFAULT_COLOR_LIGHT, + fontName)); + } + + writeIncludes(view, writer); + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + super.writeFooter(view, writer); + writeStyles(writer); + } + + private void writeStyles(IndentingWriter writer) { + StringBuilder styles = new StringBuilder(); + List<PlantUMLStyle> sortedStyles = plantUMLStyles.stream().sorted(Comparator.comparing(PlantUMLStyle::getName)).toList(); + + sortedStyles.stream().filter(style -> style instanceof PlantUMLRootStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLElementStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLRelationshipStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLDeploymentNodeStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLBoundaryStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLGroupStyle).forEach(style -> styles.append(style.toString())); + sortedStyles.stream().filter(style -> style instanceof PlantUMLLegendStyle).forEach(style -> styles.append(style.toString())); + + writer.replace("<style></style>", "<style>\n" + styles + "</style>"); + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + String groupName = group; + + String groupSeparator = view.getModel().getProperties().get(GROUP_SEPARATOR_PROPERTY_NAME); + if (!StringUtils.isNullOrEmpty(groupSeparator)) { + groupName = group.substring(group.lastIndexOf(groupSeparator) + groupSeparator.length()); + } + + ElementStyle elementStyle = findGroupStyle(view, group); + PlantUMLGroupStyle plantUMLBoundaryStyle = new PlantUMLGroupStyle( + group, + elementStyle.getBackground(), + elementStyle.getColor(), + elementStyle.getStroke(), + elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH, + elementStyle.getBorder(), + elementStyle.getFontSize(), + "true".equalsIgnoreCase(elementStyle.getProperties().getOrDefault(PLANTUML_SHADOW, "false")) + ); + plantUMLStyles.add(plantUMLBoundaryStyle); + + if (!renderAsSequenceDiagram(view)) { + String icon = elementStyle.getIcon(); + if (!StringUtils.isNullOrEmpty(icon)) { + double scale = calculateIconScale(icon, elementStyle.getFontSize() * MAX_ICON_SIZE_RATIO); + icon = "\\n\\n<img:" + icon + "{scale=" + scale + "}>"; + } else { + icon = ""; + } + + writer.writeLine( + String.format( + "rectangle \"%s%s\" <<%s>> as group%s {", + groupName, + icon, + classSelectorForGroup(group), + Base64.getEncoder().encodeToString(group.getBytes())) + ); + writer.indent(); + } + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + if (!renderAsSequenceDiagram(view)) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + if (!renderAsSequenceDiagram(view)) { + ElementStyle elementStyle = findBoundaryStyle(view, softwareSystem); + PlantUMLBoundaryStyle plantUMLBoundaryStyle = new PlantUMLBoundaryStyle( + softwareSystem.getName(), + elementStyle.getBackground(), + elementStyle.getColor(), + elementStyle.getStroke(), + elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH, + elementStyle.getBorder(), + elementStyle.getFontSize(), + "true".equalsIgnoreCase(elementStyle.getProperties().getOrDefault(PLANTUML_SHADOW, "false")) + ); + plantUMLStyles.add(plantUMLBoundaryStyle); + + writer.writeLine( + String.format( + "rectangle \"%s\\n<size:%s>%s</size>\" <<%s>> {", + softwareSystem.getName(), + calculateMetadataFontSize(elementStyle.getFontSize()), + typeOf(view, softwareSystem, true), + plantUMLBoundaryStyle.getClassSelector() + ) + ); + writer.indent(); + } + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + if (!renderAsSequenceDiagram(view)) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + if (!renderAsSequenceDiagram(view)) { + ElementStyle elementStyle = findBoundaryStyle(view, container); + PlantUMLBoundaryStyle plantUMLBoundaryStyle = new PlantUMLBoundaryStyle( + container.getName(), + elementStyle.getBackground(), + elementStyle.getColor(), + elementStyle.getStroke(), + elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH, + elementStyle.getBorder(), + elementStyle.getFontSize(), + "true".equalsIgnoreCase(elementStyle.getProperties().getOrDefault(PLANTUML_SHADOW, "false")) + ); + plantUMLStyles.add(plantUMLBoundaryStyle); + + writer.writeLine( + String.format( + "rectangle \"%s\\n<size:%s>%s</size>\" <<%s>> {", + container.getName(), + calculateMetadataFontSize(findBoundaryStyle(view, container).getFontSize()), typeOf(view, container, true), + plantUMLBoundaryStyle.getClassSelector())); + writer.indent(); + } + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + if (!renderAsSequenceDiagram(view)) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + ElementStyle elementStyle = findElementStyle(view, deploymentNode); + + PlantUMLDeploymentNodeStyle plantUMLDeploymentNodeStyle = new PlantUMLDeploymentNodeStyle( + elementStyle.getTag(), + elementStyle.getBackground(), + elementStyle.getColor(), + elementStyle.getStroke(), + elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH, + elementStyle.getBorder(), + elementStyle.getFontSize(), + elementStyle.getIcon(), + "true".equalsIgnoreCase(elementStyle.getProperties().getOrDefault(PLANTUML_SHADOW, "false")) + ); + plantUMLStyles.add(plantUMLDeploymentNodeStyle); + + String icon = ""; + if (isSupportedIcon(elementStyle.getIcon())) { + double scale = calculateIconScale(elementStyle.getIcon(), elementStyle.getFontSize() * MAX_ICON_SIZE_RATIO); + icon = "\\n\\n<img:" + elementStyle.getIcon() + "{scale=" + scale + "}>"; + } + + String url = deploymentNode.getUrl(); + if (!StringUtils.isNullOrEmpty(url)) { + url = " [[" + url + "]]"; + } else { + url = ""; + } + + writer.writeLine( + format( + "rectangle \"%s\\n<size:%s>%s</size>%s\" <<%s>> as %s%s {", + deploymentNode.getName() + (!"1".equals(deploymentNode.getInstances()) ? " (x" + deploymentNode.getInstances() + ")" : ""), + calculateMetadataFontSize(findElementStyle(view, deploymentNode).getFontSize()), + typeOf(view, deploymentNode, true), + icon, + plantUMLDeploymentNodeStyle.getClassSelector(), + idOf(deploymentNode), + url + ) + ); + writer.indent(); + + if (!isVisible(view, deploymentNode)) { + writer.writeLine("hide " + idOf(deploymentNode)); + } + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + writer.outdent(); + writer.writeLine("}"); + writer.writeLine(); + } + + @Override + public Diagram export(DynamicView view) { + if (renderAsSequenceDiagram(view)) { + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + Set<Element> elements = new LinkedHashSet<>(); + for (RelationshipView relationshipView : view.getRelationships()) { + elements.add(relationshipView.getRelationship().getSource()); + elements.add(relationshipView.getRelationship().getDestination()); + } + + for (Element element : elements) { + writeElement(view, element, writer); + } + + if (!elements.isEmpty()) { + writer.writeLine(); + } + + writeRelationships(view, writer); + writeFooter(view, writer); + + Diagram diagram = createDiagram(view, writer.toString()); + diagram.setLegend(createLegend(view)); + + return diagram; + } else { + return super.export(view); + } + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + ElementStyle elementStyle = findElementStyle(view, element); + + if (elementStyle.getWidth() == null) { + elementStyle.setWidth(DEFAULT_WIDTH); + } + + if (elementStyle.getHeight() == null) { + elementStyle.setHeight(DEFAULT_HEIGHT); + } + + String sequenceDiagramShape = plantumlSequenceType(view, element); + + if (renderAsSequenceDiagram(view)) { + // actor and database require special treatment because the label sits outside the shape + if ("actor".equals(sequenceDiagramShape) || "database".equals(sequenceDiagramShape)) { + elementStyle.color(elementStyle.getStroke()); + } + } + + PlantUMLElementStyle plantUMLElementStyle = new PlantUMLElementStyle( + elementStyle.getTag(), + elementStyle.getShape(), + elementStyle.getWidth(), + elementStyle.getBackground(), + elementStyle.getColor(), + elementStyle.getStroke(), + renderAsSequenceDiagram(view) ? DEFAULT_STROKE_WIDTH : elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH, + elementStyle.getBorder(), + elementStyle.getFontSize(), + elementStyle.getIcon(), + "true".equalsIgnoreCase(elementStyle.getProperties().getOrDefault(PLANTUML_SHADOW, "false")) + ); + plantUMLStyles.add(plantUMLElementStyle); + + int metadataFontSize = calculateMetadataFontSize(elementStyle.getFontSize()); + + if (renderAsSequenceDiagram(view)) { + writer.writeLine(String.format("%s \"%s\\n<size:%s>%s</size>\" as %s <<%s>>", + sequenceDiagramShape, + element.getName(), + metadataFontSize, + typeOf(view, element, true), + idOf(element), + plantUMLElementStyle.getClassSelector() + )); + } else { + String shape = plantUMLShapeOf(view, element); + String name = element.getName(); + String description = element.getDescription(); + String type = typeOf(view, element, true); + String icon = ""; + String url = element.getUrl(); + + if (element instanceof StaticStructureElementInstance) { + StaticStructureElementInstance elementInstance = (StaticStructureElementInstance) element; + name = elementInstance.getElement().getName(); + description = elementInstance.getElement().getDescription(); + type = typeOf(view, elementInstance.getElement(), true); + shape = plantUMLShapeOf(view, elementInstance.getElement()); + url = elementInstance.getUrl(); + + if (StringUtils.isNullOrEmpty(url)) { + url = elementInstance.getElement().getUrl(); + } + } + + if (!StringUtils.isNullOrEmpty(url)) { + url = " [[" + url + "]]"; + } else { + url = ""; + } + + if (StringUtils.isNullOrEmpty(description) || false == elementStyle.getDescription()) { + description = ""; + } else { + description = "\\n\\n" + description; + } + + if (StringUtils.isNullOrEmpty(type) || false == elementStyle.getMetadata()) { + type = ""; + } else { + type = String.format("\\n<size:%s>%s</size>", metadataFontSize, type); + } + + if (isSupportedIcon(elementStyle.getIcon())) { + double scale = calculateIconScale(elementStyle.getIcon(), elementStyle.getFontSize() * MAX_ICON_SIZE_RATIO); + icon = "\\n\\n<img:" + elementStyle.getIcon() + "{scale=" + scale + "}>"; + } + + String classSelector = plantUMLElementStyle.getClassSelector(); + String id = idOf(element); + + writer.writeLine(format("%s \"==%s%s%s%s\" <<%s>> as %s%s", + shape, + name, + type, + icon, + description, + classSelector, + id, + url) + ); + + if (!isVisible(view, element)) { + writer.writeLine("hide " + id); + } + } + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + Relationship relationship = relationshipView.getRelationship(); + RelationshipStyle style = findRelationshipStyle(view, relationship); + + PlantUMLRelationshipStyle plantUMLRelationshipStyle = new PlantUMLRelationshipStyle( + style.getTag(), + style.getColor(), + style.getStyle(), + renderAsSequenceDiagram(view) ? DEFAULT_STROKE_WIDTH : style.getThickness(), + style.getFontSize() + ); + plantUMLStyles.add(plantUMLRelationshipStyle); + + int metadataFontSize = calculateMetadataFontSize(style.getFontSize()); + String description = ""; + String technology = relationship.getTechnology(); + + if (!StringUtils.isNullOrEmpty(relationshipView.getOrder())) { + description = relationshipView.getOrder() + ": "; + } + + description += (hasValue(relationshipView.getDescription()) ? relationshipView.getDescription() : hasValue(relationshipView.getRelationship().getDescription()) ? relationshipView.getRelationship().getDescription() : ""); + + if (renderAsSequenceDiagram(view)) { + String arrowStart = "-"; + String arrowEnd = ">"; + + if (relationshipView.isResponse() != null && relationshipView.isResponse() == true) { + arrowStart = "<-"; + arrowEnd = "-"; + } + + writer.writeLine( + String.format("%s %s%s %s <<%s>> : %s%s", + idOf(relationship.getSource()), + arrowStart, + arrowEnd, + idOf(relationship.getDestination()), + plantUMLRelationshipStyle.getClassSelector(), + description, + (StringUtils.isNullOrEmpty(technology) ? "" : "\\n<size:" + metadataFontSize + ">[" + technology + "]</size>"))); + } else { + String arrow; + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + arrow = "<--"; + } else { + arrow = "-->"; + } + + // 1 --> 2 : "...\n<size:..>...</size> + writer.writeLine(format("%s %s %s <<%s>> : \"%s%s\"", + idOf(relationship.getSource()), + arrow, + idOf(relationship.getDestination()), + plantUMLRelationshipStyle.getClassSelector(), + description, + (StringUtils.isNullOrEmpty(technology) ? "" : "\\n<size:" + metadataFontSize + ">[" + technology + "]</size>") + )); + } + } + + @Override + protected Legend createLegend(ModelView view) { + IndentingWriter writer = new IndentingWriter(); + + writer.writeLine("@startuml"); + writer.writeLine(); + writer.writeLine("set separator none"); + writer.writeLine("hide stereotype"); + writer.writeLine(); + + writer.writeLine("<style></style>"); + writer.writeLine(); + + plantUMLStyles.stream().sorted(Comparator.comparing(PlantUMLStyle::getName)).filter(style -> style instanceof PlantUMLDeploymentNodeStyle).map(style -> (PlantUMLDeploymentNodeStyle)style).forEach(style -> { + style.setWidth(200); + String description = style.getName(); + if (description.startsWith("Element,")) { + description = description.substring("Element,".length()); + } + description = description.replaceAll(",", ", "); + + String icon = ""; + if (isSupportedIcon(style.getIcon())) { + double scale = calculateIconScale(style.getIcon(), style.getFontSize() * MAX_ICON_SIZE_RATIO); + icon = "\\n\\n<img:" + style.getIcon() + "{scale=" + scale + "}>"; + } + + writer.writeLine(format("rectangle \"==%s%s\" <<%s>>", + description, + icon, + style.getClassSelector()) + ); + writer.writeLine(); + }); + + plantUMLStyles.stream().sorted(Comparator.comparing(PlantUMLStyle::getName)).filter(style -> style instanceof PlantUMLElementStyle).map(style -> (PlantUMLElementStyle)style).forEach(style -> { + style.setWidth(200); + String description = style.getName(); + if (description.startsWith("Element,")) { + description = description.substring("Element,".length()); + } + description = description.replaceAll(",", ", "); + + String icon = ""; + if (isSupportedIcon(style.getIcon())) { + double scale = calculateIconScale(style.getIcon(), style.getFontSize() * MAX_ICON_SIZE_RATIO); + icon = "\\n\\n<img:" + style.getIcon() + "{scale=" + scale + "}>"; + } + + writer.writeLine(format("%s \"==%s%s\" <<%s>>", + plantUMLShapeOf(style.getShape()), + description, + icon, + style.getClassSelector()) + ); + writer.writeLine(); + }); + + int id = 0; + List<PlantUMLRelationshipStyle> relationshipStyles = plantUMLStyles.stream().sorted(Comparator.comparing(PlantUMLStyle::getName)).filter(style -> style instanceof PlantUMLRelationshipStyle).map(style -> (PlantUMLRelationshipStyle)style).toList(); + for (PlantUMLRelationshipStyle relationshipStyle : relationshipStyles) { + id++; + String description = relationshipStyle.getName(); + if (description.startsWith("Relationship,")) { + description = description.substring("Relationship,".length()); + } + description = description.replaceAll(",", ", "); + + // id --> id : "..." + writer.writeLine(format("rectangle \".\" <<.Element-Transparent>> as %s", id)); + writer.writeLine(format("%s --> %s <<%s>> : \"%s\"", + id, + id, + relationshipStyle.getClassSelector(), + description) + ); + + writer.writeLine(); + }; + + writer.writeLine("@enduml"); + + plantUMLStyles.add(new PlantUMLLegendStyle()); + writeStyles(writer); + + return new Legend(writer.toString()); + } + + protected boolean renderAsSequenceDiagram(ModelView view) { + return view instanceof DynamicView && "true".equalsIgnoreCase(getViewOrViewSetProperty(view, PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "false")); + } + + private ElementStyle findBoundaryStyle(ModelView view, Element element) { + String background = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_BACKGROUND_DARK : Styles.DEFAULT_BACKGROUND_LIGHT; + String stroke = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_COLOR_DARK : Styles.DEFAULT_COLOR_LIGHT; + int strokeWidth = DEFAULT_STROKE_WIDTH; + Border border = Border.Dotted; + String color = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_COLOR_DARK : Styles.DEFAULT_COLOR_LIGHT; + String icon = ""; + int fontSize = DEFAULT_FONT_SIZE; + + String type = element instanceof SoftwareSystem ? "SoftwareSystem" : "Container"; + + ElementStyle style = new ElementStyle(""); + ElementStyle elementStyleForBoundary = findElementStyle(view, "Boundary:" + type); + ElementStyle elementStyleForAllBoundaries = findElementStyle(view, "Boundary"); + ElementStyle elementStyleForElement = findElementStyle(view, element); + + if (elementStyleForBoundary != null && !StringUtils.isNullOrEmpty(elementStyleForBoundary.getBackground())) { + background = elementStyleForBoundary.getBackground(); + } else if (elementStyleForAllBoundaries != null && !StringUtils.isNullOrEmpty(elementStyleForAllBoundaries.getBackground())) { + background = elementStyleForAllBoundaries.getBackground(); + } + style.setBackground(background); + + if (elementStyleForBoundary != null && !StringUtils.isNullOrEmpty(elementStyleForBoundary.getStroke())) { + stroke = elementStyleForBoundary.getStroke(); + } else if (elementStyleForAllBoundaries != null && !StringUtils.isNullOrEmpty(elementStyleForAllBoundaries.getStroke())) { + stroke = elementStyleForAllBoundaries.getStroke(); + } else if (!StringUtils.isNullOrEmpty(elementStyleForElement.getStroke())) { + stroke = elementStyleForElement.getStroke(); + } + style.setStroke(stroke); + + if (elementStyleForBoundary != null && elementStyleForBoundary.getStrokeWidth() != null) { + strokeWidth = elementStyleForBoundary.getStrokeWidth(); + } else if (elementStyleForAllBoundaries != null && elementStyleForAllBoundaries.getStrokeWidth() != null) { + strokeWidth = elementStyleForAllBoundaries.getStrokeWidth(); + } else if (elementStyleForElement.getStrokeWidth() != null) { + strokeWidth = elementStyleForElement.getStrokeWidth(); + } + style.setStrokeWidth(strokeWidth); + + if (elementStyleForBoundary != null && !StringUtils.isNullOrEmpty(elementStyleForBoundary.getColor())) { + color = elementStyleForBoundary.getColor(); + } else if (elementStyleForAllBoundaries != null && !StringUtils.isNullOrEmpty(elementStyleForAllBoundaries.getColor())) { + color = elementStyleForAllBoundaries.getColor(); + } else if (!StringUtils.isNullOrEmpty(elementStyleForElement.getColor())) { + color = elementStyleForElement.getColor(); + } + style.setColor(color); + + if (elementStyleForBoundary != null && elementStyleForBoundary.getBorder() != null) { + border = elementStyleForBoundary.getBorder(); + } else if (elementStyleForAllBoundaries != null && elementStyleForAllBoundaries.getBorder() != null) { + border = elementStyleForAllBoundaries.getBorder(); + } else if (elementStyleForElement.getBorder() != null) { + border = elementStyleForElement.getBorder(); + } + style.setBorder(border); + + if (elementStyleForBoundary != null && isSupportedIcon(elementStyleForBoundary.getIcon())) { + icon = elementStyleForBoundary.getIcon(); + } else if (elementStyleForAllBoundaries != null && isSupportedIcon(elementStyleForAllBoundaries.getIcon())) { + icon = elementStyleForAllBoundaries.getIcon(); + } else if (isSupportedIcon(elementStyleForElement.getIcon())) { + icon = elementStyleForElement.getIcon(); + } + style.setIcon(icon); + + if (elementStyleForBoundary != null && elementStyleForBoundary.getFontSize() != null) { + fontSize = elementStyleForBoundary.getFontSize(); + } else if (elementStyleForAllBoundaries != null && elementStyleForAllBoundaries.getFontSize() != null) { + fontSize = elementStyleForAllBoundaries.getFontSize(); + } else if (elementStyleForElement.getFontSize() != null) { + fontSize = elementStyleForElement.getFontSize(); + } + style.setFontSize(fontSize); + + return style; + } + + private String classSelectorForGroup(String group) { + return "Group-" + Base64.getEncoder().encodeToString(group.getBytes()); + } + + private ElementStyle findGroupStyle(ModelView view, String group) { + String background = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_BACKGROUND_DARK : Styles.DEFAULT_BACKGROUND_LIGHT; + String stroke = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_COLOR_DARK : Styles.DEFAULT_COLOR_LIGHT; + int strokeWidth = DEFAULT_STROKE_WIDTH; + Border border = Border.Dotted; + String color = colorScheme == ColorScheme.Dark ? Styles.DEFAULT_COLOR_DARK : Styles.DEFAULT_COLOR_LIGHT; + String icon = ""; + int fontSize = DEFAULT_FONT_SIZE; + + ElementStyle style = new ElementStyle(""); + ElementStyle elementStyleForGroup = findElementStyle(view, "Group:" + group); + ElementStyle elementStyleForAllGroups = findElementStyle(view, "Group"); + + if (elementStyleForGroup != null && !StringUtils.isNullOrEmpty(elementStyleForGroup.getBackground())) { + background = elementStyleForGroup.getBackground(); + } else if (elementStyleForAllGroups != null && !StringUtils.isNullOrEmpty(elementStyleForAllGroups.getBackground())) { + background = elementStyleForAllGroups.getBackground(); + } + style.setBackground(background); + + if (elementStyleForGroup != null && !StringUtils.isNullOrEmpty(elementStyleForGroup.getStroke())) { + stroke = elementStyleForGroup.getStroke(); + } else if (elementStyleForAllGroups != null && !StringUtils.isNullOrEmpty(elementStyleForAllGroups.getStroke())) { + stroke = elementStyleForAllGroups.getStroke(); + } + style.setStroke(stroke); + + if (elementStyleForGroup != null && elementStyleForGroup.getStrokeWidth() != null) { + strokeWidth = elementStyleForGroup.getStrokeWidth(); + } else if (elementStyleForAllGroups != null && elementStyleForAllGroups.getStrokeWidth() != null) { + strokeWidth = elementStyleForAllGroups.getStrokeWidth(); + } + style.setStrokeWidth(strokeWidth); + + if (elementStyleForGroup != null && !StringUtils.isNullOrEmpty(elementStyleForGroup.getColor())) { + color = elementStyleForGroup.getColor(); + } else if (elementStyleForAllGroups != null && !StringUtils.isNullOrEmpty(elementStyleForAllGroups.getColor())) { + color = elementStyleForAllGroups.getColor(); + } + style.setColor(color); + + if (elementStyleForGroup != null && elementStyleForGroup.getBorder() != null) { + border = elementStyleForGroup.getBorder(); + } else if (elementStyleForAllGroups != null && elementStyleForAllGroups.getBorder() != null) { + border = elementStyleForAllGroups.getBorder(); + } + style.setBorder(border); + + if (elementStyleForGroup != null && isSupportedIcon(elementStyleForGroup.getIcon())) { + icon = elementStyleForGroup.getIcon(); + } else if (elementStyleForAllGroups != null && isSupportedIcon(elementStyleForAllGroups.getIcon())) { + icon = elementStyleForAllGroups.getColor(); + } + style.setIcon(icon); + + if (elementStyleForGroup != null && elementStyleForGroup.getFontSize() != null) { + fontSize = elementStyleForGroup.getFontSize(); + } else if (elementStyleForAllGroups != null && elementStyleForAllGroups.getFontSize() != null) { + fontSize = elementStyleForAllGroups.getFontSize(); + } + style.setFontSize(fontSize); + + return style; + } + + private int calculateMetadataFontSize(int fontSize) { + return (int)Math.floor(fontSize * METADATA_FONT_SIZE_RATIO); + } + + private String toLineStyle(ElementStyle elementStyle) { + int strokeWidth = elementStyle.getStrokeWidth() != null ? elementStyle.getStrokeWidth() : DEFAULT_STROKE_WIDTH; + switch (elementStyle.getBorder()) { + case Dotted: + return (strokeWidth * 1) + "-" + (strokeWidth * 1); + case Dashed: + return (strokeWidth * 5) + "-" + (strokeWidth * 5); + default: + return "0"; + } + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/README.md b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/README.md new file mode 100644 index 000000000..f75f782df --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/README.md @@ -0,0 +1,23 @@ +# WebSequenceDiagrams + +The [WebSequenceDiagramExporter](WebSequenceDiagramsDiagram.java) class provides a way to export dynamic views to +diagram definitions that are compatible with [websequencediagrams.com](https://www.websequencediagrams.com). + +## Example usage + +You can either export all dynamic views in a workspace: + +``` +Workspace workspace = ... +WebSequenceDiagramsExporter exporter = new WebSequenceDiagramsExporter(); +Collection<Diagram> diagrams = exporter.export(workspace); +``` + +Or just a single dynamic view: + +``` +Workspace workspace = ... +DynamicView view = ... +WebSequenceDiagramsExporter exporter = new WebSequenceDiagramsExporter(); +Diagram diagram = exporter.export(view); +``` \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsDiagram.java b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsDiagram.java new file mode 100644 index 000000000..73445af84 --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsDiagram.java @@ -0,0 +1,17 @@ +package com.structurizr.export.websequencediagrams; + +import com.structurizr.export.Diagram; +import com.structurizr.view.ModelView; + +public class WebSequenceDiagramsDiagram extends Diagram { + + public WebSequenceDiagramsDiagram(ModelView view, String definition) { + super(view, definition); + } + + @Override + public String getFileExtension() { + return "wsd"; + } + +} \ No newline at end of file diff --git a/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporter.java b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporter.java new file mode 100644 index 000000000..c7932abcc --- /dev/null +++ b/structurizr-export/src/main/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporter.java @@ -0,0 +1,172 @@ +package com.structurizr.export.websequencediagrams; + +import com.structurizr.export.AbstractDiagramExporter; +import com.structurizr.export.Diagram; +import com.structurizr.export.IndentingWriter; +import com.structurizr.model.*; +import com.structurizr.util.StringUtils; +import com.structurizr.view.*; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Exports dynamic diagram definitions that can be copy-pasted + * into https://www.websequencediagrams.com + * + * This implementation only supports a basic sequence of interactions, + * both synchronous and asynchronous. It doesn't support return messages, + * parallel behaviour, etc. + */ +public class WebSequenceDiagramsExporter extends AbstractDiagramExporter { + + private static final String SYNCHRONOUS_INTERACTION = "->"; + private static final String ASYNCHRONOUS_INTERACTION = "->>"; + private static final String SYNCHRONOUS_INTERACTION_RETURN = "-->"; + private static final String ASYNCHRONOUS_INTERACTION_RETURN = "-->>"; + + @Override + public Diagram export(SystemLandscapeView view) { + return null; + } + + @Override + public Diagram export(SystemContextView view) { + return null; + } + + @Override + public Diagram export(ContainerView view) { + return null; + } + + @Override + public Diagram export(ComponentView view) { + return null; + } + + @Override + public Diagram export(DynamicView view) { + IndentingWriter writer = new IndentingWriter(); + writeHeader(view, writer); + + Set<Element> elements = new LinkedHashSet<>(); + for (RelationshipView relationshipView : view.getRelationships()) { + elements.add(relationshipView.getRelationship().getSource()); + elements.add(relationshipView.getRelationship().getDestination()); + } + + for (Element element : elements) { + writeElement(view, element, writer); + } + + writer.writeLine(); + + writeRelationships(view, writer); + writeFooter(view, writer); + + return createDiagram(view, writer.toString()); + } + + @Override + public Diagram export(DeploymentView view) { + return null; + } + + @Override + protected void writeHeader(ModelView view, IndentingWriter writer) { + if (!StringUtils.isNullOrEmpty(view.getDescription())) { + writer.writeLine("title " + view.getName() + "\n" + view.getDescription()); + } else { + writer.writeLine("title " + view.getName()); + } + writer.writeLine(); + } + + @Override + protected void writeFooter(ModelView view, IndentingWriter writer) { + } + + @Override + protected void startGroupBoundary(ModelView view, String group, IndentingWriter writer) { + } + + @Override + protected void endGroupBoundary(ModelView view, IndentingWriter writer) { + } + + @Override + protected void startSoftwareSystemBoundary(ModelView view, SoftwareSystem softwareSystem, IndentingWriter writer) { + } + + @Override + protected void endSoftwareSystemBoundary(ModelView view, IndentingWriter writer) { + } + + @Override + protected void startContainerBoundary(ModelView view, Container container, IndentingWriter writer) { + } + + @Override + protected void endContainerBoundary(ModelView view, IndentingWriter writer) { + } + + @Override + protected void startDeploymentNodeBoundary(DeploymentView view, DeploymentNode deploymentNode, IndentingWriter writer) { + } + + @Override + protected void endDeploymentNodeBoundary(ModelView view, IndentingWriter writer) { + } + + @Override + protected void writeElement(ModelView view, Element element, IndentingWriter writer) { + if (element instanceof Person) { + writer.writeLine(String.format("actor <<%s>>\\n%s as %s", + view.getViewSet().getConfiguration().getTerminology().findTerminology(element), + element.getName(), + element.getName()) + ); + } else { + writer.writeLine(String.format("participant <<%s>>\\n%s as %s", + view.getViewSet().getConfiguration().getTerminology().findTerminology(element), + element.getName(), + element.getName()) + ); + } + } + + @Override + protected void writeRelationship(ModelView view, RelationshipView relationshipView, IndentingWriter writer) { + Relationship r = relationshipView.getRelationship(); + + Element source = r.getSource(); + Element destination = r.getDestination(); + String description = relationshipView.getDescription(); + String arrow = r.getInteractionStyle() == InteractionStyle.Asynchronous ? ASYNCHRONOUS_INTERACTION : SYNCHRONOUS_INTERACTION; + + if (relationshipView.isResponse() != null && relationshipView.isResponse()) { + source = r.getDestination(); + destination = r.getSource(); + arrow = r.getInteractionStyle() == InteractionStyle.Asynchronous ? ASYNCHRONOUS_INTERACTION_RETURN : SYNCHRONOUS_INTERACTION_RETURN; + } + + if (StringUtils.isNullOrEmpty(description)) { + description = relationshipView.getRelationship().getDescription(); + } + + // Thing A->Thing B: Description + writer.writeLine(String.format("%s%s%s: %s", + source.getName(), + arrow, + destination.getName(), + description + )); + } + + @Override + protected Diagram createDiagram(ModelView view, String definition) { + return new WebSequenceDiagramsDiagram(view, definition); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/AbstractExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/AbstractExporterTests.java new file mode 100644 index 000000000..7782650d9 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/AbstractExporterTests.java @@ -0,0 +1,29 @@ +package com.structurizr.export; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +public abstract class AbstractExporterTests { + + protected String readFile(File file) throws Exception { + StringBuilder buf = new StringBuilder(); + + Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + + List<String> lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + + for (String line : lines) { + buf.append(line); + buf.append("\n"); + } + + if (buf.length() > 1) { + return buf.toString().substring(0, buf.length() - 1); + } else { + return buf.toString(); + } + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/IndentingWriterTests.java b/structurizr-export/src/test/java/com/structurizr/export/IndentingWriterTests.java new file mode 100644 index 000000000..7c050b98a --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/IndentingWriterTests.java @@ -0,0 +1,76 @@ +package com.structurizr.export; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class IndentingWriterTests { + + @Test + public void test_WithDefaultSettings() { + IndentingWriter writer = new IndentingWriter(); + + writer.writeLine("Line 1"); + writer.indent(); + writer.writeLine("Line 2"); + writer.indent(); + writer.writeLine("Line 3"); + writer.outdent(); + writer.writeLine("Line 4"); + writer.outdent(); + writer.writeLine("Line 4"); + + assertEquals("Line 1\n" + + " Line 2\n" + + " Line 3\n" + + " Line 4\n" + + "Line 4", writer.toString()); + } + + @Test + public void test_WithSpaces() { + IndentingWriter writer = new IndentingWriter(); + writer.setIndentType(IndentType.Spaces); + writer.setIndentQuantity(4); + + writer.writeLine("Line 1"); + writer.indent(); + writer.writeLine("Line 2"); + writer.indent(); + writer.writeLine("Line 3"); + writer.outdent(); + writer.writeLine("Line 4"); + writer.outdent(); + writer.writeLine("Line 4"); + + assertEquals("Line 1\n" + + " Line 2\n" + + " Line 3\n" + + " Line 4\n" + + "Line 4", writer.toString()); + } + + @Test + public void test_WithTabs() { + IndentingWriter writer = new IndentingWriter(); + writer.setIndentType(IndentType.Tabs); + writer.setIndentQuantity(1); + + writer.writeLine("Line 1"); + writer.indent(); + writer.writeLine("Line 2"); + writer.indent(); + writer.writeLine("Line 3"); + writer.outdent(); + writer.writeLine("Line 4"); + writer.outdent(); + writer.writeLine("Line 4"); + + assertEquals("Line 1\n" + + "\tLine 2\n" + + "\t\tLine 3\n" + + "\tLine 4\n" + + "Line 4", writer.toString()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/dot/DOTDiagramExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/dot/DOTDiagramExporterTests.java new file mode 100644 index 000000000..ca459bddd --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/dot/DOTDiagramExporterTests.java @@ -0,0 +1,1168 @@ +package com.structurizr.export.dot; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.Diagram; +import com.structurizr.http.HttpClient; +import com.structurizr.model.*; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DOTDiagramExporterTests extends AbstractExporterTests { + + @Test + public void test_BigBankPlcExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/big-bank-plc.json")); + DOTExporter dotWriter = new DOTExporter(); + + Collection<Diagram> diagrams = dotWriter.export(workspace); + assertEquals(7, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Landscape View</font>> + + subgraph "cluster_group_Big Bank plc" { + margin=25 + label=<<font point-size="24"><br />Big Bank plc</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="32">Customer Service Staff</font><br /><font point-size="17">[Person]</font><br /><br /><font point-size="22">Customer service staff within the<br />bank.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 3 [id=3,shape=rect, label=<<font point-size="32">Back Office Staff</font><br /><font point-size="17">[Person]</font><br /><br /><font point-size="22">Administration and support staff<br />within the bank.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 4 [id=4,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 5 [id=5,shape=rect, label=<<font point-size="34">E-mail System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">The internal Microsoft<br />Exchange e-mail system.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 6 [id=6,shape=rect, label=<<font point-size="34">ATM</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Allows customers to withdraw<br />cash.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 7 [id=7,shape=rect, label=<<font point-size="34">Internet Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Allows customers to view<br />information about their bank<br />accounts, and make payments.</font>>, style=filled, color="#0b4884", fillcolor="#1168bd", fontcolor="#ffffff"] + } + + 1 [id=1,shape=rect, label=<<font point-size="32">Personal Banking<br />Customer</font><br /><font point-size="17">[Person]</font><br /><br /><font point-size="22">A customer of the bank, with<br />personal bank accounts.</font>>, style=filled, color="#052e56", fillcolor="#08427b", fontcolor="#ffffff"] + + 1 -> 7 [id=19, label=<<font point-size="24">Views account balances,<br />and makes payments using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 7 -> 4 [id=20, label=<<font point-size="24">Gets account information<br />from, and makes payments<br />using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 7 -> 5 [id=21, label=<<font point-size="24">Sends e-mail using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 5 -> 1 [id=22, label=<<font point-size="24">Sends e-mails to</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 2 [id=23, label=<<font point-size="24">Asks questions to</font><br /><font point-size="19">[Telephone]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 2 -> 4 [id=24, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 6 [id=25, label=<<font point-size="24">Withdraws cash using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 6 -> 4 [id=26, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 3 -> 4 [id=27, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemContext")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Context View: Internet Banking System</font><br /><font point-size="24">The system context diagram for the Internet Banking System.</font>> + + subgraph "cluster_group_Big Bank plc" { + margin=25 + label=<<font point-size="24"><br />Big Bank plc</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 4 [id=4,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 5 [id=5,shape=rect, label=<<font point-size="34">E-mail System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">The internal Microsoft<br />Exchange e-mail system.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 7 [id=7,shape=rect, label=<<font point-size="34">Internet Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Allows customers to view<br />information about their bank<br />accounts, and make payments.</font>>, style=filled, color="#0b4884", fillcolor="#1168bd", fontcolor="#ffffff"] + } + + 1 [id=1,shape=rect, label=<<font point-size="32">Personal Banking<br />Customer</font><br /><font point-size="17">[Person]</font><br /><br /><font point-size="22">A customer of the bank, with<br />personal bank accounts.</font>>, style=filled, color="#052e56", fillcolor="#08427b", fontcolor="#ffffff"] + + 1 -> 7 [id=19, label=<<font point-size="24">Views account balances,<br />and makes payments using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 7 -> 4 [id=20, label=<<font point-size="24">Gets account information<br />from, and makes payments<br />using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 7 -> 5 [id=21, label=<<font point-size="24">Sends e-mail using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 5 -> 1 [id=22, label=<<font point-size="24">Sends e-mails to</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Container View: Internet Banking System</font><br /><font point-size="24">The container diagram for the Internet Banking System.</font>> + + 1 [id=1,shape=rect, label=<<font point-size="32">Personal Banking<br />Customer</font><br /><font point-size="17">[Person]</font><br /><br /><font point-size="22">A customer of the bank, with<br />personal bank accounts.</font>>, style=filled, color="#052e56", fillcolor="#08427b", fontcolor="#ffffff"] + 4 [id=4,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 5 [id=5,shape=rect, label=<<font point-size="34">E-mail System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">The internal Microsoft<br />Exchange e-mail system.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + + subgraph cluster_7 { + margin=25 + label=<<font point-size="24"><br />Internet Banking System</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + 10 [id=10,shape=rect, label=<<font point-size="34">Web Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Delivers the static content<br />and the Internet banking<br />single page application.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 11 [id=11,shape=rect, label=<<font point-size="34">API Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Provides Internet banking<br />functionality via a JSON/HTTPS<br />API.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 18 [id=18,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 8 [id=8,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 9 [id=9,shape=rect, label=<<font point-size="34">Mobile App</font><br /><font point-size="19">[Container: Xamarin]</font><br /><br /><font point-size="24">Provides a limited subset of<br />the Internet banking<br />functionality to customers via<br />their mobile device.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + 5 -> 1 [id=22, label=<<font point-size="24">Sends e-mails to</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 10 [id=28, label=<<font point-size="24">Visits bigbank.com/ib<br />using</font><br /><font point-size="19">[HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 8 [id=29, label=<<font point-size="24">Views account balances,<br />and makes payments using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 9 [id=30, label=<<font point-size="24">Views account balances,<br />and makes payments using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 10 -> 8 [id=31, label=<<font point-size="24">Delivers to the customer's<br />web browser</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 8 -> 11 [id=33, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 9 -> 11 [id=37, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 11 -> 18 [id=45, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 11 -> 4 [id=47, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[XML/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 11 -> 5 [id=49, label=<<font point-size="24">Sends e-mail using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Component View: Internet Banking System - API Application</font><br /><font point-size="24">The component diagram for the API Application.</font>> + + 4 [id=4,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + 5 [id=5,shape=rect, label=<<font point-size="34">E-mail System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">The internal Microsoft<br />Exchange e-mail system.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + + subgraph cluster_7 { + margin=25 + label=<<font point-size="24"><br />Internet Banking System</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph cluster_11 { + margin=25 + label=<<font point-size="24"><br />API Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + 12 [id=12,shape=rect, label=<<font point-size="34">Sign In Controller</font><br /><font point-size="19">[Component: Spring MVC Rest Controller]</font><br /><br /><font point-size="24">Allows users to sign in to the<br />Internet Banking System.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 13 [id=13,shape=rect, label=<<font point-size="34">Accounts Summary<br />Controller</font><br /><font point-size="19">[Component: Spring MVC Rest Controller]</font><br /><br /><font point-size="24">Provides customers with a<br />summary of their bank<br />accounts.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 14 [id=14,shape=rect, label=<<font point-size="34">Reset Password<br />Controller</font><br /><font point-size="19">[Component: Spring MVC Rest Controller]</font><br /><br /><font point-size="24">Allows users to reset their<br />passwords with a single use<br />URL.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 15 [id=15,shape=rect, label=<<font point-size="34">Security Component</font><br /><font point-size="19">[Component: Spring Bean]</font><br /><br /><font point-size="24">Provides functionality related<br />to signing in, changing<br />passwords, etc.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 16 [id=16,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System Facade</font><br /><font point-size="19">[Component: Spring Bean]</font><br /><br /><font point-size="24">A facade onto the mainframe<br />banking system.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 17 [id=17,shape=rect, label=<<font point-size="34">E-mail Component</font><br /><font point-size="19">[Component: Spring Bean]</font><br /><br /><font point-size="24">Sends e-mails to users.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + } + + 18 [id=18,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 8 [id=8,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 9 [id=9,shape=rect, label=<<font point-size="34">Mobile App</font><br /><font point-size="19">[Container: Xamarin]</font><br /><br /><font point-size="24">Provides a limited subset of<br />the Internet banking<br />functionality to customers via<br />their mobile device.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + 8 -> 12 [id=32, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 8 -> 13 [id=34, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 8 -> 14 [id=35, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 9 -> 12 [id=36, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 9 -> 13 [id=38, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 9 -> 14 [id=39, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 12 -> 15 [id=40, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 13 -> 16 [id=41, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 14 -> 15 [id=42, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 14 -> 17 [id=43, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 15 -> 18 [id=44, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 16 -> 4 [id=46, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[XML/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 17 -> 5 [id=48, label=<<font point-size="24">Sends e-mail using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Dynamic View: Internet Banking System - API Application</font><br /><font point-size="24">Summarises how the sign in feature works in the single-page application.</font>> + + subgraph cluster_7 { + margin=25 + label=<<font point-size="24"><br />Internet Banking System</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#cccccc" + + subgraph cluster_11 { + margin=25 + label=<<font point-size="24"><br />API Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + 12 [id=12,shape=rect, label=<<font point-size="34">Sign In Controller</font><br /><font point-size="19">[Component: Spring MVC Rest Controller]</font><br /><br /><font point-size="24">Allows users to sign in to the<br />Internet Banking System.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + 15 [id=15,shape=rect, label=<<font point-size="34">Security Component</font><br /><font point-size="19">[Component: Spring Bean]</font><br /><br /><font point-size="24">Provides functionality related<br />to signing in, changing<br />passwords, etc.</font>>, style=filled, color="#5d82a8", fillcolor="#85bbf0", fontcolor="#000000"] + } + + 18 [id=18,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 8 [id=8,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + 8 [id=8,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 18 [id=18,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + + 8 -> 12 [id=32, label=<<font point-size="24">1. Submits credentials to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 12 -> 15 [id=40, label=<<font point-size="24">2. Validates credentials<br />using</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 15 -> 18 [id=44, label=<<font point-size="24">3. select * from users<br />where username = ?</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 18 -> 15 [id=44, label=<<font point-size="24">4. Returns user data to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 15 -> 12 [id=40, label=<<font point-size="24">5. Returns true if the<br />hashed password matches</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 12 -> 8 [id=32, label=<<font point-size="24">6. Sends back an<br />authentication token to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("DevelopmentDeployment")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Deployment View: Internet Banking System - Development</font><br /><font point-size="24">An example development deployment scenario for the Internet Banking System.</font>> + + subgraph cluster_50 { + margin=25 + label=<<font point-size="24">Developer Laptop</font><br /><font point-size="19">[Deployment Node: Microsoft Windows 10 or Apple macOS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_51 { + margin=25 + label=<<font point-size="24">Web Browser</font><br /><font point-size="19">[Deployment Node: Chrome, Firefox, Safari, or Edge]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 52 [id=52,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + subgraph cluster_53 { + margin=25 + label=<<font point-size="24">Docker Container - Web Server</font><br /><font point-size="19">[Deployment Node: Docker]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_54 { + margin=25 + label=<<font point-size="24">Apache Tomcat</font><br /><font point-size="19">[Deployment Node: Apache Tomcat 8.x]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 55 [id=55,shape=rect, label=<<font point-size="34">Web Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Delivers the static content<br />and the Internet banking<br />single page application.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + 57 [id=57,shape=rect, label=<<font point-size="34">API Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Provides Internet banking<br />functionality via a JSON/HTTPS<br />API.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_59 { + margin=25 + label=<<font point-size="24">Docker Container - Database Server</font><br /><font point-size="19">[Deployment Node: Docker]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_60 { + margin=25 + label=<<font point-size="24">Database Server</font><br /><font point-size="19">[Deployment Node: Oracle 12c]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 61 [id=61,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + } + + subgraph cluster_63 { + margin=25 + label=<<font point-size="24">Big Bank plc</font><br /><font point-size="19">[Deployment Node: Big Bank plc data center]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_64 { + margin=25 + label=<<font point-size="24">bigbank-dev001</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 65 [id=65,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + } + + } + + 55 -> 52 [id=56, label=<<font point-size="24">Delivers to the customer's<br />web browser</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 52 -> 57 [id=58, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 57 -> 61 [id=62, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 57 -> 65 [id=66, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[XML/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("LiveDeployment")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Deployment View: Internet Banking System - Live</font><br /><font point-size="24">An example live deployment scenario for the Internet Banking System.</font>> + + subgraph cluster_67 { + margin=25 + label=<<font point-size="24">Customer's mobile device</font><br /><font point-size="19">[Deployment Node: Apple iOS or Android]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 68 [id=68,shape=rect, label=<<font point-size="34">Mobile App</font><br /><font point-size="19">[Container: Xamarin]</font><br /><br /><font point-size="24">Provides a limited subset of<br />the Internet banking<br />functionality to customers via<br />their mobile device.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + subgraph cluster_69 { + margin=25 + label=<<font point-size="24">Customer's computer</font><br /><font point-size="19">[Deployment Node: Microsoft Windows or Apple macOS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_70 { + margin=25 + label=<<font point-size="24">Web Browser</font><br /><font point-size="19">[Deployment Node: Chrome, Firefox, Safari, or Edge]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 71 [id=71,shape=rect, label=<<font point-size="34">Single-Page<br />Application</font><br /><font point-size="19">[Container: JavaScript and Angular]</font><br /><br /><font point-size="24">Provides all of the Internet<br />banking functionality to<br />customers via their web<br />browser.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_72 { + margin=25 + label=<<font point-size="24">Big Bank plc</font><br /><font point-size="19">[Deployment Node: Big Bank plc data center]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_73 { + margin=25 + label=<<font point-size="24">bigbank-web***</font><br /><font point-size="19">[Deployment Node: Ubuntu 16.04 LTS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_74 { + margin=25 + label=<<font point-size="24">Apache Tomcat</font><br /><font point-size="19">[Deployment Node: Apache Tomcat 8.x]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 75 [id=75,shape=rect, label=<<font point-size="34">Web Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Delivers the static content<br />and the Internet banking<br />single page application.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_77 { + margin=25 + label=<<font point-size="24">bigbank-api***</font><br /><font point-size="19">[Deployment Node: Ubuntu 16.04 LTS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_78 { + margin=25 + label=<<font point-size="24">Apache Tomcat</font><br /><font point-size="19">[Deployment Node: Apache Tomcat 8.x]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 79 [id=79,shape=rect, label=<<font point-size="34">API Application</font><br /><font point-size="19">[Container: Java and Spring MVC]</font><br /><br /><font point-size="24">Provides Internet banking<br />functionality via a JSON/HTTPS<br />API.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_82 { + margin=25 + label=<<font point-size="24">bigbank-db01</font><br /><font point-size="19">[Deployment Node: Ubuntu 16.04 LTS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_83 { + margin=25 + label=<<font point-size="24">Oracle - Primary</font><br /><font point-size="19">[Deployment Node: Oracle 12c]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 84 [id=84,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_86 { + margin=25 + label=<<font point-size="24">bigbank-db02</font><br /><font point-size="19">[Deployment Node: Ubuntu 16.04 LTS]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + subgraph cluster_87 { + margin=25 + label=<<font point-size="24">Oracle - Secondary</font><br /><font point-size="19">[Deployment Node: Oracle 12c]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 88 [id=88,shape=cylinder, label=<<font point-size="34">Database</font><br /><font point-size="19">[Container: Oracle Database Schema]</font><br /><br /><font point-size="24">Stores user registration<br />information, hashed<br />authentication credentials,<br />access logs, etc.</font>>, style=filled, color="#2e6295", fillcolor="#438dd5", fontcolor="#ffffff"] + } + + } + + subgraph cluster_90 { + margin=25 + label=<<font point-size="24">bigbank-prod001</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#ffffff" + + 91 [id=91,shape=rect, label=<<font point-size="34">Mainframe Banking<br />System</font><br /><font point-size="19">[Software System]</font><br /><br /><font point-size="24">Stores all of the core banking<br />information about customers,<br />accounts, transactions, etc.</font>>, style=filled, color="#6b6b6b", fillcolor="#999999", fontcolor="#ffffff"] + } + + } + + 75 -> 71 [id=76, label=<<font point-size="24">Delivers to the customer's<br />web browser</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 68 -> 79 [id=80, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 71 -> 79 [id=81, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[JSON/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 79 -> 84 [id=85, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 79 -> 88 [id=89, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[SQL/TCP]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 79 -> 91 [id=92, label=<<font point-size="24">Makes API calls to</font><br /><font point-size="19">[XML/HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 84 -> 88 [id=93, label=<<font point-size="24">Replicates data to</font>>, style="dashed", color="#444444", fontcolor="#444444",ltail=cluster_83,lhead=cluster_87] + + }""", diagram.getDefinition()); + } + + @Test + @Tag("IntegrationTest") + public void test_AmazonWebServicesExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + workspace.getViews().getDeploymentViews().iterator().next().enableAutomaticLayout(AutomaticLayout.RankDirection.LeftRight, 300, 300); + + DOTExporter exporter = new DOTExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=LR, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Deployment View: X - Live</font>> + + subgraph cluster_5 { + margin=25 + label=<<font point-size="24">Amazon Web Services</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#232f3e" + fontcolor="#232f3e" + fillcolor="#ffffff" + + subgraph cluster_6 { + margin=25 + label=<<font point-size="24">US-East-1</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#147eba" + fontcolor="#147eba" + fillcolor="#ffffff" + + subgraph cluster_10 { + margin=25 + label=<<font point-size="24">Autoscaling group</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#cc2264" + fontcolor="#cc2264" + fillcolor="#ffffff" + + subgraph cluster_11 { + margin=25 + label=<<font point-size="24">Amazon EC2 - Ubuntu server</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#d86613" + fontcolor="#d86613" + fillcolor="#ffffff" + + 12 [id=12,shape=rect, label=<<font point-size="34">Web Application</font><br /><font point-size="19">[Container: Java and Spring Boot]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + subgraph cluster_14 { + margin=25 + label=<<font point-size="24">Amazon RDS</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#3b48cc" + fontcolor="#3b48cc" + fillcolor="#ffffff" + + subgraph cluster_15 { + margin=25 + label=<<font point-size="24">MySQL</font><br /><font point-size="19">[Deployment Node]</font>> + labelloc=b + color="#3b48cc" + fontcolor="#3b48cc" + fillcolor="#ffffff" + + 16 [id=16,shape=cylinder, label=<<font point-size="34">Database Schema</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + 7 [id=7,shape=rect, label=<<font point-size="34">DNS router</font><br /><font point-size="19">[Infrastructure Node: Route 53]</font><br /><br /><font point-size="24">Routes incoming requests based<br />upon domain name.</font>>, style=filled, color="#693cc5", fillcolor="#ffffff", fontcolor="#693cc5"] + 8 [id=8,shape=rect, label=<<font point-size="34">Load Balancer</font><br /><font point-size="19">[Infrastructure Node: Elastic Load Balancer]</font><br /><br /><font point-size="24">Automatically distributes<br />incoming application traffic.</font>>, style=filled, color="#693cc5", fillcolor="#ffffff", fontcolor="#693cc5"] + } + + } + + 8 -> 12 [id=13, label=<<font point-size="24">Forwards requests to</font><br /><font point-size="19">[HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 12 -> 16 [id=17, label=<<font point-size="24">Reads from and writes to</font><br /><font point-size="19">[MySQL Protocol/SSL]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + 7 -> 8 [id=9, label=<<font point-size="24">Forwards requests to</font><br /><font point-size="19">[HTTPS]</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + } + + @Test + public void test_GroupsExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/groups.json")); + ThemeUtils.loadThemes(workspace); + + DOTExporter exporter = new DOTExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(3, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Landscape View</font>> + + subgraph "cluster_group_Group 1" { + margin=25 + label=<<font point-size="24"><br />Group 1</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="34">B</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 2" { + margin=25 + label=<<font point-size="24"><br />Group 2</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 3 [id=3,shape=rect, label=<<font point-size="34">C</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + subgraph "cluster_group_Group 3" { + margin=25 + label=<<font point-size="24"><br />Group 3</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 4 [id=4,shape=rect, label=<<font point-size="34">D</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + 1 [id=1,shape=rect, label=<<font point-size="34">A</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + + 2 -> 3 [id=10, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + 3 -> 4 [id=12, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + 1 -> 2 [id=9, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Container View: D</font>> + + 3 [id=3,shape=rect, label=<<font point-size="34">C</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + + subgraph cluster_4 { + margin=25 + label=<<font point-size="24"><br />D</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph "cluster_group_Group 4" { + margin=25 + label=<<font point-size="24"><br />Group 4</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 6 [id=6,shape=rect, label=<<font point-size="34">F</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + 5 [id=5,shape=rect, label=<<font point-size="34">E</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + 3 -> 5 [id=11, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + 3 -> 6 [id=14, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Component View: D - F</font>> + + 3 [id=3,shape=rect, label=<<font point-size="34">C</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + + subgraph cluster_4 { + margin=25 + label=<<font point-size="24"><br />D</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph cluster_6 { + margin=25 + label=<<font point-size="24"><br />F</font><br /><font point-size="19">[Container]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph "cluster_group_Group 5" { + margin=25 + label=<<font point-size="24"><br />Group 5</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 8 [id=8,shape=rect, label=<<font point-size="34">H</font><br /><font point-size="19">[Component]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + 7 [id=7,shape=rect, label=<<font point-size="34">G</font><br /><font point-size="19">[Component]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + 3 -> 7 [id=13, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + 3 -> 8 [id=15, label=<>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + } + + @Test + public void test_NestedGroupsExample() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Team 1"); + a.setGroup("Organisation 1/Department 1/Team 1"); + + SoftwareSystem b = workspace.getModel().addSoftwareSystem("Team 2"); + b.setGroup("Organisation 1/Department 1/Team 2"); + + SoftwareSystem c = workspace.getModel().addSoftwareSystem("Organisation 1"); + c.setGroup("Organisation 1"); + + SoftwareSystem d = workspace.getModel().addSoftwareSystem("Organisation 2"); + d.setGroup("Organisation 2"); + + SoftwareSystem e = workspace.getModel().addSoftwareSystem("Department 1"); + e.setGroup("Organisation 1/Department 1"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description"); + view.addAllElements(); + + DOTExporter exporter = new DOTExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Landscape View</font><br /><font point-size="24">Description</font>> + + subgraph "cluster_group_Organisation 1" { + margin=25 + label=<<font point-size="24"><br />Organisation 1</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 3 [id=3,shape=rect, label=<<font point-size="34">Organisation 1</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + subgraph "cluster_group_Department 1" { + margin=25 + label=<<font point-size="24"><br />Department 1</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 5 [id=5,shape=rect, label=<<font point-size="34">Department 1</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + subgraph "cluster_group_Team 1" { + margin=25 + label=<<font point-size="24"><br />Team 1</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 1 [id=1,shape=rect, label=<<font point-size="34">Team 1</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Team 2" { + margin=25 + label=<<font point-size="24"><br />Team 2</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="34">Team 2</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + } + + subgraph "cluster_group_Organisation 2" { + margin=25 + label=<<font point-size="24"><br />Organisation 2</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 4 [id=4,shape=rect, label=<<font point-size="34">Organisation 2</font><br /><font point-size="19">[Software System]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + + }""", diagram.getDefinition()); + } + + @Test + public void test_renderContainerDiagramWithExternalContainers() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + + container1.uses(container2, "Uses"); + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem1, "Containers", ""); + containerView.add(container1); + containerView.add(container2); + + Diagram diagram = new DOTExporter().export(containerView); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Container View: Software System 1</font>> + + subgraph cluster_1 { + margin=25 + label=<<font point-size="24"><br />Software System 1</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + 2 [id=2,shape=rect, label=<<font point-size="34">Container 1</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph cluster_3 { + margin=25 + label=<<font point-size="24"><br />Software System 2</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#cccccc" + + 4 [id=4,shape=rect, label=<<font point-size="34">Container 2</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + 2 -> 4 [id=5, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + } + + @Test + public void test_renderComponentDiagramWithExternalComponents() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + + component1.uses(component2, "Uses"); + + ComponentView componentView = workspace.getViews().createComponentView(container1, "Components", ""); + componentView.add(component1); + componentView.add(component2); + + Diagram diagram = new DOTExporter().export(componentView); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Component View: Software System 1 - Container 1</font>> + + subgraph cluster_1 { + margin=25 + label=<<font point-size="24"><br />Software System 1</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph cluster_2 { + margin=25 + label=<<font point-size="24"><br />Container 1</font><br /><font point-size="19">[Container]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + 3 [id=3,shape=rect, label=<<font point-size="34">Component 1</font><br /><font point-size="19">[Component]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + subgraph cluster_4 { + margin=25 + label=<<font point-size="24"><br />Software System 2</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#cccccc" + + subgraph cluster_5 { + margin=25 + label=<<font point-size="24"><br />Container 2</font><br /><font point-size="19">[Container]</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#cccccc" + + 6 [id=6,shape=rect, label=<<font point-size="34">Component 2</font><br /><font point-size="19">[Component]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + 3 -> 6 [id=7, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + } + + @Test + public void test_renderGroupStyles() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User 1").setGroup("Group 1"); + workspace.getModel().addPerson("User 2").setGroup("Group 2"); + workspace.getModel().addPerson("User 3").setGroup("Group 3"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", ""); + view.addDefaultElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 1").color("#111111"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 2").color("#222222"); + + DOTExporter exporter = new DOTExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Landscape View</font>> + + subgraph "cluster_group_Group 1" { + margin=25 + label=<<font point-size="24"><br />Group 1</font>> + labelloc=b + color="#111111" + fontcolor="#111111" + fillcolor="#ffffff" + style="dashed" + + 1 [id=1,shape=rect, label=<<font point-size="34">User 1</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 2" { + margin=25 + label=<<font point-size="24"><br />Group 2</font>> + labelloc=b + color="#222222" + fontcolor="#222222" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="34">User 2</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 3" { + margin=25 + label=<<font point-size="24"><br />Group 3</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 3 [id=3,shape=rect, label=<<font point-size="34">User 3</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + + }""", diagram.getDefinition()); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group").color("#aabbcc"); + + diagram = exporter.export(view); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">System Landscape View</font>> + + subgraph "cluster_group_Group 1" { + margin=25 + label=<<font point-size="24"><br />Group 1</font>> + labelloc=b + color="#111111" + fontcolor="#111111" + fillcolor="#ffffff" + style="dashed" + + 1 [id=1,shape=rect, label=<<font point-size="34">User 1</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 2" { + margin=25 + label=<<font point-size="24"><br />Group 2</font>> + labelloc=b + color="#222222" + fontcolor="#222222" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="34">User 2</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 3" { + margin=25 + label=<<font point-size="24"><br />Group 3</font>> + labelloc=b + color="#aabbcc" + fontcolor="#aabbcc" + fillcolor="#ffffff" + style="dashed" + + 3 [id=3,shape=rect, label=<<font point-size="34">User 3</font><br /><font point-size="19">[Person]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + + }""", diagram.getDefinition()); + } + + @Test + public void test_renderCustomView() { + Workspace workspace = new Workspace("Name", "Description"); + Model model = workspace.getModel(); + + CustomElement a = model.addCustomElement("A"); + CustomElement b = model.addCustomElement("B", "Custom", "Description"); + a.uses(b, "Uses"); + + CustomView view = workspace.getViews().createCustomView("key", "Title", "Description"); + view.addDefaultElements(); + + Diagram diagram = new DOTExporter().export(view); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Title</font><br /><font point-size="24">Description</font>> + + 1 [id=1,shape=rect, label=<<font point-size="34">A</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + 2 [id=2,shape=rect, label=<<font point-size="34">B</font><br /><font point-size="19">[Custom]</font><br /><br /><font point-size="24">Description</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + + 1 -> 2 [id=3, label=<<font point-size="24">Uses</font>>, style="dashed", color="#444444", fontcolor="#444444"] + + }""", diagram.getDefinition()); + } + + @Test + public void test_writeContainerViewWithGroupedElements_WithAndWithoutAGroupSeparator() { + Workspace workspace = new Workspace("Name", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System", ""); + Container container1 = softwareSystem.addContainer("Container 1"); + container1.setGroup("Group 1"); + Container container2 = softwareSystem.addContainer("Container 2"); + container2.setGroup("Group 2"); + + ContainerView view = workspace.getViews().createContainerView(softwareSystem, "Containers", ""); + view.addAllElements(); + + DOTExporter exporter = new DOTExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + digraph { + compound=true + graph [fontname="Arial", rankdir=TB, ranksep=1.0, nodesep=1.0] + node [fontname="Arial", shape=box, margin="0.4,0.3"] + edge [fontname="Arial"] + label=<<br /><font point-size="34">Container View: Software System</font>> + + subgraph cluster_1 { + margin=25 + label=<<font point-size="24"><br />Software System</font><br /><font point-size="19">[Software System]</font>> + labelloc=b + color="#444444" + fontcolor="#444444" + fillcolor="#444444" + + subgraph "cluster_group_Group 1" { + margin=25 + label=<<font point-size="24"><br />Group 1</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 2 [id=2,shape=rect, label=<<font point-size="34">Container 1</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + subgraph "cluster_group_Group 2" { + margin=25 + label=<<font point-size="24"><br />Group 2</font>> + labelloc=b + color="#cccccc" + fontcolor="#cccccc" + fillcolor="#ffffff" + style="dashed" + + 3 [id=3,shape=rect, label=<<font point-size="34">Container 2</font><br /><font point-size="19">[Container]</font>>, style=filled, color="#444444", fillcolor="#ffffff", fontcolor="#444444"] + } + + } + + }""", diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/ilograph/IlographExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/ilograph/IlographExporterTests.java new file mode 100644 index 000000000..88d8184f4 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/ilograph/IlographExporterTests.java @@ -0,0 +1,837 @@ +package com.structurizr.export.ilograph; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.WorkspaceExport; +import com.structurizr.http.HttpClient; +import com.structurizr.model.CustomElement; +import com.structurizr.model.Model; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.ThemeUtils; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class IlographExporterTests extends AbstractExporterTests { + + @Test + public void test_BigBankPlcExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/big-bank-plc.json")); + IlographExporter ilographExporter = new IlographExporter(); + WorkspaceExport export = ilographExporter.export(workspace); + + assertEquals(""" +resources: + - id: "1" + name: "Personal Banking Customer" + subtitle: "[Person]" + description: "A customer of the bank, with personal bank accounts." + backgroundColor: "#08427b" + color: "#ffffff" + + - id: "2" + name: "Customer Service Staff" + subtitle: "[Person]" + description: "Customer service staff within the bank." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "3" + name: "Back Office Staff" + subtitle: "[Person]" + description: "Administration and support staff within the bank." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "4" + name: "Mainframe Banking System" + subtitle: "[Software System]" + description: "Stores all of the core banking information about customers, accounts, transactions, etc." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "5" + name: "E-mail System" + subtitle: "[Software System]" + description: "The internal Microsoft Exchange e-mail system." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "6" + name: "ATM" + subtitle: "[Software System]" + description: "Allows customers to withdraw cash." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "7" + name: "Internet Banking System" + subtitle: "[Software System]" + description: "Allows customers to view information about their bank accounts, and make payments." + backgroundColor: "#1168bd" + color: "#ffffff" + + children: + - id: "10" + name: "Web Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Delivers the static content and the Internet banking single page application." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "11" + name: "API Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Provides Internet banking functionality via a JSON/HTTPS API." + backgroundColor: "#438dd5" + color: "#ffffff" + + children: + - id: "12" + name: "Sign In Controller" + subtitle: "[Component: Spring MVC Rest Controller]" + description: "Allows users to sign in to the Internet Banking System." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "13" + name: "Accounts Summary Controller" + subtitle: "[Component: Spring MVC Rest Controller]" + description: "Provides customers with a summary of their bank accounts." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "14" + name: "Reset Password Controller" + subtitle: "[Component: Spring MVC Rest Controller]" + description: "Allows users to reset their passwords with a single use URL." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "15" + name: "Security Component" + subtitle: "[Component: Spring Bean]" + description: "Provides functionality related to signing in, changing passwords, etc." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "16" + name: "Mainframe Banking System Facade" + subtitle: "[Component: Spring Bean]" + description: "A facade onto the mainframe banking system." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "17" + name: "E-mail Component" + subtitle: "[Component: Spring Bean]" + description: "Sends e-mails to users." + backgroundColor: "#85bbf0" + color: "#000000" + + - id: "18" + name: "Database" + subtitle: "[Container: Oracle Database Schema]" + description: "Stores user registration information, hashed authentication credentials, access logs, etc." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "8" + name: "Single-Page Application" + subtitle: "[Container: JavaScript and Angular]" + description: "Provides all of the Internet banking functionality to customers via their web browser." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "9" + name: "Mobile App" + subtitle: "[Container: Xamarin]" + description: "Provides a limited subset of the Internet banking functionality to customers via their mobile device." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "50" + name: "Developer Laptop" + subtitle: "[Deployment Node: Microsoft Windows 10 or Apple macOS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "51" + name: "Web Browser" + subtitle: "[Deployment Node: Chrome, Firefox, Safari, or Edge]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "52" + name: "Single-Page Application" + subtitle: "[Container: JavaScript and Angular]" + description: "Provides all of the Internet banking functionality to customers via their web browser." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "53" + name: "Docker Container - Web Server" + subtitle: "[Deployment Node: Docker]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "54" + name: "Apache Tomcat" + subtitle: "[Deployment Node: Apache Tomcat 8.x]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "55" + name: "Web Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Delivers the static content and the Internet banking single page application." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "57" + name: "API Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Provides Internet banking functionality via a JSON/HTTPS API." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "59" + name: "Docker Container - Database Server" + subtitle: "[Deployment Node: Docker]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "60" + name: "Database Server" + subtitle: "[Deployment Node: Oracle 12c]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "61" + name: "Database" + subtitle: "[Container: Oracle Database Schema]" + description: "Stores user registration information, hashed authentication credentials, access logs, etc." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "63" + name: "Big Bank plc" + subtitle: "[Deployment Node: Big Bank plc data center]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "64" + name: "bigbank-dev001" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "65" + name: "Mainframe Banking System" + subtitle: "[Software System]" + description: "Stores all of the core banking information about customers, accounts, transactions, etc." + backgroundColor: "#999999" + color: "#ffffff" + + - id: "67" + name: "Customer's mobile device" + subtitle: "[Deployment Node: Apple iOS or Android]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "68" + name: "Mobile App" + subtitle: "[Container: Xamarin]" + description: "Provides a limited subset of the Internet banking functionality to customers via their mobile device." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "69" + name: "Customer's computer" + subtitle: "[Deployment Node: Microsoft Windows or Apple macOS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "70" + name: "Web Browser" + subtitle: "[Deployment Node: Chrome, Firefox, Safari, or Edge]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "71" + name: "Single-Page Application" + subtitle: "[Container: JavaScript and Angular]" + description: "Provides all of the Internet banking functionality to customers via their web browser." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "72" + name: "Big Bank plc" + subtitle: "[Deployment Node: Big Bank plc data center]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "73" + name: "bigbank-web***" + subtitle: "[Deployment Node: Ubuntu 16.04 LTS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "74" + name: "Apache Tomcat" + subtitle: "[Deployment Node: Apache Tomcat 8.x]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "75" + name: "Web Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Delivers the static content and the Internet banking single page application." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "77" + name: "bigbank-api***" + subtitle: "[Deployment Node: Ubuntu 16.04 LTS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "78" + name: "Apache Tomcat" + subtitle: "[Deployment Node: Apache Tomcat 8.x]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "79" + name: "API Application" + subtitle: "[Container: Java and Spring MVC]" + description: "Provides Internet banking functionality via a JSON/HTTPS API." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "82" + name: "bigbank-db01" + subtitle: "[Deployment Node: Ubuntu 16.04 LTS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "83" + name: "Oracle - Primary" + subtitle: "[Deployment Node: Oracle 12c]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "84" + name: "Database" + subtitle: "[Container: Oracle Database Schema]" + description: "Stores user registration information, hashed authentication credentials, access logs, etc." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "86" + name: "bigbank-db02" + subtitle: "[Deployment Node: Ubuntu 16.04 LTS]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "87" + name: "Oracle - Secondary" + subtitle: "[Deployment Node: Oracle 12c]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "88" + name: "Database" + subtitle: "[Container: Oracle Database Schema]" + description: "Stores user registration information, hashed authentication credentials, access logs, etc." + backgroundColor: "#438dd5" + color: "#ffffff" + + - id: "90" + name: "bigbank-prod001" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "91" + name: "Mainframe Banking System" + subtitle: "[Software System]" + description: "Stores all of the core banking information about customers, accounts, transactions, etc." + backgroundColor: "#999999" + color: "#ffffff" + +perspectives: + - name: Static Structure + relations: + - from: "1" + to: "7" + label: "Views account balances, and makes payments using" + color: "#444444" + + - from: "1" + to: "2" + label: "Asks questions to" + description: "Telephone" + color: "#444444" + + - from: "1" + to: "6" + label: "Withdraws cash using" + color: "#444444" + + - from: "2" + to: "4" + label: "Uses" + color: "#444444" + + - from: "3" + to: "4" + label: "Uses" + color: "#444444" + + - from: "5" + to: "1" + label: "Sends e-mails to" + color: "#444444" + + - from: "6" + to: "4" + label: "Uses" + color: "#444444" + + - from: "7" + to: "4" + label: "Gets account information from, and makes payments using" + color: "#444444" + + - from: "7" + to: "5" + label: "Sends e-mail using" + color: "#444444" + + - from: "1" + to: "10" + label: "Visits bigbank.com/ib using" + description: "HTTPS" + color: "#444444" + + - from: "1" + to: "8" + label: "Views account balances, and makes payments using" + color: "#444444" + + - from: "1" + to: "9" + label: "Views account balances, and makes payments using" + color: "#444444" + + - from: "10" + to: "8" + label: "Delivers to the customer's web browser" + color: "#444444" + + - from: "11" + to: "18" + label: "Reads from and writes to" + description: "SQL/TCP" + color: "#444444" + + - from: "11" + to: "4" + label: "Makes API calls to" + description: "XML/HTTPS" + color: "#444444" + + - from: "11" + to: "5" + label: "Sends e-mail using" + color: "#444444" + + - from: "8" + to: "11" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "9" + to: "11" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "12" + to: "15" + label: "Uses" + color: "#444444" + + - from: "13" + to: "16" + label: "Uses" + color: "#444444" + + - from: "14" + to: "15" + label: "Uses" + color: "#444444" + + - from: "14" + to: "17" + label: "Uses" + color: "#444444" + + - from: "15" + to: "18" + label: "Reads from and writes to" + description: "SQL/TCP" + color: "#444444" + + - from: "16" + to: "4" + label: "Makes API calls to" + description: "XML/HTTPS" + color: "#444444" + + - from: "17" + to: "5" + label: "Sends e-mail using" + color: "#444444" + + - from: "8" + to: "12" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "8" + to: "13" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "8" + to: "14" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "9" + to: "12" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "9" + to: "13" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - from: "9" + to: "14" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + + - name: Dynamic: Internet Banking System - API Application + sequence: + start: "8" + steps: + - to: "12" + label: "1. Submits credentials to" + description: "JSON/HTTPS" + color: "#444444" + + - to: "15" + label: "2. Validates credentials using" + color: "#444444" + + - to: "18" + label: "3. select * from users where username = ?" + description: "SQL/TCP" + color: "#444444" + + - to: "15" + label: "4. Returns user data to" + description: "SQL/TCP" + color: "#444444" + + - to: "12" + label: "5. Returns true if the hashed password matches" + color: "#444444" + + - to: "8" + label: "6. Sends back an authentication token to" + description: "JSON/HTTPS" + color: "#444444" + + - name: Deployment - Development + relations: + - from: "52" + to: "57" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + - from: "55" + to: "52" + label: "Delivers to the customer's web browser" + color: "#444444" + - from: "57" + to: "61" + label: "Reads from and writes to" + description: "SQL/TCP" + color: "#444444" + - from: "57" + to: "65" + label: "Makes API calls to" + description: "XML/HTTPS" + color: "#444444" + - name: Deployment - Live + relations: + - from: "68" + to: "79" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + - from: "71" + to: "79" + label: "Makes API calls to" + description: "JSON/HTTPS" + color: "#444444" + - from: "75" + to: "71" + label: "Delivers to the customer's web browser" + color: "#444444" + - from: "79" + to: "84" + label: "Reads from and writes to" + description: "SQL/TCP" + color: "#444444" + - from: "79" + to: "88" + label: "Reads from and writes to" + description: "SQL/TCP" + color: "#444444" + - from: "79" + to: "91" + label: "Makes API calls to" + description: "XML/HTTPS" + color: "#444444\"""", export.getDefinition()); + } + + @Test + @Tag("IntegrationTest") + void test_AmazonWebServicesExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Amazon Web Services - Route 53").addProperty(IlographExporter.ILOGRAPH_ICON, "AWS/Networking/Route-53.svg"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + IlographExporter ilographExporter = new IlographExporter(); + WorkspaceExport export = ilographExporter.export(workspace); + + assertEquals(""" + resources: + - id: "1" + name: "X" + subtitle: "[Software System]" + backgroundColor: "#ffffff" + color: "#444444" + + children: + - id: "2" + name: "Web Application" + subtitle: "[Container: Java and Spring Boot]" + backgroundColor: "#ffffff" + color: "#444444" + + - id: "3" + name: "Database Schema" + subtitle: "[Container]" + backgroundColor: "#ffffff" + color: "#444444" + + - id: "5" + name: "Amazon Web Services" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#232f3e" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png" + + children: + - id: "6" + name: "US-East-1" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#147eba" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png" + + children: + - id: "10" + name: "Autoscaling group" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#cc2264" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png" + + children: + - id: "11" + name: "Amazon EC2 - Ubuntu server" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#d86613" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png" + + children: + - id: "12" + name: "Web Application" + subtitle: "[Container: Java and Spring Boot]" + backgroundColor: "#ffffff" + color: "#444444" + + - id: "14" + name: "Amazon RDS" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#3b48cc" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png" + + children: + - id: "15" + name: "MySQL" + subtitle: "[Deployment Node]" + backgroundColor: "#ffffff" + color: "#3b48cc" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png" + + children: + - id: "16" + name: "Database Schema" + subtitle: "[Container]" + backgroundColor: "#ffffff" + color: "#444444" + + - id: "7" + name: "DNS router" + subtitle: "[Infrastructure Node: Route 53]" + description: "Routes incoming requests based upon domain name." + backgroundColor: "#ffffff" + color: "#693cc5" + icon: "AWS/Networking/Route-53.svg" + + - id: "8" + name: "Load Balancer" + subtitle: "[Infrastructure Node: Elastic Load Balancer]" + description: "Automatically distributes incoming application traffic." + backgroundColor: "#ffffff" + color: "#693cc5" + icon: "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png" + + perspectives: + - name: Static Structure + relations: + - from: "2" + to: "3" + label: "Reads from and writes to" + description: "MySQL Protocol/SSL" + color: "#444444" + + - name: Deployment - Live + relations: + - from: "12" + to: "16" + label: "Reads from and writes to" + description: "MySQL Protocol/SSL" + color: "#444444" + - from: "7" + to: "8" + label: "Forwards requests to" + description: "HTTPS" + color: "#444444" + - from: "8" + to: "12" + label: "Forwards requests to" + description: "HTTPS" + color: "#444444\"""", export.getDefinition()); + } + + @Test + void test_renderCustomElements() { + Workspace workspace = new Workspace("Name", "Description"); + Model model = workspace.getModel(); + + CustomElement a = model.addCustomElement("A"); + CustomElement b = model.addCustomElement("B", "Custom", "Description"); + a.uses(b, "Uses"); + + WorkspaceExport export = new IlographExporter().export(workspace); + assertEquals(""" + resources: + - id: "1" + name: "A" + subtitle: "" + backgroundColor: "#ffffff" + color: "#444444" + + - id: "2" + name: "B" + subtitle: "[Custom]" + description: "Description" + backgroundColor: "#ffffff" + color: "#444444" + + perspectives: + - name: Static Structure + relations: + - from: "1" + to: "2" + label: "Uses" + color: "#444444" + """, export.getDefinition()); + } + + @Test + void test_imports() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.addProperty(IlographExporter.ILOGRAPH_IMPORTS, "NAMESPACE1:path1,NAMESPACE2:path2"); + + WorkspaceExport export = new IlographExporter().export(workspace); + assertEquals(""" +imports: +- from: path1 + namespace: NAMESPACE1 +- from: path2 + namespace: NAMESPACE2 + +resources: +perspectives: + - name: Static Structure + relations:""", export.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/mermaid/MermaidDiagramExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/mermaid/MermaidDiagramExporterTests.java new file mode 100644 index 000000000..1259c57be --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/mermaid/MermaidDiagramExporterTests.java @@ -0,0 +1,479 @@ +package com.structurizr.export.mermaid; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.Diagram; +import com.structurizr.http.HttpClient; +import com.structurizr.model.*; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MermaidDiagramExporterTests extends AbstractExporterTests { + + @Test + @Tag("IntegrationTest") + public void test_AmazonWebServicesExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + workspace.getViews().getDeploymentViews().iterator().next().enableAutomaticLayout(AutomaticLayout.RankDirection.LeftRight, 300, 300); + + MermaidDiagramExporter exporter = new MermaidDiagramExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + graph LR + linkStyle default fill:#ffffff + + subgraph diagram ["Deployment View: X - Live"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph 5 ["Amazon Web Services"] + style 5 fill:#ffffff,stroke:#232f3e,color:#232f3e + + subgraph 6 ["US-East-1"] + style 6 fill:#ffffff,stroke:#147eba,color:#147eba + + subgraph 10 ["Autoscaling group"] + style 10 fill:#ffffff,stroke:#cc2264,color:#cc2264 + + subgraph 11 ["Amazon EC2 - Ubuntu server"] + style 11 fill:#ffffff,stroke:#d86613,color:#d86613 + + 12("<div style='font-weight: bold'>Web Application</div><div style='font-size: 70%; margin-top: 0px'>[Container: Java and Spring Boot]</div>") + style 12 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + subgraph 14 ["Amazon RDS"] + style 14 fill:#ffffff,stroke:#3b48cc,color:#3b48cc + + subgraph 15 ["MySQL"] + style 15 fill:#ffffff,stroke:#3b48cc,color:#3b48cc + + 16[("<div style='font-weight: bold'>Database Schema</div><div style='font-size: 70%; margin-top: 0px'>[Container]</div>")] + style 16 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + 7["<div style='font-weight: bold'>DNS router</div><div style='font-size: 70%; margin-top: 0px'>[Infrastructure Node: Route 53]</div><div style='font-size: 80%; margin-top:10px'>Routes incoming requests<br />based upon domain name.</div>"] + style 7 fill:#ffffff,stroke:#693cc5,color:#693cc5 + 8["<div style='font-weight: bold'>Load Balancer</div><div style='font-size: 70%; margin-top: 0px'>[Infrastructure Node: Elastic Load Balancer]</div><div style='font-size: 80%; margin-top:10px'>Automatically distributes<br />incoming application traffic.</div>"] + style 8 fill:#ffffff,stroke:#693cc5,color:#693cc5 + end + + end + + 8-. "<div>Forwards requests to</div><div style='font-size: 70%'>[HTTPS]</div>" .->12 + 12-. "<div>Reads from and writes to</div><div style='font-size: 70%'>[MySQL Protocol/SSL]</div>" .->16 + 7-. "<div>Forwards requests to</div><div style='font-size: 70%'>[HTTPS]</div>" .->8 + + end""", diagram.getDefinition()); + } + + @Test + public void test_GroupsExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/groups.json")); + ThemeUtils.loadThemes(workspace); + + MermaidDiagramExporter exporter = new MermaidDiagramExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(3, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["System Landscape View"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph group1 ["Group 1"] + style group1 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 2["<div style='font-weight: bold'>B</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group2 ["Group 2"] + style group2 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 3["<div style='font-weight: bold'>C</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + subgraph group3 ["Group 3"] + style group3 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 4["<div style='font-weight: bold'>D</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + 1["<div style='font-weight: bold'>A</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + + 2-. "<div></div><div style='font-size: 70%'></div>" .->3 + 3-. "<div></div><div style='font-size: 70%'></div>" .->4 + 1-. "<div></div><div style='font-size: 70%'></div>" .->2 + + end""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["Container View: D"] + style diagram fill:#ffffff,stroke:#ffffff + + 3["<div style='font-weight: bold'>C</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph 4 ["D"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph group1 ["Group 4"] + style group1 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 6["<div style='font-weight: bold'>F</div><div style='font-size: 70%; margin-top: 0px'>[Container]</div>"] + style 6 fill:#ffffff,stroke:#444444,color:#444444 + end + + 5["<div style='font-weight: bold'>E</div><div style='font-size: 70%; margin-top: 0px'>[Container]</div>"] + style 5 fill:#ffffff,stroke:#444444,color:#444444 + end + + 3-. "<div></div><div style='font-size: 70%'></div>" .->5 + 3-. "<div></div><div style='font-size: 70%'></div>" .->6 + + end""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["Component View: D - F"] + style diagram fill:#ffffff,stroke:#ffffff + + 3["<div style='font-weight: bold'>C</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph 4 ["D"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph 6 ["F"] + style 6 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph group1 ["Group 5"] + style group1 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 8["<div style='font-weight: bold'>H</div><div style='font-size: 70%; margin-top: 0px'>[Component]</div>"] + style 8 fill:#ffffff,stroke:#444444,color:#444444 + end + + 7["<div style='font-weight: bold'>G</div><div style='font-size: 70%; margin-top: 0px'>[Component]</div>"] + style 7 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + 3-. "<div></div><div style='font-size: 70%'></div>" .->7 + 3-. "<div></div><div style='font-size: 70%'></div>" .->8 + + end""", diagram.getDefinition()); + } + + @Test + public void test_NestedGroupsExample() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Team 1"); + a.setGroup("Organisation 1/Department 1/Team 1"); + + SoftwareSystem b = workspace.getModel().addSoftwareSystem("Team 2"); + b.setGroup("Organisation 1/Department 1/Team 2"); + + SoftwareSystem c = workspace.getModel().addSoftwareSystem("Organisation 1"); + c.setGroup("Organisation 1"); + + SoftwareSystem d = workspace.getModel().addSoftwareSystem("Organisation 2"); + d.setGroup("Organisation 2"); + + SoftwareSystem e = workspace.getModel().addSoftwareSystem("Department 1"); + e.setGroup("Organisation 1/Department 1"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description"); + view.addAllElements(); + + MermaidDiagramExporter exporter = new MermaidDiagramExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["System Landscape View"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph group1 ["Organisation 1"] + style group1 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 3["<div style='font-weight: bold'>Organisation 1</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + subgraph group2 ["Department 1"] + style group2 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 5["<div style='font-weight: bold'>Department 1</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 5 fill:#ffffff,stroke:#444444,color:#444444 + subgraph group3 ["Team 1"] + style group3 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 1["<div style='font-weight: bold'>Team 1</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group4 ["Team 2"] + style group4 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 2["<div style='font-weight: bold'>Team 2</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + end + + subgraph group5 ["Organisation 2"] + style group5 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 4["<div style='font-weight: bold'>Organisation 2</div><div style='font-size: 70%; margin-top: 0px'>[Software System]</div>"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + end + + + end""", diagram.getDefinition()); + } + + @Test + public void test_renderContainerDiagramWithExternalContainers() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + + container1.uses(container2, "Uses"); + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem1, "Containers", ""); + containerView.add(container1); + containerView.add(container2); + + Diagram diagram = new MermaidDiagramExporter().export(containerView); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["Container View: Software System 1"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph 1 ["Software System 1"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + + 2["<div style='font-weight: bold'>Container 1</div><div style='font-size: 70%; margin-top: 0px'>[Container]</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph 3 ["Software System 2"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + + 4["<div style='font-weight: bold'>Container 2</div><div style='font-size: 70%; margin-top: 0px'>[Container]</div>"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + end + + 2-. "<div>Uses</div><div style='font-size: 70%'></div>" .->4 + + end""", diagram.getDefinition()); + } + + @Test + public void test_renderComponentDiagramWithExternalComponents() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + + component1.uses(component2, "Uses"); + + ComponentView componentView = workspace.getViews().createComponentView(container1, "Components", ""); + componentView.add(component1); + componentView.add(component2); + + Diagram diagram = new MermaidDiagramExporter().export(componentView); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["Component View: Software System 1 - Container 1"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph 1 ["Software System 1"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph 2 ["Container 1"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + + 3["<div style='font-weight: bold'>Component 1</div><div style='font-size: 70%; margin-top: 0px'>[Component]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + subgraph 4 ["Software System 2"] + style 4 fill:#ffffff,stroke:#444444,color:#444444 + + subgraph 5 ["Container 2"] + style 5 fill:#ffffff,stroke:#444444,color:#444444 + + 6["<div style='font-weight: bold'>Component 2</div><div style='font-size: 70%; margin-top: 0px'>[Component]</div>"] + style 6 fill:#ffffff,stroke:#444444,color:#444444 + end + + end + + 3-. "<div>Uses</div><div style='font-size: 70%'></div>" .->6 + + end""", diagram.getDefinition()); + } + + @Test + public void test_renderGroupStyles() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User 1").setGroup("Group 1"); + workspace.getModel().addPerson("User 2").setGroup("Group 2"); + workspace.getModel().addPerson("User 3").setGroup("Group 3"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", ""); + view.addDefaultElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 1").color("#111111"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 2").color("#222222"); + + MermaidDiagramExporter exporter = new MermaidDiagramExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["System Landscape View"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph group1 ["Group 1"] + style group1 fill:#ffffff,stroke:#111111,color:#111111,stroke-dasharray:5 + + 1["<div style='font-weight: bold'>User 1</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group2 ["Group 2"] + style group2 fill:#ffffff,stroke:#222222,color:#222222,stroke-dasharray:5 + + 2["<div style='font-weight: bold'>User 2</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group3 ["Group 3"] + style group3 fill:#ffffff,stroke:#cccccc,color:#cccccc,stroke-dasharray:5 + + 3["<div style='font-weight: bold'>User 3</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + end + + + end""", diagram.getDefinition()); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group").color("#aabbcc"); + + diagram = exporter.export(view); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["System Landscape View"] + style diagram fill:#ffffff,stroke:#ffffff + + subgraph group1 ["Group 1"] + style group1 fill:#ffffff,stroke:#111111,color:#111111,stroke-dasharray:5 + + 1["<div style='font-weight: bold'>User 1</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group2 ["Group 2"] + style group2 fill:#ffffff,stroke:#222222,color:#222222,stroke-dasharray:5 + + 2["<div style='font-weight: bold'>User 2</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + end + + subgraph group3 ["Group 3"] + style group3 fill:#ffffff,stroke:#aabbcc,color:#aabbcc,stroke-dasharray:5 + + 3["<div style='font-weight: bold'>User 3</div><div style='font-size: 70%; margin-top: 0px'>[Person]</div>"] + style 3 fill:#ffffff,stroke:#444444,color:#444444 + end + + + end""", diagram.getDefinition()); + } + + @Test + public void test_renderCustomView() { + Workspace workspace = new Workspace("Name", "Description"); + Model model = workspace.getModel(); + + CustomElement a = model.addCustomElement("A"); + CustomElement b = model.addCustomElement("B", "Custom", "Description"); + a.uses(b, "Uses"); + + CustomView view = workspace.getViews().createCustomView("key", "Title", "Description"); + view.addDefaultElements(); + + Diagram diagram = new MermaidDiagramExporter().export(view); + assertEquals(""" + graph TB + linkStyle default fill:#ffffff + + subgraph diagram ["Title"] + style diagram fill:#ffffff,stroke:#ffffff + + 1["<div style='font-weight: bold'>A</div><div style='font-size: 70%; margin-top: 0px'></div>"] + style 1 fill:#ffffff,stroke:#444444,color:#444444 + 2["<div style='font-weight: bold'>B</div><div style='font-size: 70%; margin-top: 0px'>[Custom]</div><div style='font-size: 80%; margin-top:10px'>Description</div>"] + style 2 fill:#ffffff,stroke:#444444,color:#444444 + + 1-. "<div>Uses</div><div style='font-size: 70%'></div>" .->2 + + end""", diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/C4PlantUMLDiagramExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/C4PlantUMLDiagramExporterTests.java new file mode 100644 index 000000000..9497bb5c9 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/C4PlantUMLDiagramExporterTests.java @@ -0,0 +1,1798 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.Diagram; +import com.structurizr.http.HttpClient; +import com.structurizr.model.*; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class C4PlantUMLDiagramExporterTests extends AbstractExporterTests { + + @Test + public void test_BigBankPlcExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/big-bank-plc.json")); + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_TAGS_PROPERTY, "true"); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(7, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(d -> d.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddElementTag("Software System", $bgColor="#1168bd", $borderColor="#0b4884", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Person,Bank Staff", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Person,Customer", $bgColor="#08427b", $borderColor="#052e56", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + AddBoundaryTag("Big Bank plc", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Big Bank plc", $tags="Big Bank plc") { + Person(CustomerServiceStaff, "Customer Service Staff", $descr="Customer service staff within the bank.", $tags="Person,Bank Staff", $link="") + Person(BackOfficeStaff, "Back Office Staff", $descr="Administration and support staff within the bank.", $tags="Person,Bank Staff", $link="") + System(MainframeBankingSystem, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + System(EmailSystem, "E-mail System", $descr="The internal Microsoft Exchange e-mail system.", $tags="Software System,Existing System", $link="") + System(ATM, "ATM", $descr="Allows customers to withdraw cash.", $tags="Software System,Existing System", $link="") + System(InternetBankingSystem, "Internet Banking System", $descr="Allows customers to view information about their bank accounts, and make payments.", $tags="Software System", $link="") + } + + Person(PersonalBankingCustomer, "Personal Banking Customer", $descr="A customer of the bank, with personal bank accounts.", $tags="Person,Customer", $link="") + + Rel(PersonalBankingCustomer, InternetBankingSystem, "Views account balances, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem, MainframeBankingSystem, "Gets account information from, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem, EmailSystem, "Sends e-mail using", $techn="", $tags="Relationship", $link="") + Rel(EmailSystem, PersonalBankingCustomer, "Sends e-mails to", $techn="", $tags="Relationship", $link="") + Rel(PersonalBankingCustomer, CustomerServiceStaff, "Asks questions to", $techn="Telephone", $tags="Relationship", $link="") + Rel(CustomerServiceStaff, MainframeBankingSystem, "Uses", $techn="", $tags="Relationship", $link="") + Rel(PersonalBankingCustomer, ATM, "Withdraws cash using", $techn="", $tags="Relationship", $link="") + Rel(ATM, MainframeBankingSystem, "Uses", $techn="", $tags="Relationship", $link="") + Rel(BackOfficeStaff, MainframeBankingSystem, "Uses", $techn="", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("SystemContext")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Context View: Internet Banking System</size>\\n<size:24>The system context diagram for the Internet Banking System.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddElementTag("Software System", $bgColor="#1168bd", $borderColor="#0b4884", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Person,Customer", $bgColor="#08427b", $borderColor="#052e56", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + AddBoundaryTag("Big Bank plc", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Big Bank plc", $tags="Big Bank plc") { + System(MainframeBankingSystem, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + System(EmailSystem, "E-mail System", $descr="The internal Microsoft Exchange e-mail system.", $tags="Software System,Existing System", $link="") + System(InternetBankingSystem, "Internet Banking System", $descr="Allows customers to view information about their bank accounts, and make payments.", $tags="Software System", $link="") + } + + Person(PersonalBankingCustomer, "Personal Banking Customer", $descr="A customer of the bank, with personal bank accounts.", $tags="Person,Customer", $link="") + + Rel(PersonalBankingCustomer, InternetBankingSystem, "Views account balances, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem, MainframeBankingSystem, "Gets account information from, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem, EmailSystem, "Sends e-mail using", $techn="", $tags="Relationship", $link="") + Rel(EmailSystem, PersonalBankingCustomer, "Sends e-mails to", $techn="", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Container View: Internet Banking System</size>\\n<size:24>The container diagram for the Internet Banking System.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Mobile App", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Person,Customer", $bgColor="#08427b", $borderColor="#052e56", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + AddBoundaryTag("Software System", $bgColor="#ffffff", $borderColor="#0b4884", $fontColor="#0b4884", $shadowing="", $borderStyle="solid") + + Person(PersonalBankingCustomer, "Personal Banking Customer", $descr="A customer of the bank, with personal bank accounts.", $tags="Person,Customer", $link="") + System(MainframeBankingSystem, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + System(EmailSystem, "E-mail System", $descr="The internal Microsoft Exchange e-mail system.", $tags="Software System,Existing System", $link="") + + System_Boundary("InternetBankingSystem_boundary", "Internet Banking System", $tags="Software System") { + Container(InternetBankingSystem.WebApplication, "Web Application", $techn="Java and Spring MVC", $descr="Delivers the static content and the Internet banking single page application.", $tags="Container", $link="") + Container(InternetBankingSystem.APIApplication, "API Application", $techn="Java and Spring MVC", $descr="Provides Internet banking functionality via a JSON/HTTPS API.", $tags="Container", $link="") + ContainerDb(InternetBankingSystem.Database, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + Container(InternetBankingSystem.SinglePageApplication, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + Container(InternetBankingSystem.MobileApp, "Mobile App", $techn="Xamarin", $descr="Provides a limited subset of the Internet banking functionality to customers via their mobile device.", $tags="Container,Mobile App", $link="") + } + + Rel(EmailSystem, PersonalBankingCustomer, "Sends e-mails to", $techn="", $tags="Relationship", $link="") + Rel(PersonalBankingCustomer, InternetBankingSystem.WebApplication, "Visits bigbank.com/ib using", $techn="HTTPS", $tags="Relationship", $link="") + Rel(PersonalBankingCustomer, InternetBankingSystem.SinglePageApplication, "Views account balances, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(PersonalBankingCustomer, InternetBankingSystem.MobileApp, "Views account balances, and makes payments using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.WebApplication, InternetBankingSystem.SinglePageApplication, "Delivers to the customer's web browser", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.MobileApp, InternetBankingSystem.APIApplication, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication, InternetBankingSystem.Database, "Reads from and writes to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication, MainframeBankingSystem, "Makes API calls to", $techn="XML/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication, EmailSystem, "Sends e-mail using", $techn="", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Component View: Internet Banking System - API Application</size>\\n<size:24>The component diagram for the API Application.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Component> + + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Component", $bgColor="#85bbf0", $borderColor="#5d82a8", $fontColor="#000000", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Mobile App", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + AddBoundaryTag("Container", $bgColor="#ffffff", $borderColor="#2e6295", $fontColor="#2e6295", $shadowing="", $borderStyle="solid") + + System(MainframeBankingSystem, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + System(EmailSystem, "E-mail System", $descr="The internal Microsoft Exchange e-mail system.", $tags="Software System,Existing System", $link="") + + System_Boundary("InternetBankingSystem_boundary", "Internet Banking System", $tags="Software System") { + Container_Boundary("InternetBankingSystem.APIApplication_boundary", "API Application", $tags="Container") { + Component(InternetBankingSystem.APIApplication.SignInController, "Sign In Controller", $techn="Spring MVC Rest Controller", $descr="Allows users to sign in to the Internet Banking System.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.AccountsSummaryController, "Accounts Summary Controller", $techn="Spring MVC Rest Controller", $descr="Provides customers with a summary of their bank accounts.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.ResetPasswordController, "Reset Password Controller", $techn="Spring MVC Rest Controller", $descr="Allows users to reset their passwords with a single use URL.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.SecurityComponent, "Security Component", $techn="Spring Bean", $descr="Provides functionality related to signing in, changing passwords, etc.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.MainframeBankingSystemFacade, "Mainframe Banking System Facade", $techn="Spring Bean", $descr="A facade onto the mainframe banking system.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.EmailComponent, "E-mail Component", $techn="Spring Bean", $descr="Sends e-mails to users.", $tags="Component", $link="") + } + + ContainerDb(InternetBankingSystem.Database, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + Container(InternetBankingSystem.SinglePageApplication, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + Container(InternetBankingSystem.MobileApp, "Mobile App", $techn="Xamarin", $descr="Provides a limited subset of the Internet banking functionality to customers via their mobile device.", $tags="Container,Mobile App", $link="") + } + + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication.SignInController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication.AccountsSummaryController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication.ResetPasswordController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.MobileApp, InternetBankingSystem.APIApplication.SignInController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.MobileApp, InternetBankingSystem.APIApplication.AccountsSummaryController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.MobileApp, InternetBankingSystem.APIApplication.ResetPasswordController, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SignInController, InternetBankingSystem.APIApplication.SecurityComponent, "Uses", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.AccountsSummaryController, InternetBankingSystem.APIApplication.MainframeBankingSystemFacade, "Uses", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.ResetPasswordController, InternetBankingSystem.APIApplication.SecurityComponent, "Uses", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.ResetPasswordController, InternetBankingSystem.APIApplication.EmailComponent, "Uses", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SecurityComponent, InternetBankingSystem.Database, "Reads from and writes to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.MainframeBankingSystemFacade, MainframeBankingSystem, "Makes API calls to", $techn="XML/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.EmailComponent, EmailSystem, "Sends e-mail using", $techn="", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Internet Banking System - API Application</size>\\n<size:24>Summarises how the sign in feature works in the single-page application.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Component> + + AddElementTag("Component", $bgColor="#85bbf0", $borderColor="#5d82a8", $fontColor="#000000", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + AddBoundaryTag("Container", $bgColor="#ffffff", $borderColor="#2e6295", $fontColor="#2e6295", $shadowing="", $borderStyle="solid") + + System_Boundary("InternetBankingSystem_boundary", "Internet Banking System", $tags="Software System") { + Container_Boundary("InternetBankingSystem.APIApplication_boundary", "API Application", $tags="Container") { + Component(InternetBankingSystem.APIApplication.SignInController, "Sign In Controller", $techn="Spring MVC Rest Controller", $descr="Allows users to sign in to the Internet Banking System.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.SecurityComponent, "Security Component", $techn="Spring Bean", $descr="Provides functionality related to signing in, changing passwords, etc.", $tags="Component", $link="") + } + + ContainerDb(InternetBankingSystem.Database, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + Container(InternetBankingSystem.SinglePageApplication, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + } + + Container(InternetBankingSystem.SinglePageApplication, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + ContainerDb(InternetBankingSystem.Database, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication.SignInController, "1: Submits credentials to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SignInController, InternetBankingSystem.APIApplication.SecurityComponent, "2: Validates credentials using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SecurityComponent, InternetBankingSystem.Database, "3: select * from users where username = ?", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.Database, InternetBankingSystem.APIApplication.SecurityComponent, "4: Returns user data to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SecurityComponent, InternetBankingSystem.APIApplication.SignInController, "5: Returns true if the hashed password matches", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SignInController, InternetBankingSystem.SinglePageApplication, "6: Sends back an authentication token to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("DevelopmentDeployment")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: Internet Banking System - Development</size>\\n<size:24>An example development deployment scenario for the Internet Banking System.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Deployment> + + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Element", $bgColor="#ffffff", $borderColor="#444444", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + Deployment_Node(Development.DeveloperLaptop, "Developer Laptop", $type="Microsoft Windows 10 or Apple macOS", $descr="", $tags="Element", $link="") { + Deployment_Node(Development.DeveloperLaptop.WebBrowser, "Web Browser", $type="Chrome, Firefox, Safari, or Edge", $descr="", $tags="Element", $link="") { + Container(Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + } + + Deployment_Node(Development.DeveloperLaptop.DockerContainerWebServer, "Docker Container - Web Server", $type="Docker", $descr="", $tags="Element", $link="") { + Deployment_Node(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat, "Apache Tomcat", $type="Apache Tomcat 8.x", $descr="", $tags="Element", $link="") { + Container(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.WebApplication_1, "Web Application", $techn="Java and Spring MVC", $descr="Delivers the static content and the Internet banking single page application.", $tags="Container", $link="") + Container(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1, "API Application", $techn="Java and Spring MVC", $descr="Provides Internet banking functionality via a JSON/HTTPS API.", $tags="Container", $link="") + } + + } + + Deployment_Node(Development.DeveloperLaptop.DockerContainerDatabaseServer, "Docker Container - Database Server", $type="Docker", $descr="", $tags="Element", $link="") { + Deployment_Node(Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer, "Database Server", $type="Oracle 12c", $descr="", $tags="Element", $link="") { + ContainerDb(Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer.Database_1, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + } + + } + + } + + Deployment_Node(Development.BigBankplc, "Big Bank plc", $type="Big Bank plc data center", $descr="", $tags="Element", $link="") { + Deployment_Node(Development.BigBankplc.bigbankdev001, "bigbank-dev001", $type="", $descr="", $tags="Element", $link="") { + System(Development.BigBankplc.bigbankdev001.MainframeBankingSystem_1, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + } + + } + + Rel(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.WebApplication_1, Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1, "Delivers to the customer's web browser", $techn="", $tags="Relationship", $link="") + Rel(Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1, Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1, Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer.Database_1, "Reads from and writes to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1, Development.BigBankplc.bigbankdev001.MainframeBankingSystem_1, "Makes API calls to", $techn="XML/HTTPS", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("LiveDeployment")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: Internet Banking System - Live</size>\\n<size:24>An example live deployment scenario for the Internet Banking System.</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Deployment> + + AddElementTag("Failover", $bgColor="#ffffff", $borderColor="#444444", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Software System,Existing System", $bgColor="#999999", $borderColor="#6b6b6b", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Element", $bgColor="#ffffff", $borderColor="#444444", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Mobile App", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + Deployment_Node(Live.Customersmobiledevice, "Customer's mobile device", $type="Apple iOS or Android", $descr="", $tags="Element", $link="") { + Container(Live.Customersmobiledevice.MobileApp_1, "Mobile App", $techn="Xamarin", $descr="Provides a limited subset of the Internet banking functionality to customers via their mobile device.", $tags="Container,Mobile App", $link="") + } + + Deployment_Node(Live.Customerscomputer, "Customer's computer", $type="Microsoft Windows or Apple macOS", $descr="", $tags="Element", $link="") { + Deployment_Node(Live.Customerscomputer.WebBrowser, "Web Browser", $type="Chrome, Firefox, Safari, or Edge", $descr="", $tags="Element", $link="") { + Container(Live.Customerscomputer.WebBrowser.SinglePageApplication_1, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + } + + } + + Deployment_Node(Live.BigBankplc, "Big Bank plc", $type="Big Bank plc data center", $descr="", $tags="Element", $link="") { + Deployment_Node(Live.BigBankplc.bigbankweb, "bigbank-web*** (x4)", $type="Ubuntu 16.04 LTS", $descr="", $tags="Element", $link="") { + Deployment_Node(Live.BigBankplc.bigbankweb.ApacheTomcat, "Apache Tomcat", $type="Apache Tomcat 8.x", $descr="", $tags="Element", $link="") { + Container(Live.BigBankplc.bigbankweb.ApacheTomcat.WebApplication_1, "Web Application", $techn="Java and Spring MVC", $descr="Delivers the static content and the Internet banking single page application.", $tags="Container", $link="") + } + + } + + Deployment_Node(Live.BigBankplc.bigbankapi, "bigbank-api*** (x8)", $type="Ubuntu 16.04 LTS", $descr="", $tags="Element", $link="") { + Deployment_Node(Live.BigBankplc.bigbankapi.ApacheTomcat, "Apache Tomcat", $type="Apache Tomcat 8.x", $descr="", $tags="Element", $link="") { + Container(Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, "API Application", $techn="Java and Spring MVC", $descr="Provides Internet banking functionality via a JSON/HTTPS API.", $tags="Container", $link="") + } + + } + + Deployment_Node(Live.BigBankplc.bigbankdb01, "bigbank-db01", $type="Ubuntu 16.04 LTS", $descr="", $tags="Element", $link="") { + Deployment_Node(Live.BigBankplc.bigbankdb01.OraclePrimary, "Oracle - Primary", $type="Oracle 12c", $descr="", $tags="Element", $link="") { + ContainerDb(Live.BigBankplc.bigbankdb01.OraclePrimary.Database_1, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + } + + } + + Deployment_Node(Live.BigBankplc.bigbankdb02, "bigbank-db02", $type="Ubuntu 16.04 LTS", $descr="", $tags="Failover", $link="") { + Deployment_Node(Live.BigBankplc.bigbankdb02.OracleSecondary, "Oracle - Secondary", $type="Oracle 12c", $descr="", $tags="Failover", $link="") { + ContainerDb(Live.BigBankplc.bigbankdb02.OracleSecondary.Database_1, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + } + + } + + Deployment_Node(Live.BigBankplc.bigbankprod001, "bigbank-prod001", $type="", $descr="", $tags="Element", $link="") { + System(Live.BigBankplc.bigbankprod001.MainframeBankingSystem_1, "Mainframe Banking System", $descr="Stores all of the core banking information about customers, accounts, transactions, etc.", $tags="Software System,Existing System", $link="") + } + + } + + Rel(Live.BigBankplc.bigbankweb.ApacheTomcat.WebApplication_1, Live.Customerscomputer.WebBrowser.SinglePageApplication_1, "Delivers to the customer's web browser", $techn="", $tags="Relationship", $link="") + Rel(Live.Customersmobiledevice.MobileApp_1, Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(Live.Customerscomputer.WebBrowser.SinglePageApplication_1, Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, "Makes API calls to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, Live.BigBankplc.bigbankdb01.OraclePrimary.Database_1, "Reads from and writes to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, Live.BigBankplc.bigbankdb02.OracleSecondary.Database_1, "Reads from and writes to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1, Live.BigBankplc.bigbankprod001.MainframeBankingSystem_1, "Makes API calls to", $techn="XML/HTTPS", $tags="Relationship", $link="") + Rel(Live.BigBankplc.bigbankdb01.OraclePrimary, Live.BigBankplc.bigbankdb02.OracleSecondary, "Replicates data to", $techn="", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + // and the sequence diagram version + workspace.getViews().getConfiguration().addProperty(exporter.PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "true"); + diagrams = exporter.export(workspace); + diagram = diagrams.stream().filter(d -> d.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Internet Banking System - API Application</size>\\n<size:24>Summarises how the sign in feature works in the single-page application.</size> + + set separator none + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4_Sequence> + + AddElementTag("Component", $bgColor="#85bbf0", $borderColor="#5d82a8", $fontColor="#000000", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Database", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Container,Web Browser", $bgColor="#438dd5", $borderColor="#2e6295", $fontColor="#ffffff", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + Container(InternetBankingSystem.SinglePageApplication, "Single-Page Application", $techn="JavaScript and Angular", $descr="Provides all of the Internet banking functionality to customers via their web browser.", $tags="Container,Web Browser", $link="") + Component(InternetBankingSystem.APIApplication.SignInController, "Sign In Controller", $techn="Spring MVC Rest Controller", $descr="Allows users to sign in to the Internet Banking System.", $tags="Component", $link="") + Component(InternetBankingSystem.APIApplication.SecurityComponent, "Security Component", $techn="Spring Bean", $descr="Provides functionality related to signing in, changing passwords, etc.", $tags="Component", $link="") + ContainerDb(InternetBankingSystem.Database, "Database", $techn="Oracle Database Schema", $descr="Stores user registration information, hashed authentication credentials, access logs, etc.", $tags="Container,Database", $link="") + + Rel(InternetBankingSystem.SinglePageApplication, InternetBankingSystem.APIApplication.SignInController, "1: Submits credentials to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SignInController, InternetBankingSystem.APIApplication.SecurityComponent, "2: Validates credentials using", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SecurityComponent, InternetBankingSystem.Database, "3: select * from users where username = ?", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.Database, InternetBankingSystem.APIApplication.SecurityComponent, "4: Returns user data to", $techn="SQL/TCP", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SecurityComponent, InternetBankingSystem.APIApplication.SignInController, "5: Returns true if the hashed password matches", $techn="", $tags="Relationship", $link="") + Rel(InternetBankingSystem.APIApplication.SignInController, InternetBankingSystem.SinglePageApplication, "6: Sends back an authentication token to", $techn="JSON/HTTPS", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + @Tag("IntegrationTest") + public void test_AmazonWebServicesExampleWithoutTags() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + workspace.getViews().getDeploymentViews().iterator().next().enableAutomaticLayout(AutomaticLayout.RankDirection.LeftRight, 300, 300); + workspace.getViews().getViews().forEach(v -> v.addProperty(C4PlantUMLExporter.C4PLANTUML_TAGS_PROPERTY, "false")); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: X - Live</size> + + set separator none + left to right direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Deployment> + + Deployment_Node(Live.AmazonWebServices, "Amazon Web Services", $type="", $descr="", $tags="", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1, "US-East-1", $type="", $descr="", $tags="", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.Autoscalinggroup, "Autoscaling group", $type="", $descr="", $tags="", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver, "Amazon EC2 - Ubuntu server", $type="", $descr="", $tags="", $link="") { + Container(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, "Web Application", $techn="Java and Spring Boot", $descr="", $tags="", $link="") + } + + } + + Deployment_Node(Live.AmazonWebServices.USEast1.AmazonRDS, "Amazon RDS", $type="", $descr="", $tags="", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.AmazonRDS.MySQL, "MySQL", $type="", $descr="", $tags="", $link="") { + ContainerDb(Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1, "Database Schema", $techn="", $descr="", $tags="", $link="") + } + + } + + Deployment_Node(Live.AmazonWebServices.USEast1.DNSrouter, "DNS router", $type="Route 53", $descr="Routes incoming requests based upon domain name.", $tags="", $link="") + Deployment_Node(Live.AmazonWebServices.USEast1.LoadBalancer, "Load Balancer", $type="Elastic Load Balancer", $descr="Automatically distributes incoming application traffic.", $tags="", $link="") + } + + } + + Rel(Live.AmazonWebServices.USEast1.LoadBalancer, Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, "Forwards requests to", $techn="HTTPS", $tags="", $link="") + Rel(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1, "Reads from and writes to", $techn="MySQL Protocol/SSL", $tags="", $link="") + Rel(Live.AmazonWebServices.USEast1.DNSrouter, Live.AmazonWebServices.USEast1.LoadBalancer, "Forwards requests to", $techn="HTTPS", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + @Tag("IntegrationTest") + public void test_AmazonWebServicesExampleWithTags() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + workspace.getViews().getDeploymentViews().iterator().next().enableAutomaticLayout(AutomaticLayout.RankDirection.LeftRight, 300, 300); + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_TAGS_PROPERTY, "true"); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: X - Live</size> + + set separator none + left to right direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + !include <C4/C4_Deployment> + + AddElementTag("Amazon Web Services - RDS", $bgColor="#ffffff", $borderColor="#3b48cc", $fontColor="#3b48cc", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png{scale=0.1}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - Auto Scaling", $bgColor="#ffffff", $borderColor="#cc2264", $fontColor="#cc2264", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png{scale=0.1}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - Route 53", $bgColor="#ffffff", $borderColor="#693cc5", $fontColor="#693cc5", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-route-53.png{scale=0.1}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - EC2", $bgColor="#ffffff", $borderColor="#d86613", $fontColor="#d86613", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png{scale=0.1}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - Region", $bgColor="#ffffff", $borderColor="#147eba", $fontColor="#147eba", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png{scale=0.21428571428571427}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - Elastic Load Balancing", $bgColor="#ffffff", $borderColor="#693cc5", $fontColor="#693cc5", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png{scale=0.1}", $shadowing="", $borderStyle="solid") + AddElementTag("Application", $bgColor="#ffffff", $borderColor="#444444", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - RDS MySQL instance", $bgColor="#ffffff", $borderColor="#3b48cc", $fontColor="#3b48cc", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png{scale=0.15}", $shadowing="", $borderStyle="solid") + AddElementTag("Amazon Web Services - Cloud", $bgColor="#ffffff", $borderColor="#232f3e", $fontColor="#232f3e", $sprite="img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png{scale=0.21428571428571427}", $shadowing="", $borderStyle="solid") + AddElementTag("Database", $bgColor="#ffffff", $borderColor="#444444", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="solid") + + AddRelTag("Relationship", $textColor="#444444", $lineColor="#444444", $lineStyle = DashedLine()) + + Deployment_Node(Live.AmazonWebServices, "Amazon Web Services", $type="", $descr="", $tags="Amazon Web Services - Cloud", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1, "US-East-1", $type="", $descr="", $tags="Amazon Web Services - Region", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.Autoscalinggroup, "Autoscaling group", $type="", $descr="", $tags="Amazon Web Services - Auto Scaling", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver, "Amazon EC2 - Ubuntu server", $type="", $descr="", $tags="Amazon Web Services - EC2", $link="") { + Container(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, "Web Application", $techn="Java and Spring Boot", $descr="", $tags="Application", $link="") + } + + } + + Deployment_Node(Live.AmazonWebServices.USEast1.AmazonRDS, "Amazon RDS", $type="", $descr="", $tags="Amazon Web Services - RDS", $link="") { + Deployment_Node(Live.AmazonWebServices.USEast1.AmazonRDS.MySQL, "MySQL", $type="", $descr="", $tags="Amazon Web Services - RDS MySQL instance", $link="") { + ContainerDb(Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1, "Database Schema", $techn="", $descr="", $tags="Database", $link="") + } + + } + + Deployment_Node(Live.AmazonWebServices.USEast1.DNSrouter, "DNS router", $type="Route 53", $descr="Routes incoming requests based upon domain name.", $tags="Amazon Web Services - Route 53", $link="") + Deployment_Node(Live.AmazonWebServices.USEast1.LoadBalancer, "Load Balancer", $type="Elastic Load Balancer", $descr="Automatically distributes incoming application traffic.", $tags="Amazon Web Services - Elastic Load Balancing", $link="") + } + + } + + Rel(Live.AmazonWebServices.USEast1.LoadBalancer, Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, "Forwards requests to", $techn="HTTPS", $tags="Relationship", $link="") + Rel(Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1, Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1, "Reads from and writes to", $techn="MySQL Protocol/SSL", $tags="Relationship", $link="") + Rel(Live.AmazonWebServices.USEast1.DNSrouter, Live.AmazonWebServices.USEast1.LoadBalancer, "Forwards requests to", $techn="HTTPS", $tags="Relationship", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_GroupsExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/groups.json")); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(3, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddBoundaryTag("Group 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Group 1", $tags="Group 1") { + System(B, "B", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Group 2", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_2, "Group 2", $tags="Group 2") { + System(C, "C", $descr="", $tags="", $link="") + AddBoundaryTag("Group 2/Group 3", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_3, "Group 3", $tags="Group 2/Group 3") { + System(D, "D", $descr="", $tags="", $link="") + } + + } + + System(A, "A", $descr="", $tags="", $link="") + + Rel(B, C, "", $techn="", $tags="", $link="") + Rel(C, D, "", $techn="", $tags="", $link="") + Rel(A, B, "", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Container View: D</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + + System(C, "C", $descr="", $tags="", $link="") + + System_Boundary("D_boundary", "D", $tags="") { + AddBoundaryTag("Group 4", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Group 4", $tags="Group 4") { + Container(D.F, "F", $techn="", $descr="", $tags="", $link="") + } + + Container(D.E, "E", $techn="", $descr="", $tags="", $link="") + } + + Rel(C, D.E, "", $techn="", $tags="", $link="") + Rel(C, D.F, "", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Component View: D - F</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Component> + + System(C, "C", $descr="", $tags="", $link="") + + System_Boundary("D_boundary", "D", $tags="") { + Container_Boundary("D.F_boundary", "F", $tags="") { + AddBoundaryTag("Group 5", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Group 5", $tags="Group 5") { + Component(D.F.H, "H", $techn="", $descr="", $tags="", $link="") + } + + Component(D.F.G, "G", $techn="", $descr="", $tags="", $link="") + } + + } + + Rel(C, D.F.G, "", $techn="", $tags="", $link="") + Rel(C, D.F.H, "", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_NestedGroupsExample() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Team 1"); + a.setGroup("Organisation 1/Department 1/Team 1"); + + SoftwareSystem b = workspace.getModel().addSoftwareSystem("Team 2"); + b.setGroup("Organisation 1/Department 1/Team 2"); + + SoftwareSystem c = workspace.getModel().addSoftwareSystem("Organisation 1"); + c.setGroup("Organisation 1"); + + SoftwareSystem d = workspace.getModel().addSoftwareSystem("Organisation 2"); + d.setGroup("Organisation 2"); + + SoftwareSystem e = workspace.getModel().addSoftwareSystem("Department 1"); + e.setGroup("Organisation 1/Department 1"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description"); + view.addAllElements(); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddBoundaryTag("Organisation 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Organisation 1", $tags="Organisation 1") { + System(Organisation1, "Organisation 1", $descr="", $tags="", $link="") + AddBoundaryTag("Organisation 1/Department 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_2, "Department 1", $tags="Organisation 1/Department 1") { + System(Department1, "Department 1", $descr="", $tags="", $link="") + AddBoundaryTag("Organisation 1/Department 1/Team 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_3, "Team 1", $tags="Organisation 1/Department 1/Team 1") { + System(Team1, "Team 1", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Organisation 1/Department 1/Team 2", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_4, "Team 2", $tags="Organisation 1/Department 1/Team 2") { + System(Team2, "Team 2", $descr="", $tags="", $link="") + } + + } + + } + + AddBoundaryTag("Organisation 2", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_5, "Organisation 2", $tags="Organisation 2") { + System(Organisation2, "Organisation 2", $descr="", $tags="", $link="") + } + + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_NestedGroupsExample_WithDotAsGroupSeparator() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addProperty("structurizr.groupSeparator", "."); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Team 1"); + a.setGroup("Organisation 1.Department 1.Team 1"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape", "Description"); + view.addAllElements(); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddBoundaryTag("Organisation 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_1, "Organisation 1", $tags="Organisation 1") { + AddBoundaryTag("Organisation 1.Department 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_2, "Department 1", $tags="Organisation 1.Department 1") { + AddBoundaryTag("Organisation 1.Department 1.Team 1", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_3, "Team 1", $tags="Organisation 1.Department 1.Team 1") { + System(Team1, "Team 1", $descr="", $tags="", $link="") + } + + } + + } + + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderGroupStyles() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User 1").setGroup("Group 1"); + workspace.getModel().addPerson("User 2").setGroup("Group 2"); + workspace.getModel().addPerson("User 3").setGroup("Group 3"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", ""); + view.addDefaultElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 1").color("#111111").icon("https://example.com/icon1.png"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Group 2").color("#222222").icon("https://example.com/icon2.png"); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter() { + @Override + protected double calculateIconScale(String iconUrl, int maxIconSize) { + return 1.0; + } + }; + + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddBoundaryTag("Group 1", $borderColor="#111111", $fontColor="#111111", $borderStyle="dashed") + Boundary(group_1, "Group 1", $tags="Group 1") { + Person(User1, "User 1", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Group 2", $borderColor="#222222", $fontColor="#222222", $borderStyle="dashed") + Boundary(group_2, "Group 2", $tags="Group 2") { + Person(User2, "User 2", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Group 3", $borderColor="#cccccc", $fontColor="#cccccc", $borderStyle="dashed") + Boundary(group_3, "Group 3", $tags="Group 3") { + Person(User3, "User 3", $descr="", $tags="", $link="") + } + + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group").color("#aabbcc"); + + diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddBoundaryTag("Group 1", $borderColor="#111111", $fontColor="#111111", $borderStyle="dashed") + Boundary(group_1, "Group 1", $tags="Group 1") { + Person(User1, "User 1", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Group 2", $borderColor="#222222", $fontColor="#222222", $borderStyle="dashed") + Boundary(group_2, "Group 2", $tags="Group 2") { + Person(User2, "User 2", $descr="", $tags="", $link="") + } + + AddBoundaryTag("Group 3", $borderColor="#aabbcc", $fontColor="#aabbcc", $borderStyle="dashed") + Boundary(group_3, "Group 3", $tags="Group 3") { + Person(User3, "User 3", $descr="", $tags="", $link="") + } + + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderContainerDiagramWithExternalContainers() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + + container1.uses(container2, "Uses"); + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem1, "Containers", ""); + containerView.add(container1); + containerView.add(container2); + + Diagram diagram = new C4PlantUMLExporter().export(containerView); + assertEquals(""" + @startuml + title <size:24>Container View: Software System 1</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + + System_Boundary("SoftwareSystem1_boundary", "Software System 1", $tags="") { + Container(SoftwareSystem1.Container1, "Container 1", $techn="", $descr="", $tags="", $link="") + } + + System_Boundary("SoftwareSystem2_boundary", "Software System 2", $tags="") { + Container(SoftwareSystem2.Container2, "Container 2", $techn="", $descr="", $tags="", $link="") + } + + Rel(SoftwareSystem1.Container1, SoftwareSystem2.Container2, "Uses", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderComponentDiagramWithExternalComponents() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + + component1.uses(component2, "Uses"); + + ComponentView componentView = workspace.getViews().createComponentView(container1, "Components", ""); + componentView.add(component1); + componentView.add(component2); + + Diagram diagram = new C4PlantUMLExporter().export(componentView); + assertEquals(""" + @startuml + title <size:24>Component View: Software System 1 - Container 1</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Component> + + System_Boundary("SoftwareSystem1_boundary", "Software System 1", $tags="") { + Container_Boundary("SoftwareSystem1.Container1_boundary", "Container 1", $tags="") { + Component(SoftwareSystem1.Container1.Component1, "Component 1", $techn="", $descr="", $tags="", $link="") + } + + } + + System_Boundary("SoftwareSystem2_boundary", "Software System 2", $tags="") { + Container_Boundary("SoftwareSystem2.Container2_boundary", "Container 2", $tags="") { + Component(SoftwareSystem2.Container2.Component2, "Component 2", $techn="", $descr="", $tags="", $link="") + } + + } + + Rel(SoftwareSystem1.Container1.Component1, SoftwareSystem2.Container2.Component2, "Uses", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderDiagramWithElementUrls() { + Workspace workspace = new Workspace("Name", "Description"); + + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + softwareSystem.setUrl("https://structurizr.com"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(SoftwareSystem, "Software System", $descr="", $tags="", $link="https://structurizr.com") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderDiagramWithIncludes() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("Software System"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + view.addProperty(C4PlantUMLExporter.PLANTUML_INCLUDES_PROPERTY, "styles.puml"); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + !include styles.puml + + System(SoftwareSystem, "Software System", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderDiagramWithNewLineCharacterInElementName() { + Workspace workspace = new Workspace("Name", "Description"); + + workspace.getModel().addSoftwareSystem("Software\nSystem"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(SoftwareSystem, "Software\\nSystem", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderInfrastructureNodeWithTechnology() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment node"); + deploymentNode.addInfrastructureNode("Infrastructure node", "description", "technology"); + + DeploymentView view = workspace.getViews().createDeploymentView("key", "view description"); + view.addDefaultElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>Deployment View: Default</size>\\n<size:24>view description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Deployment> + + Deployment_Node(Default.Deploymentnode, "Deployment node", $type="", $descr="", $tags="", $link="") { + Deployment_Node(Default.Deploymentnode.Infrastructurenode, "Infrastructure node", $type="technology", $descr="description", $tags="", $link="") + } + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_printProperties() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("SoftwareSystem"); + Container container1 = softwareSystem.addContainer("Container 1"); + container1.addProperty("structurizr.dsl.identifier", "container1"); + container1.addProperty("IP", "127.0.0.1"); + container1.addProperty("Region", "East"); + Container container2 = softwareSystem.addContainer("Container 2"); + container1.addProperty("structurizr.dsl.identifier", "container2"); + container2.addProperty("Region", "West"); + container2.addProperty("IP", "127.0.0.2"); + Relationship relationship = container1.uses(container2, ""); + relationship.addProperty("Prop1", "Value1"); + relationship.addProperty("Prop2", "Value2"); + + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_ELEMENT_PROPERTIES_PROPERTY, Boolean.TRUE.toString()); + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_RELATIONSHIP_PROPERTIES_PROPERTY, Boolean.TRUE.toString()); + ContainerView view = workspace.getViews().createContainerView(softwareSystem, "containerView", ""); + view.addDefaultElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>Container View: SoftwareSystem</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + + System_Boundary("SoftwareSystem_boundary", "SoftwareSystem", $tags="") { + WithoutPropertyHeader() + AddProperty("IP","127.0.0.1") + AddProperty("Region","East") + Container(SoftwareSystem.Container1, "Container 1", $techn="", $descr="", $tags="", $link="") + WithoutPropertyHeader() + AddProperty("IP","127.0.0.2") + AddProperty("Region","West") + Container(SoftwareSystem.Container2, "Container 2", $techn="", $descr="", $tags="", $link="") + } + + WithoutPropertyHeader() + AddProperty("Prop1","Value1") + AddProperty("Prop2","Value2") + Rel(SoftwareSystem.Container1, SoftwareSystem.Container2, "", $techn="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_deploymentViewPrintProperties() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment node"); + deploymentNode.addProperty("Prop1", "Value1"); + + InfrastructureNode infraNode = deploymentNode.addInfrastructureNode("Infrastructure node", "description", "technology"); + infraNode.addProperty("Prop2", "Value2"); + + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_ELEMENT_PROPERTIES_PROPERTY, Boolean.TRUE.toString()); + DeploymentView deploymentView = workspace.getViews().createDeploymentView("deploymentView", ""); + deploymentView.addDefaultElements(); + + Diagram diagram = new C4PlantUMLExporter().export(deploymentView); + assertEquals(""" + @startuml + title <size:24>Deployment View: Default</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Deployment> + + WithoutPropertyHeader() + AddProperty("Prop1","Value1") + Deployment_Node(Default.Deploymentnode, "Deployment node", $type="", $descr="", $tags="", $link="") { + WithoutPropertyHeader() + AddProperty("Prop2","Value2") + Deployment_Node(Default.Deploymentnode.Infrastructurenode, "Infrastructure node", $type="technology", $descr="description", $tags="", $link="") + } + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_legendAndStereotypes() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("Name"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + // legend (true) and stereotypes (false) + view.addProperty(C4PlantUMLExporter.C4PLANTUML_LEGEND_PROPERTY, "true"); + view.addProperty(C4PlantUMLExporter.C4PLANTUML_STEREOTYPES_PROPERTY, "false"); + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(Name, "Name", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + // legend (true) and stereotypes (true) + view.addProperty(C4PlantUMLExporter.C4PLANTUML_LEGEND_PROPERTY, "true"); + view.addProperty(C4PlantUMLExporter.C4PLANTUML_STEREOTYPES_PROPERTY, "true"); + diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(Name, "Name", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + show stereotypes + @enduml""", diagram.getDefinition()); + + // legend (false) and stereotypes (false) + view.addProperty(C4PlantUMLExporter.C4PLANTUML_LEGEND_PROPERTY, "false"); + view.addProperty(C4PlantUMLExporter.C4PLANTUML_STEREOTYPES_PROPERTY, "false"); + diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(Name, "Name", $descr="", $tags="", $link="") + + SHOW_LEGEND(false) + hide stereotypes + @enduml""", diagram.getDefinition()); + + // legend (false) and stereotypes (true) + view.addProperty(C4PlantUMLExporter.C4PLANTUML_LEGEND_PROPERTY, "false"); + view.addProperty(C4PlantUMLExporter.C4PLANTUML_STEREOTYPES_PROPERTY, "true"); + diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(Name, "Name", $descr="", $tags="", $link="") + + SHOW_LEGEND(false) + show stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderContainerShapes() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Container container1 = softwareSystem.addContainer("Default Container"); + Container container2 = softwareSystem.addContainer("Cylinder Container"); + Container container3 = softwareSystem.addContainer("Pipe Container"); + Container container4 = softwareSystem.addContainer("Robot Container"); + container2.addTags("Cylinder"); + container3.addTags("Pipe"); + container4.addTags("Robot"); // Just an example of a shape that has no C4-PlantUML mapping. + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem, "Containers", ""); + containerView.add(container1); + containerView.add(container2); + containerView.add(container3); + containerView.add(container4); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Cylinder").shape(Shape.Cylinder); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Pipe").shape(Shape.Pipe); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Robot").shape(Shape.Robot); + + Diagram diagram = new C4PlantUMLExporter().export(containerView); + assertEquals(""" + @startuml + title <size:24>Container View: Software System</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Container> + + System_Boundary("SoftwareSystem_boundary", "Software System", $tags="") { + Container(SoftwareSystem.DefaultContainer, "Default Container", $techn="", $descr="", $tags="", $link="") + ContainerDb(SoftwareSystem.CylinderContainer, "Cylinder Container", $techn="", $descr="", $tags="", $link="") + ContainerQueue(SoftwareSystem.PipeContainer, "Pipe Container", $techn="", $descr="", $tags="", $link="") + Container(SoftwareSystem.RobotContainer, "Robot Container", $techn="", $descr="", $tags="", $link="") + } + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void test_renderComponentShapes() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + + Component component1 = container.addComponent("Default Component"); + Component component2 = container.addComponent("Cylinder Component"); + Component component3 = container.addComponent("Pipe Component"); + Component component4 = container.addComponent("Robot Component"); + + component2.addTags("Cylinder"); + component3.addTags("Pipe"); + component4.addTags("Robot"); // Just an example of a shape that has no C4-PlantUML mapping. + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem, "Containers", ""); + ComponentView componentView = workspace.getViews().createComponentView(container, "Components", ""); + componentView.add(component1); + componentView.add(component2); + componentView.add(component3); + componentView.add(component4); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Cylinder").shape(Shape.Cylinder); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Pipe").shape(Shape.Pipe); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Robot").shape(Shape.Robot); + + Diagram diagram = new C4PlantUMLExporter().export(componentView); + assertEquals(""" + @startuml + title <size:24>Component View: Software System - Container</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Component> + + System_Boundary("SoftwareSystem_boundary", "Software System", $tags="") { + Container_Boundary("SoftwareSystem.Container_boundary", "Container", $tags="") { + Component(SoftwareSystem.Container.DefaultComponent, "Default Component", $techn="", $descr="", $tags="", $link="") + ComponentDb(SoftwareSystem.Container.CylinderComponent, "Cylinder Component", $techn="", $descr="", $tags="", $link="") + ComponentQueue(SoftwareSystem.Container.PipeComponent, "Pipe Component", $techn="", $descr="", $tags="", $link="") + Component(SoftwareSystem.Container.RobotComponent, "Robot Component", $techn="", $descr="", $tags="", $link="") + } + + } + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void testFont() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addAllElements(); + workspace.getViews().getConfiguration().getBranding().setFont(new Font("Courier")); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + FontName: Courier + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + Person(User, "User", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + + } + + @Test + public void stdlib_false() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addAllElements(); + view.addProperty(C4PlantUMLExporter.C4PLANTUML_STANDARD_LIBRARY_PROPERTY, "false"); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4.puml + !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml + + Person(User, "User", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition().toString()); + + } + + @Test + public void componentWithoutTechnology() { + Workspace workspace = new Workspace("Name", "Description"); + Container container = workspace.getModel().addSoftwareSystem("Name").addContainer("Name"); + container.addComponent("Name").setDescription("Description"); + + ComponentView view = workspace.getViews().createComponentView(container, "key", "Description"); + view.addAllElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>Component View: Name - Name</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + !include <C4/C4_Component> + + System_Boundary("Name_boundary", "Name", $tags="") { + Container_Boundary("Name.Name_boundary", "Name", $tags="") { + Component(Name.Name.Name, "Name", $techn="", $descr="Description", $tags="", $link="") + } + + } + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void borderStyling() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("Name"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addAllElements(); + workspace.getViews().getConfiguration().addProperty(C4PlantUMLExporter.C4PLANTUML_TAGS_PROPERTY, "true"); + workspace.getViews().getConfiguration().getStyles().addElementStyle(Tags.ELEMENT).stroke("green").border(Border.Dashed).strokeWidth(2); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + AddElementTag("Element", $bgColor="#ffffff", $borderColor="#008000", $fontColor="#444444", $sprite="", $shadowing="", $borderStyle="dashed", $borderThickness="2") + + System(Name, "Name", $descr="", $tags="Element", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + public void elementWithUrl() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("Name").setUrl("https://example.com"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addAllElements(); + + Diagram diagram = new C4PlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(Name, "Name", $descr="", $tags="", $link="https://example.com") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + + @Test + void skinparams() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("A"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addAllElements(); + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + exporter.addSkinParam("linetype", "ortho"); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + + skinparam { + linetype ortho + } + + <style> + root { + BackgroundColor: #ffffff + FontColor: #444444 + } + </style> + + !include <C4/C4> + !include <C4/C4_Context> + + System(A, "A", $descr="", $tags="", $link="") + + SHOW_LEGEND(true) + hide stereotypes + @enduml""", diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyleTests.java new file mode 100644 index 000000000..0abedaa56 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLBoundaryStyleTests.java @@ -0,0 +1,29 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.view.Border; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLBoundaryStyleTests { + + @Test + void test() { + PlantUMLBoundaryStyle style = new PlantUMLBoundaryStyle("Name", "#ffffff", "#444444", "#ff0000", 4, Border.Dotted, 24, false); + + assertEquals(""" + // Name + .Boundary-TmFtZQ== { + BackgroundColor: #ffffff; + LineColor: #ff0000; + LineStyle: 4-4; + LineThickness: 4; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLElementStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLElementStyleTests.java new file mode 100644 index 000000000..dee150a42 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLElementStyleTests.java @@ -0,0 +1,32 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.view.Border; +import com.structurizr.view.Shape; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLElementStyleTests { + + @Test + void test() { + PlantUMLElementStyle style = new PlantUMLElementStyle("Name", Shape.RoundedBox, 450, "#ffffff", "#444444", "#ff0000", 4, Border.Dotted, 24, "icon", false); + + assertEquals(""" + // Name + .Element-TmFtZQ== { + BackgroundColor: #ffffff; + LineColor: #ff0000; + LineStyle: 4-4; + LineThickness: 4; + RoundCorner: 20; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLGroupStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLGroupStyleTests.java new file mode 100644 index 000000000..dd146d0c2 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLGroupStyleTests.java @@ -0,0 +1,31 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.view.Border; +import com.structurizr.view.LineStyle; +import com.structurizr.view.RelationshipStyle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLGroupStyleTests { + + @Test + void test() { + PlantUMLGroupStyle style = new PlantUMLGroupStyle("Name", "#ffffff", "#444444", "#ff0000", 4, Border.Dotted, 24, false); + + assertEquals(""" + // Name + .Group-TmFtZQ== { + BackgroundColor: #ffffff; + LineColor: #ff0000; + LineStyle: 4-4; + LineThickness: 4; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLLegendStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLLegendStyleTests.java new file mode 100644 index 000000000..d56e3e786 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLLegendStyleTests.java @@ -0,0 +1,23 @@ +package com.structurizr.export.plantuml; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLLegendStyleTests { + + @Test + void test() { + PlantUMLLegendStyle style = new PlantUMLLegendStyle(); + + assertEquals(""" + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyleTests.java new file mode 100644 index 000000000..c73cee2c8 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRelationshipStyleTests.java @@ -0,0 +1,58 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.view.LineStyle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLRelationshipStyleTests { + + @Test + void solid() { + PlantUMLRelationshipStyle style = new PlantUMLRelationshipStyle("Relationship", "#ff0000", LineStyle.Solid, 3, 24); + + assertEquals(""" + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 3; + LineStyle: 0; + LineColor: #ff0000; + FontColor: #ff0000; + FontSize: 24; + } + """, style.toString()); + } + + @Test + void dashed() { + PlantUMLRelationshipStyle style = new PlantUMLRelationshipStyle("Relationship", "#ff0000", LineStyle.Dashed, 3, 24); + + assertEquals(""" + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 3; + LineStyle: 15-15; + LineColor: #ff0000; + FontColor: #ff0000; + FontSize: 24; + } + """, style.toString()); + } + + @Test + void dotted() { + PlantUMLRelationshipStyle style = new PlantUMLRelationshipStyle("Relationship", "#ff0000", LineStyle.Dotted, 3, 24); + + assertEquals(""" + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 3; + LineStyle: 3-3; + LineColor: #ff0000; + FontColor: #ff0000; + FontSize: 24; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRootStyleTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRootStyleTests.java new file mode 100644 index 000000000..b59703243 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/PlantUMLRootStyleTests.java @@ -0,0 +1,53 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.view.LineStyle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLRootStyleTests { + + @Test + void noFont() { + PlantUMLRootStyle style = new PlantUMLRootStyle( + "#ffffff", + "#444444", + null); + + assertEquals(""" + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + """, style.toString()); + + style = new PlantUMLRootStyle( + "#ffffff", + "#444444", + ""); + + assertEquals(""" + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + """, style.toString()); + } + + @Test + void font() { + PlantUMLRootStyle style = new PlantUMLRootStyle( + "#ffffff", + "#444444", + "Courier"); + + assertEquals(""" + root { + BackgroundColor: #ffffff; + FontColor: #444444; + FontName: Courier; + } + """, style.toString()); + } + +} diff --git a/structurizr-export/src/test/java/com/structurizr/export/plantuml/StructurizrPlantUMLDiagramExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/plantuml/StructurizrPlantUMLDiagramExporterTests.java new file mode 100644 index 000000000..b781d9a21 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/plantuml/StructurizrPlantUMLDiagramExporterTests.java @@ -0,0 +1,4267 @@ +package com.structurizr.export.plantuml; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.Diagram; +import com.structurizr.http.HttpClient; +import com.structurizr.model.*; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.*; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class StructurizrPlantUMLDiagramExporterTests extends AbstractExporterTests { + + @Test + public void test_BigBankPlcExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/big-bank-plc.json")); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Boundary:SoftwareSystem").color("#0b4884"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Boundary:Container").color("#438dd5"); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(7, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(d -> d.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Person,Bank Staff + .Element-RWxlbWVudCxQZXJzb24sQmFuayBTdGFmZg== { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 22; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Person,Customer + .Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI= { + BackgroundColor: #08427b; + LineColor: #052e56; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 22; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0= { + BackgroundColor: #1168bd; + LineColor: #0b4884; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Big Bank plc + .Group-QmlnIEJhbmsgcGxj { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Big Bank plc" <<Group-QmlnIEJhbmsgcGxj>> as groupQmlnIEJhbmsgcGxj { + person "==Customer Service Staff\\n<size:15>[Person]</size>\\n\\nCustomer service staff within the bank." <<Element-RWxlbWVudCxQZXJzb24sQmFuayBTdGFmZg==>> as CustomerServiceStaff + person "==Back Office Staff\\n<size:15>[Person]</size>\\n\\nAdministration and support staff within the bank." <<Element-RWxlbWVudCxQZXJzb24sQmFuayBTdGFmZg==>> as BackOfficeStaff + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as MainframeBankingSystem + rectangle "==E-mail System\\n<size:16>[Software System]</size>\\n\\nThe internal Microsoft Exchange e-mail system." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as EmailSystem + rectangle "==ATM\\n<size:16>[Software System]</size>\\n\\nAllows customers to withdraw cash." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as ATM + rectangle "==Internet Banking System\\n<size:16>[Software System]</size>\\n\\nAllows customers to view information about their bank accounts, and make payments." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0=>> as InternetBankingSystem + } + + person "==Personal Banking Customer\\n<size:15>[Person]</size>\\n\\nA customer of the bank, with personal bank accounts." <<Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI=>> as PersonalBankingCustomer + + PersonalBankingCustomer --> InternetBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Views account balances, and makes payments using" + InternetBankingSystem --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Gets account information from, and makes payments using" + InternetBankingSystem --> EmailSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mail using" + EmailSystem --> PersonalBankingCustomer <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mails to" + PersonalBankingCustomer --> CustomerServiceStaff <<Relationship-UmVsYXRpb25zaGlw>> : "Asks questions to\\n<size:16>[Telephone]</size>" + CustomerServiceStaff --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + PersonalBankingCustomer --> ATM <<Relationship-UmVsYXRpb25zaGlw>> : "Withdraws cash using" + ATM --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + BackOfficeStaff --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("SystemContext")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Context View: Internet Banking System</size>\\n<size:24>The system context diagram for the Internet Banking System.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Person,Customer + .Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI= { + BackgroundColor: #08427b; + LineColor: #052e56; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 22; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0= { + BackgroundColor: #1168bd; + LineColor: #0b4884; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Big Bank plc + .Group-QmlnIEJhbmsgcGxj { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Big Bank plc" <<Group-QmlnIEJhbmsgcGxj>> as groupQmlnIEJhbmsgcGxj { + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as MainframeBankingSystem + rectangle "==E-mail System\\n<size:16>[Software System]</size>\\n\\nThe internal Microsoft Exchange e-mail system." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as EmailSystem + rectangle "==Internet Banking System\\n<size:16>[Software System]</size>\\n\\nAllows customers to view information about their bank accounts, and make payments." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0=>> as InternetBankingSystem + } + + person "==Personal Banking Customer\\n<size:15>[Person]</size>\\n\\nA customer of the bank, with personal bank accounts." <<Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI=>> as PersonalBankingCustomer + + PersonalBankingCustomer --> InternetBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Views account balances, and makes payments using" + InternetBankingSystem --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Gets account information from, and makes payments using" + InternetBankingSystem --> EmailSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mail using" + EmailSystem --> PersonalBankingCustomer <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mails to" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Container View: Internet Banking System</size>\\n<size:24>The container diagram for the Internet Banking System.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Container + .Element-RWxlbWVudCxDb250YWluZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Mobile App + .Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA== { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Person,Customer + .Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI= { + BackgroundColor: #08427b; + LineColor: #052e56; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 22; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Internet Banking System + .Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0= { + BackgroundColor: #ffffff; + LineColor: #0b4884; + LineStyle: 0; + LineThickness: 2; + FontColor: #0b4884; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + person "==Personal Banking Customer\\n<size:15>[Person]</size>\\n\\nA customer of the bank, with personal bank accounts." <<Element-RWxlbWVudCxQZXJzb24sQ3VzdG9tZXI=>> as PersonalBankingCustomer + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as MainframeBankingSystem + rectangle "==E-mail System\\n<size:16>[Software System]</size>\\n\\nThe internal Microsoft Exchange e-mail system." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as EmailSystem + + rectangle "Internet Banking System\\n<size:16>[Software System]</size>" <<Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0=>> { + rectangle "==Web Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nDelivers the static content and the Internet banking single page application." <<Element-RWxlbWVudCxDb250YWluZXI=>> as InternetBankingSystem.WebApplication + rectangle "==API Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nProvides Internet banking functionality via a JSON/HTTPS API." <<Element-RWxlbWVudCxDb250YWluZXI=>> as InternetBankingSystem.APIApplication + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as InternetBankingSystem.Database + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as InternetBankingSystem.SinglePageApplication + rectangle "==Mobile App\\n<size:16>[Container: Xamarin]</size>\\n\\nProvides a limited subset of the Internet banking functionality to customers via their mobile device." <<Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA==>> as InternetBankingSystem.MobileApp + } + + EmailSystem --> PersonalBankingCustomer <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mails to" + PersonalBankingCustomer --> InternetBankingSystem.WebApplication <<Relationship-UmVsYXRpb25zaGlw>> : "Visits bigbank.com/ib using\\n<size:16>[HTTPS]</size>" + PersonalBankingCustomer --> InternetBankingSystem.SinglePageApplication <<Relationship-UmVsYXRpb25zaGlw>> : "Views account balances, and makes payments using" + PersonalBankingCustomer --> InternetBankingSystem.MobileApp <<Relationship-UmVsYXRpb25zaGlw>> : "Views account balances, and makes payments using" + InternetBankingSystem.WebApplication --> InternetBankingSystem.SinglePageApplication <<Relationship-UmVsYXRpb25zaGlw>> : "Delivers to the customer's web browser" + InternetBankingSystem.SinglePageApplication --> InternetBankingSystem.APIApplication <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.MobileApp --> InternetBankingSystem.APIApplication <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.APIApplication --> InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[SQL/TCP]</size>" + InternetBankingSystem.APIApplication --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[XML/HTTPS]</size>" + InternetBankingSystem.APIApplication --> EmailSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mail using" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Component View: Internet Banking System - API Application</size>\\n<size:24>The component diagram for the API Application.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Component + .Element-RWxlbWVudCxDb21wb25lbnQ= { + BackgroundColor: #85bbf0; + LineColor: #5d82a8; + LineStyle: 0; + LineThickness: 2; + FontColor: #000000; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Mobile App + .Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA== { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // API Application + .Boundary-QVBJIEFwcGxpY2F0aW9u { + BackgroundColor: #ffffff; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #438dd5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Internet Banking System + .Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0= { + BackgroundColor: #ffffff; + LineColor: #0b4884; + LineStyle: 0; + LineThickness: 2; + FontColor: #0b4884; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as MainframeBankingSystem + rectangle "==E-mail System\\n<size:16>[Software System]</size>\\n\\nThe internal Microsoft Exchange e-mail system." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as EmailSystem + + rectangle "Internet Banking System\\n<size:16>[Software System]</size>" <<Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0=>> { + rectangle "API Application\\n<size:16>[Container: Java and Spring MVC]</size>" <<Boundary-QVBJIEFwcGxpY2F0aW9u>> { + rectangle "==Sign In Controller\\n<size:16>[Component: Spring MVC Rest Controller]</size>\\n\\nAllows users to sign in to the Internet Banking System." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.SignInController + rectangle "==Accounts Summary Controller\\n<size:16>[Component: Spring MVC Rest Controller]</size>\\n\\nProvides customers with a summary of their bank accounts." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.AccountsSummaryController + rectangle "==Reset Password Controller\\n<size:16>[Component: Spring MVC Rest Controller]</size>\\n\\nAllows users to reset their passwords with a single use URL." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.ResetPasswordController + rectangle "==Security Component\\n<size:16>[Component: Spring Bean]</size>\\n\\nProvides functionality related to signing in, changing passwords, etc." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.SecurityComponent + rectangle "==Mainframe Banking System Facade\\n<size:16>[Component: Spring Bean]</size>\\n\\nA facade onto the mainframe banking system." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.MainframeBankingSystemFacade + rectangle "==E-mail Component\\n<size:16>[Component: Spring Bean]</size>\\n\\nSends e-mails to users." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.EmailComponent + } + + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as InternetBankingSystem.Database + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as InternetBankingSystem.SinglePageApplication + rectangle "==Mobile App\\n<size:16>[Container: Xamarin]</size>\\n\\nProvides a limited subset of the Internet banking functionality to customers via their mobile device." <<Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA==>> as InternetBankingSystem.MobileApp + } + + InternetBankingSystem.SinglePageApplication --> InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.SinglePageApplication --> InternetBankingSystem.APIApplication.AccountsSummaryController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.SinglePageApplication --> InternetBankingSystem.APIApplication.ResetPasswordController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.MobileApp --> InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.MobileApp --> InternetBankingSystem.APIApplication.AccountsSummaryController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.MobileApp --> InternetBankingSystem.APIApplication.ResetPasswordController <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.APIApplication.SignInController --> InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + InternetBankingSystem.APIApplication.AccountsSummaryController --> InternetBankingSystem.APIApplication.MainframeBankingSystemFacade <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + InternetBankingSystem.APIApplication.ResetPasswordController --> InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + InternetBankingSystem.APIApplication.ResetPasswordController --> InternetBankingSystem.APIApplication.EmailComponent <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + InternetBankingSystem.APIApplication.SecurityComponent --> InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[SQL/TCP]</size>" + InternetBankingSystem.APIApplication.MainframeBankingSystemFacade --> MainframeBankingSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[XML/HTTPS]</size>" + InternetBankingSystem.APIApplication.EmailComponent --> EmailSystem <<Relationship-UmVsYXRpb25zaGlw>> : "Sends e-mail using" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(d -> d.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Internet Banking System - API Application</size>\\n<size:24>Summarises how the sign in feature works in the single-page application.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Component + .Element-RWxlbWVudCxDb21wb25lbnQ= { + BackgroundColor: #85bbf0; + LineColor: #5d82a8; + LineStyle: 0; + LineThickness: 2; + FontColor: #000000; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // API Application + .Boundary-QVBJIEFwcGxpY2F0aW9u { + BackgroundColor: #ffffff; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #438dd5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Internet Banking System + .Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0= { + BackgroundColor: #ffffff; + LineColor: #0b4884; + LineStyle: 0; + LineThickness: 2; + FontColor: #0b4884; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Internet Banking System\\n<size:16>[Software System]</size>" <<Boundary-SW50ZXJuZXQgQmFua2luZyBTeXN0ZW0=>> { + rectangle "API Application\\n<size:16>[Container: Java and Spring MVC]</size>" <<Boundary-QVBJIEFwcGxpY2F0aW9u>> { + rectangle "==Sign In Controller\\n<size:16>[Component: Spring MVC Rest Controller]</size>\\n\\nAllows users to sign in to the Internet Banking System." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.SignInController + rectangle "==Security Component\\n<size:16>[Component: Spring Bean]</size>\\n\\nProvides functionality related to signing in, changing passwords, etc." <<Element-RWxlbWVudCxDb21wb25lbnQ=>> as InternetBankingSystem.APIApplication.SecurityComponent + } + + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as InternetBankingSystem.Database + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as InternetBankingSystem.SinglePageApplication + } + + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as InternetBankingSystem.SinglePageApplication + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as InternetBankingSystem.Database + + InternetBankingSystem.SinglePageApplication --> InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : "1: Submits credentials to\\n<size:16>[JSON/HTTPS]</size>" + InternetBankingSystem.APIApplication.SignInController --> InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : "2: Validates credentials using" + InternetBankingSystem.APIApplication.SecurityComponent --> InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : "3: select * from users where username = ?\\n<size:16>[SQL/TCP]</size>" + InternetBankingSystem.APIApplication.SecurityComponent <-- InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : "4: Returns user data to\\n<size:16>[SQL/TCP]</size>" + InternetBankingSystem.APIApplication.SignInController <-- InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : "5: Returns true if the hashed password matches" + InternetBankingSystem.SinglePageApplication <-- InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : "6: Sends back an authentication token to\\n<size:16>[JSON/HTTPS]</size>" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("DevelopmentDeployment")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: Internet Banking System - Development</size>\\n<size:24>An example development deployment scenario for the Internet Banking System.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Container + .Element-RWxlbWVudCxDb250YWluZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Element + .DeploymentNode-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Developer Laptop\\n<size:16>[Deployment Node: Microsoft Windows 10 or Apple macOS]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop { + rectangle "Web Browser\\n<size:16>[Deployment Node: Chrome, Firefox, Safari, or Edge]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop.WebBrowser { + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1 + } + + rectangle "Docker Container - Web Server\\n<size:16>[Deployment Node: Docker]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop.DockerContainerWebServer { + rectangle "Apache Tomcat\\n<size:16>[Deployment Node: Apache Tomcat 8.x]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat { + rectangle "==Web Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nDelivers the static content and the Internet banking single page application." <<Element-RWxlbWVudCxDb250YWluZXI=>> as Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.WebApplication_1 + rectangle "==API Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nProvides Internet banking functionality via a JSON/HTTPS API." <<Element-RWxlbWVudCxDb250YWluZXI=>> as Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1 + } + + } + + rectangle "Docker Container - Database Server\\n<size:16>[Deployment Node: Docker]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop.DockerContainerDatabaseServer { + rectangle "Database Server\\n<size:16>[Deployment Node: Oracle 12c]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer { + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer.Database_1 + } + + } + + } + + rectangle "Big Bank plc\\n<size:16>[Deployment Node: Big Bank plc data center]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.BigBankplc { + rectangle "bigbank-dev001\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Development.BigBankplc.bigbankdev001 { + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as Development.BigBankplc.bigbankdev001.MainframeBankingSystem_1 + } + + } + + Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.WebApplication_1 --> Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Delivers to the customer's web browser" + Development.DeveloperLaptop.WebBrowser.SinglePageApplication_1 --> Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1 --> Development.DeveloperLaptop.DockerContainerDatabaseServer.DatabaseServer.Database_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[SQL/TCP]</size>" + Development.DeveloperLaptop.DockerContainerWebServer.ApacheTomcat.APIApplication_1 --> Development.BigBankplc.bigbankdev001.MainframeBankingSystem_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[XML/HTTPS]</size>" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("LiveDeployment")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: Internet Banking System - Live</size>\\n<size:24>An example live deployment scenario for the Internet Banking System.</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Container + .Element-RWxlbWVudCxDb250YWluZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Mobile App + .Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA== { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Software System,Existing System + .Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt { + BackgroundColor: #999999; + LineColor: #6b6b6b; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Element + .DeploymentNode-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Failover + .DeploymentNode-RWxlbWVudCxGYWlsb3Zlcg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Customer's mobile device\\n<size:16>[Deployment Node: Apple iOS or Android]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.Customersmobiledevice { + rectangle "==Mobile App\\n<size:16>[Container: Xamarin]</size>\\n\\nProvides a limited subset of the Internet banking functionality to customers via their mobile device." <<Element-RWxlbWVudCxDb250YWluZXIsTW9iaWxlIEFwcA==>> as Live.Customersmobiledevice.MobileApp_1 + } + + rectangle "Customer's computer\\n<size:16>[Deployment Node: Microsoft Windows or Apple macOS]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.Customerscomputer { + rectangle "Web Browser\\n<size:16>[Deployment Node: Chrome, Firefox, Safari, or Edge]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.Customerscomputer.WebBrowser { + rectangle "==Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>\\n\\nProvides all of the Internet banking functionality to customers via their web browser." <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> as Live.Customerscomputer.WebBrowser.SinglePageApplication_1 + } + + } + + rectangle "Big Bank plc\\n<size:16>[Deployment Node: Big Bank plc data center]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc { + rectangle "bigbank-web*** (x4)\\n<size:16>[Deployment Node: Ubuntu 16.04 LTS]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankweb { + rectangle "Apache Tomcat\\n<size:16>[Deployment Node: Apache Tomcat 8.x]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankweb.ApacheTomcat { + rectangle "==Web Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nDelivers the static content and the Internet banking single page application." <<Element-RWxlbWVudCxDb250YWluZXI=>> as Live.BigBankplc.bigbankweb.ApacheTomcat.WebApplication_1 + } + + } + + rectangle "bigbank-api*** (x8)\\n<size:16>[Deployment Node: Ubuntu 16.04 LTS]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankapi { + rectangle "Apache Tomcat\\n<size:16>[Deployment Node: Apache Tomcat 8.x]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankapi.ApacheTomcat { + rectangle "==API Application\\n<size:16>[Container: Java and Spring MVC]</size>\\n\\nProvides Internet banking functionality via a JSON/HTTPS API." <<Element-RWxlbWVudCxDb250YWluZXI=>> as Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 + } + + } + + rectangle "bigbank-db01\\n<size:16>[Deployment Node: Ubuntu 16.04 LTS]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankdb01 { + rectangle "Oracle - Primary\\n<size:16>[Deployment Node: Oracle 12c]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankdb01.OraclePrimary { + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as Live.BigBankplc.bigbankdb01.OraclePrimary.Database_1 + } + + } + + rectangle "bigbank-db02\\n<size:16>[Deployment Node: Ubuntu 16.04 LTS]</size>" <<DeploymentNode-RWxlbWVudCxGYWlsb3Zlcg==>> as Live.BigBankplc.bigbankdb02 { + rectangle "Oracle - Secondary\\n<size:16>[Deployment Node: Oracle 12c]</size>" <<DeploymentNode-RWxlbWVudCxGYWlsb3Zlcg==>> as Live.BigBankplc.bigbankdb02.OracleSecondary { + database "==Database\\n<size:16>[Container: Oracle Database Schema]</size>\\n\\nStores user registration information, hashed authentication credentials, access logs, etc." <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> as Live.BigBankplc.bigbankdb02.OracleSecondary.Database_1 + } + + } + + rectangle "bigbank-prod001\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Live.BigBankplc.bigbankprod001 { + rectangle "==Mainframe Banking System\\n<size:16>[Software System]</size>\\n\\nStores all of the core banking information about customers, accounts, transactions, etc." <<Element-RWxlbWVudCxTb2Z0d2FyZSBTeXN0ZW0sRXhpc3RpbmcgU3lzdGVt>> as Live.BigBankplc.bigbankprod001.MainframeBankingSystem_1 + } + + } + + Live.BigBankplc.bigbankweb.ApacheTomcat.WebApplication_1 --> Live.Customerscomputer.WebBrowser.SinglePageApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Delivers to the customer's web browser" + Live.Customersmobiledevice.MobileApp_1 --> Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + Live.Customerscomputer.WebBrowser.SinglePageApplication_1 --> Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[JSON/HTTPS]</size>" + Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 --> Live.BigBankplc.bigbankdb01.OraclePrimary.Database_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[SQL/TCP]</size>" + Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 --> Live.BigBankplc.bigbankdb02.OracleSecondary.Database_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[SQL/TCP]</size>" + Live.BigBankplc.bigbankapi.ApacheTomcat.APIApplication_1 --> Live.BigBankplc.bigbankprod001.MainframeBankingSystem_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Makes API calls to\\n<size:16>[XML/HTTPS]</size>" + Live.BigBankplc.bigbankdb01.OraclePrimary --> Live.BigBankplc.bigbankdb02.OracleSecondary <<Relationship-UmVsYXRpb25zaGlw>> : "Replicates data to" + + @enduml""", diagram.getDefinition()); + + // and the sequence diagram version + workspace.getViews().getConfiguration().addProperty(exporter.PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "true"); + diagrams = exporter.export(workspace); + diagram = diagrams.stream().filter(d -> d.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Internet Banking System - API Application</size>\\n<size:24>Summarises how the sign in feature works in the single-page application.</size> + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Component + .Element-RWxlbWVudCxDb21wb25lbnQ= { + BackgroundColor: #85bbf0; + LineColor: #5d82a8; + LineStyle: 0; + LineThickness: 2; + FontColor: #000000; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Database + .Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #2e6295; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Container,Web Browser + .Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI= { + BackgroundColor: #438dd5; + LineColor: #2e6295; + LineStyle: 0; + LineThickness: 2; + FontColor: #ffffff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + participant "Single-Page Application\\n<size:16>[Container: JavaScript and Angular]</size>" as InternetBankingSystem.SinglePageApplication <<Element-RWxlbWVudCxDb250YWluZXIsV2ViIEJyb3dzZXI=>> + participant "Sign In Controller\\n<size:16>[Component: Spring MVC Rest Controller]</size>" as InternetBankingSystem.APIApplication.SignInController <<Element-RWxlbWVudCxDb21wb25lbnQ=>> + participant "Security Component\\n<size:16>[Component: Spring Bean]</size>" as InternetBankingSystem.APIApplication.SecurityComponent <<Element-RWxlbWVudCxDb21wb25lbnQ=>> + database "Database\\n<size:16>[Container: Oracle Database Schema]</size>" as InternetBankingSystem.Database <<Element-RWxlbWVudCxDb250YWluZXIsRGF0YWJhc2U=>> + + InternetBankingSystem.SinglePageApplication -> InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : 1: Submits credentials to\\n<size:16>[JSON/HTTPS]</size> + InternetBankingSystem.APIApplication.SignInController -> InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : 2: Validates credentials using + InternetBankingSystem.APIApplication.SecurityComponent -> InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : 3: select * from users where username = ?\\n<size:16>[SQL/TCP]</size> + InternetBankingSystem.APIApplication.SecurityComponent <-- InternetBankingSystem.Database <<Relationship-UmVsYXRpb25zaGlw>> : 4: Returns user data to\\n<size:16>[SQL/TCP]</size> + InternetBankingSystem.APIApplication.SignInController <-- InternetBankingSystem.APIApplication.SecurityComponent <<Relationship-UmVsYXRpb25zaGlw>> : 5: Returns true if the hashed password matches + InternetBankingSystem.SinglePageApplication <-- InternetBankingSystem.APIApplication.SignInController <<Relationship-UmVsYXRpb25zaGlw>> : 6: Sends back an authentication token to\\n<size:16>[JSON/HTTPS]</size> + + @enduml""", diagram.getDefinition()); + } + + @Test + public void systemLandscapeView_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A", "Description."); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B", "Description."); + a.uses(b, "Description", "Technology"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as A + rectangle "==B\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as B + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "Description\\n<size:16>[Technology]</size>" + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + rectangle "==Element" <<Element-RWxlbWVudA==>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + public void systemLandscapeView_NoStyling_Dark() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A", "Description."); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B", "Description."); + a.uses(b, "Description", "Technology"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Dark); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #111111; + FontColor: #cccccc; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #cccccc; + FontColor: #cccccc; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as A + rectangle "==B\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as B + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "Description\\n<size:16>[Technology]</size>" + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #111111; + FontColor: #cccccc; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #cccccc; + FontColor: #cccccc; + FontSize: 24; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + rectangle "==Element" <<Element-RWxlbWVudA==>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + public void systemContextView_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A", "Description."); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B", "Description."); + a.uses(b, "Description", "Technology"); + + SystemContextView view = workspace.getViews().createSystemContextView(a, "key", "Description"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>System Context View: A</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as A + rectangle "==B\\n<size:16>[Software System]</size>\\n\\nDescription." <<Element-RWxlbWVudA==>> as B + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "Description\\n<size:16>[Technology]</size>" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void containerView_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Software System A", "Description."); + Container container1 = a.addContainer("Container 1", "Description", "Technology"); + Container container2 = a.addContainer("Container 2", "Description", "Technology"); + container1.uses(container2, "Description", "Technology"); + + ContainerView view = workspace.getViews().createContainerView(a, "key", "Description"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Container View: Software System A</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Software System A + .Boundary-U29mdHdhcmUgU3lzdGVtIEE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System A\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIEE=>> { + rectangle "==Container 1\\n<size:16>[Container: Technology]</size>\\n\\nDescription" <<Element-RWxlbWVudA==>> as SoftwareSystemA.Container1 + rectangle "==Container 2\\n<size:16>[Container: Technology]</size>\\n\\nDescription" <<Element-RWxlbWVudA==>> as SoftwareSystemA.Container2 + } + + SoftwareSystemA.Container1 --> SoftwareSystemA.Container2 <<Relationship-UmVsYXRpb25zaGlw>> : "Description\\n<size:16>[Technology]</size>" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void componentView_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Software System", "Description."); + Container container = a.addContainer("Container", "Description", "Technology"); + Component component1 = container.addComponent("Component 1", "Description", "Technology"); + Component component2 = container.addComponent("Component 2", "Description", "Technology"); + component1.uses(component2, "Description", "Technology"); + + ComponentView view = workspace.getViews().createComponentView(container, "key", "Description"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Component View: Software System - Container</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Container + .Boundary-Q29udGFpbmVy { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System + .Boundary-U29mdHdhcmUgU3lzdGVt { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVt>> { + rectangle "Container\\n<size:16>[Container: Technology]</size>" <<Boundary-Q29udGFpbmVy>> { + rectangle "==Component 1\\n<size:16>[Component: Technology]</size>\\n\\nDescription" <<Element-RWxlbWVudA==>> as SoftwareSystem.Container.Component1 + rectangle "==Component 2\\n<size:16>[Component: Technology]</size>\\n\\nDescription" <<Element-RWxlbWVudA==>> as SoftwareSystem.Container.Component2 + } + + } + + SoftwareSystem.Container.Component1 --> SoftwareSystem.Container.Component2 <<Relationship-UmVsYXRpb25zaGlw>> : "Description\\n<size:16>[Technology]</size>" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void deploymentView_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + DeploymentNode node1 = workspace.getModel().addDeploymentNode("Node 1"); + node1.addDeploymentNode("Node 2").add(a); + node1.addDeploymentNode("Node 3").addInfrastructureNode("Infrastructure Node"); + + DeploymentView view = workspace.getViews().createDeploymentView("deployment", "Default"); + view.addDefaultElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Deployment View: Default</size>\\n<size:24>Default</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element + .DeploymentNode-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Node 1\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Default.Node1 { + rectangle "Node 2\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Default.Node1.Node2 { + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Default.Node1.Node2.A_1 + } + + rectangle "Node 3\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Default.Node1.Node3 { + rectangle "==Infrastructure Node\\n<size:16>[Infrastructure Node]</size>" <<Element-RWxlbWVudA==>> as Default.Node1.Node3.InfrastructureNode + } + + } + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_CollaborationStyle_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + + a.uses(b, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(a, b); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Dynamic View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + rectangle "==B\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as B + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_CollaborationStyle_Frames() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + SoftwareSystem c = workspace.getModel().addSoftwareSystem("C"); + + a.uses(b, "Uses"); + b.uses(c, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(a, b); + view.add(b, c); + view.addProperty(StructurizrPlantUMLExporter.PLANTUML_ANIMATION_PROPERTY, "true"); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + List<Diagram> frames = exporter.export(view).getFrames(); + assertEquals(2, frames.size()); + + assertEquals(""" + @startuml + title <size:24>Dynamic View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + rectangle "==B\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as B + rectangle "==C\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as C + hide C + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + B --> C <<Relationship-UmVsYXRpb25zaGlw>> : "2: Uses" + + @enduml""", frames.get(0).getDefinition()); + + assertEquals(""" + @startuml + title <size:24>Dynamic View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + hide A + rectangle "==B\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as B + rectangle "==C\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as C + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + B --> C <<Relationship-UmVsYXRpb25zaGlw>> : "2: Uses" + + @enduml""", frames.get(1).getDefinition()); + } + + @Test + public void dynamicView_SequenceStyle_NoStyling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + + a.uses(b, "Uses", "JSON/HTTPS"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(a, b); + view.addProperty(StructurizrPlantUMLExporter.PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "true"); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Dynamic View</size>\\n<size:24>Description</size> + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + participant "A\\n<size:16>[Software System]</size>" as A <<Element-RWxlbWVudA==>> + participant "B\\n<size:16>[Software System]</size>" as B <<Element-RWxlbWVudA==>> + + A -> B <<Relationship-UmVsYXRpb25zaGlw>> : 1: Uses\\n<size:16>[JSON/HTTPS]</size> + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + rectangle "==Element" <<Element-RWxlbWVudA==>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + public void dynamicView_SequenceStyle_Styling_Light() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + a.addTags("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + b.addTags("B"); + + a.uses(b, "Uses", "JSON/HTTPS"); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("A").shape(Shape.Person).color("#ff0000"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("B").shape(Shape.Cylinder).color("#00ff00"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(a, b); + view.addProperty(StructurizrPlantUMLExporter.PLANTUML_SEQUENCE_DIAGRAM_PROPERTY, "true"); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>Dynamic View</size>\\n<size:24>Description</size> + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,A + .Element-RWxlbWVudCxB { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,B + .Element-RWxlbWVudCxC { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + actor "A\\n<size:16>[Software System]</size>" as A <<Element-RWxlbWVudCxB>> + database "B\\n<size:16>[Software System]</size>" as B <<Element-RWxlbWVudCxC>> + + A -> B <<Relationship-UmVsYXRpb25zaGlw>> : 1: Uses\\n<size:16>[JSON/HTTPS]</size> + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,A + .Element-RWxlbWVudCxB { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,B + .Element-RWxlbWVudCxC { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + person "==A" <<Element-RWxlbWVudCxB>> + + database "==B" <<Element-RWxlbWVudCxC>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + public void groups() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/groups.json")); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(3, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Group 1 + .Group-R3JvdXAgMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2 + .Group-R3JvdXAgMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2/Group 3 + .Group-R3JvdXAgMi9Hcm91cCAz { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Group 1" <<Group-R3JvdXAgMQ==>> as groupR3JvdXAgMQ== { + rectangle "==B\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as B + } + + rectangle "Group 2" <<Group-R3JvdXAgMg==>> as groupR3JvdXAgMg== { + rectangle "==C\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as C + rectangle "Group 3" <<Group-R3JvdXAgMi9Hcm91cCAz>> as groupR3JvdXAgMi9Hcm91cCAz { + rectangle "==D\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as D + } + + } + + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + + B --> C <<Relationship-UmVsYXRpb25zaGlw>> : "" + C --> D <<Relationship-UmVsYXRpb25zaGlw>> : "" + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Containers")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Container View: D</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // D + .Boundary-RA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 4 + .Group-R3JvdXAgNA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "==C\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as C + + rectangle "D\\n<size:16>[Software System]</size>" <<Boundary-RA==>> { + rectangle "Group 4" <<Group-R3JvdXAgNA==>> as groupR3JvdXAgNA== { + rectangle "==F\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as D.F + } + + rectangle "==E\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as D.E + } + + C --> D.E <<Relationship-UmVsYXRpb25zaGlw>> : "" + C --> D.F <<Relationship-UmVsYXRpb25zaGlw>> : "" + + @enduml""", diagram.getDefinition()); + + diagram = diagrams.stream().filter(md -> md.getKey().equals("Components")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Component View: D - F</size> + + set separator none + top to bottom direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // D + .Boundary-RA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // F + .Boundary-Rg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 5 + .Group-R3JvdXAgNQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "==C\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as C + + rectangle "D\\n<size:16>[Software System]</size>" <<Boundary-RA==>> { + rectangle "F\\n<size:16>[Container]</size>" <<Boundary-Rg==>> { + rectangle "Group 5" <<Group-R3JvdXAgNQ==>> as groupR3JvdXAgNQ== { + rectangle "==H\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as D.F.H + } + + rectangle "==G\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as D.F.G + } + + } + + C --> D.F.G <<Relationship-UmVsYXRpb25zaGlw>> : "" + C --> D.F.H <<Relationship-UmVsYXRpb25zaGlw>> : "" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void nestedGroups() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addProperty("structurizr.groupSeparator", "/"); + + SoftwareSystem a = workspace.getModel().addSoftwareSystem("Team 1"); + a.setGroup("Organisation 1/Department 1/Team 1"); + + SoftwareSystem b = workspace.getModel().addSoftwareSystem("Team 2"); + b.setGroup("Organisation 1/Department 1/Team 2"); + + SoftwareSystem c = workspace.getModel().addSoftwareSystem("Organisation 1"); + c.setGroup("Organisation 1"); + + SoftwareSystem d = workspace.getModel().addSoftwareSystem("Organisation 2"); + d.setGroup("Organisation 2"); + + SoftwareSystem e = workspace.getModel().addSoftwareSystem("Department 1"); + e.setGroup("Organisation 1/Department 1"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("SystemLandscape"); + view.addAllElements(); + + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Organisation 1/Department 1/Team 1").color("#ff0000"); + workspace.getViews().getConfiguration().getStyles().addElementStyle("Group:Organisation 1/Department 1/Team 2").color("#0000ff"); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Collection<Diagram> diagrams = exporter.export(workspace); + + Diagram diagram = diagrams.stream().filter(md -> md.getKey().equals("SystemLandscape")).findFirst().get(); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Organisation 1 + .Group-T3JnYW5pc2F0aW9uIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Organisation 1/Department 1 + .Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAx { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Organisation 1/Department 1/Team 1 + .Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #ff0000; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Organisation 1/Department 1/Team 2 + .Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #0000ff; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Organisation 2 + .Group-T3JnYW5pc2F0aW9uIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Organisation 1" <<Group-T3JnYW5pc2F0aW9uIDE=>> as groupT3JnYW5pc2F0aW9uIDE= { + rectangle "==Organisation 1\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Organisation1 + rectangle "Department 1" <<Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAx>> as groupT3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAx { + rectangle "==Department 1\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Department1 + rectangle "Team 1" <<Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMQ==>> as groupT3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMQ== { + rectangle "==Team 1\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Team1 + } + + rectangle "Team 2" <<Group-T3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMg==>> as groupT3JnYW5pc2F0aW9uIDEvRGVwYXJ0bWVudCAxL1RlYW0gMg== { + rectangle "==Team 2\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Team2 + } + + } + + } + + rectangle "Organisation 2" <<Group-T3JnYW5pc2F0aW9uIDI=>> as groupT3JnYW5pc2F0aW9uIDI= { + rectangle "==Organisation 2\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Organisation2 + } + + + @enduml""", diagram.getDefinition()); + } + + @Test + public void containerDiagramWithExternalContainers() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + + container1.uses(container2, "Uses"); + + ContainerView containerView = workspace.getViews().createContainerView(softwareSystem1, "Containers", ""); + containerView.add(container1); + containerView.add(container2); + + Diagram diagram = new StructurizrPlantUMLExporter().export(containerView); + assertEquals(""" + @startuml + title <size:24>Container View: Software System 1</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Software System 1 + .Boundary-U29mdHdhcmUgU3lzdGVtIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 2 + .Boundary-U29mdHdhcmUgU3lzdGVtIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System 1\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDE=>> { + rectangle "==Container 1\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1 + } + + rectangle "Software System 2\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDI=>> { + rectangle "==Container 2\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container2 + } + + SoftwareSystem1.Container1 --> SoftwareSystem2.Container2 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void componentDiagram() { + Workspace workspace = new Workspace("Name"); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + Component component2 = container1.addComponent("Component 2"); + Container container2 = softwareSystem1.addContainer("Container 2"); + + component1.uses(component2, "Uses"); + component2.uses(container2, "Uses"); + + ComponentView componentView = workspace.getViews().createComponentView(container1, "Components", ""); + componentView.addDefaultElements(); + + Diagram diagram = new StructurizrPlantUMLExporter().export(componentView); + assertEquals(""" + @startuml + title <size:24>Component View: Software System 1 - Container 1</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Container 1 + .Boundary-Q29udGFpbmVyIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 1 + .Boundary-U29mdHdhcmUgU3lzdGVtIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System 1\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDE=>> { + rectangle "Container 1\\n<size:16>[Container]</size>" <<Boundary-Q29udGFpbmVyIDE=>> { + rectangle "==Component 1\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component1 + rectangle "==Component 2\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component2 + } + + rectangle "==Container 2\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container2 + } + + SoftwareSystem1.Container1.Component1 --> SoftwareSystem1.Container1.Component2 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + SoftwareSystem1.Container1.Component2 --> SoftwareSystem1.Container2 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void componentDiagramWithExternalComponents() { + Workspace workspace = new Workspace("Name"); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + Component component2 = container1.addComponent("Component 2"); + + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component3 = container2.addComponent("Component 3"); + Container container4 = softwareSystem2.addContainer("Container 4"); + + component1.uses(component2, "Uses"); + component2.uses(component3, "Uses"); + component3.uses(container4, "Uses"); + + ComponentView componentView = workspace.getViews().createComponentView(container1, "Components", ""); + componentView.add(component1); + componentView.add(component2); + componentView.add(component3); + componentView.add(container4); + + Diagram diagram = new StructurizrPlantUMLExporter().export(componentView); + assertEquals(""" + @startuml + title <size:24>Component View: Software System 1 - Container 1</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Container 1 + .Boundary-Q29udGFpbmVyIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Container 2 + .Boundary-Q29udGFpbmVyIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 1 + .Boundary-U29mdHdhcmUgU3lzdGVtIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 2 + .Boundary-U29mdHdhcmUgU3lzdGVtIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System 1\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDE=>> { + rectangle "Container 1\\n<size:16>[Container]</size>" <<Boundary-Q29udGFpbmVyIDE=>> { + rectangle "==Component 1\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component1 + rectangle "==Component 2\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component2 + } + + } + + rectangle "Software System 2\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDI=>> { + rectangle "Container 2\\n<size:16>[Container]</size>" <<Boundary-Q29udGFpbmVyIDI=>> { + rectangle "==Component 3\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container2.Component3 + } + + rectangle "==Container 4\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container4 + } + + SoftwareSystem1.Container1.Component2 --> SoftwareSystem2.Container2.Component3 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + SoftwareSystem2.Container2.Component3 --> SoftwareSystem2.Container4 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + SoftwareSystem1.Container1.Component1 --> SoftwareSystem1.Container1.Component2 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_ExternalContainers() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + + container1.uses(container2, "Uses"); + + DynamicView dynamicView = workspace.getViews().createDynamicView(softwareSystem1, "Dynamic", ""); + dynamicView.add(container1, container2); + + Diagram diagram = new StructurizrPlantUMLExporter().export(dynamicView); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Software System 1</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Software System 1 + .Boundary-U29mdHdhcmUgU3lzdGVtIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 2 + .Boundary-U29mdHdhcmUgU3lzdGVtIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System 1\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDE=>> { + rectangle "==Container 1\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1 + } + + rectangle "Software System 2\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDI=>> { + rectangle "==Container 2\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container2 + } + + SoftwareSystem1.Container1 --> SoftwareSystem2.Container2 <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_ExternalComponents() { + Workspace workspace = new Workspace("Name"); + + SoftwareSystem softwareSystem1 = workspace.getModel().addSoftwareSystem("Software System 1"); + Container container1 = softwareSystem1.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + Component component2 = container1.addComponent("Component 2"); + + SoftwareSystem softwareSystem2 = workspace.getModel().addSoftwareSystem("Software System 2"); + Container container2 = softwareSystem2.addContainer("Container 2"); + Component component3 = container2.addComponent("Component 3"); + Container container4 = softwareSystem2.addContainer("Container 4"); + + component1.uses(component2, "Uses"); + component2.uses(component3, "Uses"); + component3.uses(container4, "Uses"); + + DynamicView dynamicView = workspace.getViews().createDynamicView(container1, "Dynamic", ""); + dynamicView.add(component1, component2); + dynamicView.add(component2, component3); + dynamicView.add(component3, container4); + + Diagram diagram = new StructurizrPlantUMLExporter().export(dynamicView); + assertEquals(""" + @startuml + title <size:24>Dynamic View: Software System 1 - Container 1</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Container 1 + .Boundary-Q29udGFpbmVyIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Container 2 + .Boundary-Q29udGFpbmVyIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 1 + .Boundary-U29mdHdhcmUgU3lzdGVtIDE= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Software System 2 + .Boundary-U29mdHdhcmUgU3lzdGVtIDI= { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Software System 1\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDE=>> { + rectangle "Container 1\\n<size:16>[Container]</size>" <<Boundary-Q29udGFpbmVyIDE=>> { + rectangle "==Component 1\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component1 + rectangle "==Component 2\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem1.Container1.Component2 + } + + } + + rectangle "Software System 2\\n<size:16>[Software System]</size>" <<Boundary-U29mdHdhcmUgU3lzdGVtIDI=>> { + rectangle "Container 2\\n<size:16>[Container]</size>" <<Boundary-Q29udGFpbmVyIDI=>> { + rectangle "==Component 3\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container2.Component3 + } + + rectangle "==Container 4\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container4 + } + + rectangle "==Container 4\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem2.Container4 + + SoftwareSystem1.Container1.Component1 --> SoftwareSystem1.Container1.Component2 <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + SoftwareSystem1.Container1.Component2 --> SoftwareSystem2.Container2.Component3 <<Relationship-UmVsYXRpb25zaGlw>> : "2: Uses" + SoftwareSystem2.Container2.Component3 --> SoftwareSystem2.Container4 <<Relationship-UmVsYXRpb25zaGlw>> : "3: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void elementUrls() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + softwareSystem.setUrl("https://structurizr.com"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addDefaultElements(); + + Diagram diagram = new StructurizrPlantUMLExporter().export(view); + assertTrue(diagram.getDefinition().contains("as SoftwareSystem [[https://structurizr.com]]")); + } + + @Test + public void elementInstanceUrl() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + a.setUrl("https://example.com/url1"); + SoftwareSystemInstance aInstance = workspace.getModel().addDeploymentNode("Node").add(a); + + DeploymentView view = workspace.getViews().createDeploymentView("deployment", "Default"); + view.add(aInstance); + + assertTrue(new StructurizrPlantUMLExporter().export(view).getDefinition().contains("as Default.Node.A_1 [[https://example.com/url1]]")); + + aInstance.setUrl("https://example.com/url2"); + assertTrue(new StructurizrPlantUMLExporter().export(view).getDefinition().contains("as Default.Node.A_1 [[https://example.com/url2]]")); + } + + @Test + public void newLineCharacterInElementName() { + Workspace workspace = new Workspace("Name"); + workspace.getModel().addSoftwareSystem("Software\nSystem"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addDefaultElements(); + + Diagram diagram = new StructurizrPlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + </style> + + rectangle "==Software\\nSystem\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as SoftwareSystem + + @enduml""", diagram.getDefinition()); + } + + @Test + public void customView() { + Workspace workspace = new Workspace("Name"); + Model model = workspace.getModel(); + + CustomElement a = model.addCustomElement("A"); + CustomElement b = model.addCustomElement("B", "Custom", "Description"); + a.uses(b, "Uses"); + + CustomView view = workspace.getViews().createCustomView("key", "Title"); + view.addDefaultElements(); + + Diagram diagram = new StructurizrPlantUMLExporter().export(view); + assertEquals(""" + @startuml + title <size:24>Title</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + </style> + + rectangle "==A" <<Element-RWxlbWVudA==>> as 1 + rectangle "==B\\n<size:16>[Custom]</size>\\n\\nDescription" <<Element-RWxlbWVudA==>> as 2 + + 1 --> 2 <<Relationship-UmVsYXRpb25zaGlw>> : "Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + void renderWorkspaceWithUnicodeElementName() { + Workspace workspace = new Workspace("Name"); + workspace.getModel().addPerson("Пользователь"); + workspace.getViews().createSystemLandscapeView("key", "Description").addDefaultElements(); + + String diagramDefinition = new StructurizrPlantUMLExporter().export(workspace).stream().findFirst().get().getDefinition(); + + assertEquals(""" + @startuml + title <size:24>System Landscape View</size>\\n<size:24>Description</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + </style> + + rectangle "==Пользователь\\n<size:16>[Person]</size>" <<Element-RWxlbWVudA==>> as Пользователь + + @enduml""", diagramDefinition); + } + + @Test + public void font() { + Workspace workspace = new Workspace("Name"); + workspace.getModel().addPerson("User"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addAllElements(); + workspace.getViews().getConfiguration().getBranding().setFont(new Font("Courier")); + + Diagram diagram = new StructurizrPlantUMLExporter().export(view); + assertTrue(diagram.getDefinition().contains(""" + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + FontName: Courier; + }""")); + + assertTrue(diagram.getLegend().getDefinition().contains(""" + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + FontName: Courier; + }""")); + } + + @Test + public void include() { + Workspace workspace = new Workspace("Name"); + workspace.getModel().addSoftwareSystem("A"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addDefaultElements(); + + view.getViewSet().getConfiguration().addProperty(StructurizrPlantUMLExporter.PLANTUML_INCLUDES_PROPERTY, "styles.puml"); + + Diagram diagram = new StructurizrPlantUMLExporter().export(view); + System.out.println(diagram.getDefinition()); + assertTrue(diagram.getDefinition().contains("!include styles.puml\n")); + } + + @Test + public void dynamicView_UnscopedWithGroups() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystemA = workspace.getModel().addSoftwareSystem("A"); + softwareSystemA.setGroup("Group 1"); + SoftwareSystem softwareSystemB = workspace.getModel().addSoftwareSystem("B"); + softwareSystemB.setGroup("Group 2"); + softwareSystemA.uses(softwareSystemB, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key"); + view.add(softwareSystemA, softwareSystemB); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>Dynamic View</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Group 1 + .Group-R3JvdXAgMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2 + .Group-R3JvdXAgMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Group 1" <<Group-R3JvdXAgMQ==>> as groupR3JvdXAgMQ== { + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + } + + rectangle "Group 2" <<Group-R3JvdXAgMg==>> as groupR3JvdXAgMg== { + rectangle "==B\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as B + } + + + A --> B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_SoftwareSystemScopedWithGroups() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystemA = workspace.getModel().addSoftwareSystem("A"); + Container containerA = softwareSystemA.addContainer("A"); + containerA.setGroup("Group 1"); + SoftwareSystem softwareSystemB = workspace.getModel().addSoftwareSystem("B"); + Container containerB = softwareSystemB.addContainer("B"); + containerB.setGroup("Group 2"); + containerA.uses(containerB, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView(softwareSystemA, "key"); + view.add(containerA, containerB); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>Dynamic View: A</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // A + .Boundary-QQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // B + .Boundary-Qg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 1 + .Group-R3JvdXAgMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2 + .Group-R3JvdXAgMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "A\\n<size:16>[Software System]</size>" <<Boundary-QQ==>> { + rectangle "Group 1" <<Group-R3JvdXAgMQ==>> as groupR3JvdXAgMQ== { + rectangle "==A\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as A.A + } + + } + + rectangle "B\\n<size:16>[Software System]</size>" <<Boundary-Qg==>> { + rectangle "Group 2" <<Group-R3JvdXAgMg==>> as groupR3JvdXAgMg== { + rectangle "==B\\n<size:16>[Container]</size>" <<Element-RWxlbWVudA==>> as B.B + } + + } + + A.A --> B.B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void dynamicView_ContainerScopedWithGroups() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystemA = workspace.getModel().addSoftwareSystem("A"); + Container containerA = softwareSystemA.addContainer("A"); + Component componentA = containerA.addComponent("A"); + componentA.setGroup("Group 1"); + SoftwareSystem softwareSystemB = workspace.getModel().addSoftwareSystem("B"); + Container containerB = softwareSystemB.addContainer("B"); + Component componentB = containerB.addComponent("B"); + componentB.setGroup("Group 2"); + componentA.uses(componentB, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView(containerA, "key"); + view.add(componentA, componentB); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>Dynamic View: A - A</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // A + .Boundary-QQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // B + .Boundary-Qg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 1 + .Group-R3JvdXAgMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2 + .Group-R3JvdXAgMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "A\\n<size:16>[Software System]</size>" <<Boundary-QQ==>> { + rectangle "A\\n<size:16>[Container]</size>" <<Boundary-QQ==>> { + rectangle "Group 1" <<Group-R3JvdXAgMQ==>> as groupR3JvdXAgMQ== { + rectangle "==A\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as A.A.A + } + + } + + } + + rectangle "B\\n<size:16>[Software System]</size>" <<Boundary-Qg==>> { + rectangle "B\\n<size:16>[Container]</size>" <<Boundary-Qg==>> { + rectangle "Group 2" <<Group-R3JvdXAgMg==>> as groupR3JvdXAgMg== { + rectangle "==B\\n<size:16>[Component]</size>" <<Element-RWxlbWVudA==>> as B.B.B + } + + } + + } + + A.A.A --> B.B.B <<Relationship-UmVsYXRpb25zaGlw>> : "1: Uses" + + @enduml""", diagram.getDefinition()); + } + + @Test + public void deploymentView_WithGroups() { + Workspace workspace = new Workspace("Name", ""); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + DeploymentNode server1 = workspace.getModel().addDeploymentNode("Server 1"); + server1.setGroup("Group 1"); + + InfrastructureNode infrastructureNode1 = server1.addInfrastructureNode("Infrastructure Node 1"); + InfrastructureNode infrastructureNode2 = server1.addInfrastructureNode("Infrastructure Node 2"); + + SoftwareSystemInstance softwareSystemInstance = server1.add(softwareSystem); + softwareSystemInstance.setGroup("Group 2"); + infrastructureNode2.setGroup("Group 2"); + + DeploymentView view = workspace.getViews().createDeploymentView("key"); + view.add(infrastructureNode1); + view.add(infrastructureNode2); + view.add(softwareSystemInstance); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>Deployment View: Default</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element + .DeploymentNode-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 1 + .Group-R3JvdXAgMQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Group 2 + .Group-R3JvdXAgMg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Group 1" <<Group-R3JvdXAgMQ==>> as groupR3JvdXAgMQ== { + rectangle "Server 1\\n<size:16>[Deployment Node]</size>" <<DeploymentNode-RWxlbWVudA==>> as Default.Server1 { + rectangle "Group 2" <<Group-R3JvdXAgMg==>> as groupR3JvdXAgMg== { + rectangle "==Infrastructure Node 2\\n<size:16>[Infrastructure Node]</size>" <<Element-RWxlbWVudA==>> as Default.Server1.InfrastructureNode2 + rectangle "==Software System\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Default.Server1.SoftwareSystem_1 + } + + rectangle "==Infrastructure Node 1\\n<size:16>[Infrastructure Node]</size>" <<Element-RWxlbWVudA==>> as Default.Server1.InfrastructureNode1 + } + + } + + @enduml""", diagram.getDefinition()); + } + + @Test + void light_group() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + softwareSystem.setGroup("Name"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.add(softwareSystem); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Name + .Group-TmFtZQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Name" <<Group-TmFtZQ==>> as groupTmFtZQ== { + rectangle "==Name\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Name + } + + + @enduml""", diagram.getDefinition()); + } + + @Test + void dark_group() { + Workspace workspace = new Workspace("Name"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + softwareSystem.setGroup("Name"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.add(softwareSystem); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Dark); + Diagram diagram = exporter.export(view); + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + hide stereotype + + <style> + root { + BackgroundColor: #111111; + FontColor: #cccccc; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Name + .Group-TmFtZQ== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 2-2; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Name" <<Group-TmFtZQ==>> as groupTmFtZQ== { + rectangle "==Name\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as Name + } + + + @enduml""", diagram.getDefinition()); + } + + @Test + @Tag("IntegrationTest") + public void amazonWebServicesExample_Light() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Light); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: X - Live</size> + + set separator none + left to right direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Amazon Web Services - Elastic Load Balancing + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw== { + BackgroundColor: #ffffff; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Amazon Web Services - Route 53 + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM= { + BackgroundColor: #ffffff; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Application + .Element-RWxlbWVudCxBcHBsaWNhdGlvbg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + RoundCorner: 20; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Database + .Element-RWxlbWVudCxEYXRhYmFzZQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Element,Amazon Web Services - Auto Scaling + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n { + BackgroundColor: #ffffff; + LineColor: #cc2264; + LineStyle: 0; + LineThickness: 2; + FontColor: #cc2264; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - Cloud + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ= { + BackgroundColor: #ffffff; + LineColor: #232f3e; + LineStyle: 0; + LineThickness: 2; + FontColor: #232f3e; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - EC2 + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy { + BackgroundColor: #ffffff; + LineColor: #d86613; + LineStyle: 0; + LineThickness: 2; + FontColor: #d86613; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - RDS + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT { + BackgroundColor: #ffffff; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - RDS MySQL instance + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl { + BackgroundColor: #ffffff; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - Region + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u { + BackgroundColor: #ffffff; + LineColor: #147eba; + LineStyle: 0; + LineThickness: 2; + FontColor: #147eba; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Amazon Web Services\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ=>> as Live.AmazonWebServices { + rectangle "US-East-1\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u>> as Live.AmazonWebServices.USEast1 { + rectangle "Autoscaling group\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n>> as Live.AmazonWebServices.USEast1.Autoscalinggroup { + rectangle "Amazon EC2 - Ubuntu server\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy>> as Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver { + rectangle "==Web Application\\n<size:16>[Container: Java and Spring Boot]</size>" <<Element-RWxlbWVudCxBcHBsaWNhdGlvbg==>> as Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 + } + + } + + rectangle "Amazon RDS\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT>> as Live.AmazonWebServices.USEast1.AmazonRDS { + rectangle "MySQL\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png{scale=0.36}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl>> as Live.AmazonWebServices.USEast1.AmazonRDS.MySQL { + database "==Database Schema\\n<size:16>[Container]</size>" <<Element-RWxlbWVudCxEYXRhYmFzZQ==>> as Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1 + } + + } + + rectangle "==DNS router\\n<size:16>[Infrastructure Node: Route 53]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-route-53.png{scale=0.24}>\\n\\nRoutes incoming requests based upon domain name." <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM=>> as Live.AmazonWebServices.USEast1.DNSrouter + rectangle "==Load Balancer\\n<size:16>[Infrastructure Node: Elastic Load Balancer]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png{scale=0.24}>\\n\\nAutomatically distributes incoming application traffic." <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw==>> as Live.AmazonWebServices.USEast1.LoadBalancer + } + + } + + Live.AmazonWebServices.USEast1.LoadBalancer --> Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Forwards requests to\\n<size:16>[HTTPS]</size>" + Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 --> Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[MySQL Protocol/SSL]</size>" + Live.AmazonWebServices.USEast1.DNSrouter --> Live.AmazonWebServices.USEast1.LoadBalancer <<Relationship-UmVsYXRpb25zaGlw>> : "Forwards requests to\\n<size:16>[HTTPS]</size>" + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element,Amazon Web Services - Elastic Load Balancing + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw== { + BackgroundColor: #ffffff; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Route 53 + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM= { + BackgroundColor: #ffffff; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Application + .Element-RWxlbWVudCxBcHBsaWNhdGlvbg== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + RoundCorner: 20; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Database + .Element-RWxlbWVudCxEYXRhYmFzZQ== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #444444; + FontColor: #444444; + FontSize: 24; + } + // Element,Amazon Web Services - Auto Scaling + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n { + BackgroundColor: #ffffff; + LineColor: #cc2264; + LineStyle: 0; + LineThickness: 2; + FontColor: #cc2264; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Cloud + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ= { + BackgroundColor: #ffffff; + LineColor: #232f3e; + LineStyle: 0; + LineThickness: 2; + FontColor: #232f3e; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - EC2 + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy { + BackgroundColor: #ffffff; + LineColor: #d86613; + LineStyle: 0; + LineThickness: 2; + FontColor: #d86613; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - RDS + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT { + BackgroundColor: #ffffff; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - RDS MySQL instance + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl { + BackgroundColor: #ffffff; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Region + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u { + BackgroundColor: #ffffff; + LineColor: #147eba; + LineStyle: 0; + LineThickness: 2; + FontColor: #147eba; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + rectangle "==Amazon Web Services - Auto Scaling\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n>> + + rectangle "==Amazon Web Services - Cloud\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ=>> + + rectangle "==Amazon Web Services - EC2\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy>> + + rectangle "==Amazon Web Services - RDS\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT>> + + rectangle "==Amazon Web Services - RDS MySQL instance\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png{scale=0.36}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl>> + + rectangle "==Amazon Web Services - Region\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u>> + + rectangle "==Amazon Web Services - Elastic Load Balancing\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png{scale=0.24}>" <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw==>> + + rectangle "==Amazon Web Services - Route 53\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-route-53.png{scale=0.24}>" <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM=>> + + rectangle "==Application" <<Element-RWxlbWVudCxBcHBsaWNhdGlvbg==>> + + database "==Database" <<Element-RWxlbWVudCxEYXRhYmFzZQ==>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + @Tag("IntegrationTest") + public void amazonWebServicesExample_Dark() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/amazon-web-services.json")); + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + ThemeUtils.loadThemes(workspace, httpClient); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(ColorScheme.Dark); + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().findFirst().get(); + assertEquals(""" + @startuml + title <size:24>Deployment View: X - Live</size> + + set separator none + left to right direction + skinparam ranksep 60 + skinparam nodesep 30 + hide stereotype + + <style> + root { + BackgroundColor: #111111; + FontColor: #cccccc; + } + // Element,Amazon Web Services - Elastic Load Balancing + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw== { + BackgroundColor: #111111; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Amazon Web Services - Route 53 + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM= { + BackgroundColor: #111111; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Application + .Element-RWxlbWVudCxBcHBsaWNhdGlvbg== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + RoundCorner: 20; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Element,Database + .Element-RWxlbWVudCxEYXRhYmFzZQ== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #cccccc; + FontColor: #cccccc; + FontSize: 24; + } + // Element,Amazon Web Services - Auto Scaling + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n { + BackgroundColor: #111111; + LineColor: #cc2264; + LineStyle: 0; + LineThickness: 2; + FontColor: #cc2264; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - Cloud + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ= { + BackgroundColor: #111111; + LineColor: #232f3e; + LineStyle: 0; + LineThickness: 2; + FontColor: #232f3e; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - EC2 + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy { + BackgroundColor: #111111; + LineColor: #d86613; + LineStyle: 0; + LineThickness: 2; + FontColor: #d86613; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - RDS + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT { + BackgroundColor: #111111; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - RDS MySQL instance + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl { + BackgroundColor: #111111; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + // Element,Amazon Web Services - Region + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u { + BackgroundColor: #111111; + LineColor: #147eba; + LineStyle: 0; + LineThickness: 2; + FontColor: #147eba; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + } + </style> + + rectangle "Amazon Web Services\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ=>> as Live.AmazonWebServices { + rectangle "US-East-1\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u>> as Live.AmazonWebServices.USEast1 { + rectangle "Autoscaling group\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n>> as Live.AmazonWebServices.USEast1.Autoscalinggroup { + rectangle "Amazon EC2 - Ubuntu server\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy>> as Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver { + rectangle "==Web Application\\n<size:16>[Container: Java and Spring Boot]</size>" <<Element-RWxlbWVudCxBcHBsaWNhdGlvbg==>> as Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 + } + + } + + rectangle "Amazon RDS\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT>> as Live.AmazonWebServices.USEast1.AmazonRDS { + rectangle "MySQL\\n<size:16>[Deployment Node]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png{scale=0.36}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl>> as Live.AmazonWebServices.USEast1.AmazonRDS.MySQL { + database "==Database Schema\\n<size:16>[Container]</size>" <<Element-RWxlbWVudCxEYXRhYmFzZQ==>> as Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1 + } + + } + + rectangle "==DNS router\\n<size:16>[Infrastructure Node: Route 53]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-route-53.png{scale=0.24}>\\n\\nRoutes incoming requests based upon domain name." <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM=>> as Live.AmazonWebServices.USEast1.DNSrouter + rectangle "==Load Balancer\\n<size:16>[Infrastructure Node: Elastic Load Balancer]</size>\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png{scale=0.24}>\\n\\nAutomatically distributes incoming application traffic." <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw==>> as Live.AmazonWebServices.USEast1.LoadBalancer + } + + } + + Live.AmazonWebServices.USEast1.LoadBalancer --> Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Forwards requests to\\n<size:16>[HTTPS]</size>" + Live.AmazonWebServices.USEast1.Autoscalinggroup.AmazonEC2Ubuntuserver.WebApplication_1 --> Live.AmazonWebServices.USEast1.AmazonRDS.MySQL.DatabaseSchema_1 <<Relationship-UmVsYXRpb25zaGlw>> : "Reads from and writes to\\n<size:16>[MySQL Protocol/SSL]</size>" + Live.AmazonWebServices.USEast1.DNSrouter --> Live.AmazonWebServices.USEast1.LoadBalancer <<Relationship-UmVsYXRpb25zaGlw>> : "Forwards requests to\\n<size:16>[HTTPS]</size>" + + @enduml""", diagram.getDefinition()); + + assertEquals(""" + @startuml + + set separator none + hide stereotype + + <style> + root { + BackgroundColor: #111111; + FontColor: #cccccc; + } + // Element,Amazon Web Services - Elastic Load Balancing + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw== { + BackgroundColor: #111111; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Route 53 + .Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM= { + BackgroundColor: #111111; + LineColor: #693cc5; + LineStyle: 0; + LineThickness: 2; + FontColor: #693cc5; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Application + .Element-RWxlbWVudCxBcHBsaWNhdGlvbg== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + RoundCorner: 20; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Database + .Element-RWxlbWVudCxEYXRhYmFzZQ== { + BackgroundColor: #111111; + LineColor: #cccccc; + LineStyle: 0; + LineThickness: 2; + FontColor: #cccccc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Relationship + .Relationship-UmVsYXRpb25zaGlw { + LineThickness: 2; + LineStyle: 10-10; + LineColor: #cccccc; + FontColor: #cccccc; + FontSize: 24; + } + // Element,Amazon Web Services - Auto Scaling + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n { + BackgroundColor: #111111; + LineColor: #cc2264; + LineStyle: 0; + LineThickness: 2; + FontColor: #cc2264; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Cloud + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ= { + BackgroundColor: #111111; + LineColor: #232f3e; + LineStyle: 0; + LineThickness: 2; + FontColor: #232f3e; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - EC2 + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy { + BackgroundColor: #111111; + LineColor: #d86613; + LineStyle: 0; + LineThickness: 2; + FontColor: #d86613; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - RDS + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT { + BackgroundColor: #111111; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - RDS MySQL instance + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl { + BackgroundColor: #111111; + LineColor: #3b48cc; + LineStyle: 0; + LineThickness: 2; + FontColor: #3b48cc; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // Element,Amazon Web Services - Region + .DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u { + BackgroundColor: #111111; + LineColor: #147eba; + LineStyle: 0; + LineThickness: 2; + FontColor: #147eba; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 200; + } + // transparent element for relationships in legend + .Element-Transparent { + BackgroundColor: transparent; + LineColor: transparent; + FontColor: transparent; + } + </style> + + rectangle "==Amazon Web Services - Auto Scaling\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-auto-scaling.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5n>> + + rectangle "==Amazon Web Services - Cloud\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/aws-cloud.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gQ2xvdWQ=>> + + rectangle "==Amazon Web Services - EC2\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-ec2.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMy>> + + rectangle "==Amazon Web Services - RDS\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds.png{scale=0.24}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRT>> + + rectangle "==Amazon Web Services - RDS MySQL instance\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-rds-mysql-instance.png{scale=0.36}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNl>> + + rectangle "==Amazon Web Services - Region\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/region.png{scale=0.5142857142857142}>" <<DeploymentNode-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9u>> + + rectangle "==Amazon Web Services - Elastic Load Balancing\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/elastic-load-balancing.png{scale=0.24}>" <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gRWxhc3RpYyBMb2FkIEJhbGFuY2luZw==>> + + rectangle "==Amazon Web Services - Route 53\\n\\n<img:https://static.structurizr.com/themes/amazon-web-services-2020.04.30/amazon-route-53.png{scale=0.24}>" <<Element-RWxlbWVudCxBbWF6b24gV2ViIFNlcnZpY2VzIC0gUm91dGUgNTM=>> + + rectangle "==Application" <<Element-RWxlbWVudCxBcHBsaWNhdGlvbg==>> + + database "==Database" <<Element-RWxlbWVudCxEYXRhYmFzZQ==>> + + rectangle "." <<.Element-Transparent>> as 1 + 1 --> 1 <<Relationship-UmVsYXRpb25zaGlw>> : "Relationship" + + @enduml""", diagram.getLegend().getDefinition()); + } + + @Test + void skinparams() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("A"); + + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key"); + view.addAllElements(); + + StructurizrPlantUMLExporter exporter = new StructurizrPlantUMLExporter(); + exporter.addSkinParam("linetype", "ortho"); + Diagram diagram = exporter.export(view); + + assertEquals(""" + @startuml + title <size:24>System Landscape View</size> + + set separator none + top to bottom direction + hide stereotype + + skinparam { + linetype ortho + } + + <style> + root { + BackgroundColor: #ffffff; + FontColor: #444444; + } + // Element + .Element-RWxlbWVudA== { + BackgroundColor: #ffffff; + LineColor: #444444; + LineStyle: 0; + LineThickness: 2; + FontColor: #444444; + FontSize: 24; + HorizontalAlignment: center; + Shadowing: 0; + MaximumWidth: 450; + } + </style> + + rectangle "==A\\n<size:16>[Software System]</size>" <<Element-RWxlbWVudA==>> as A + + @enduml""", diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporterTests.java b/structurizr-export/src/test/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporterTests.java new file mode 100644 index 000000000..9e9d6b8a3 --- /dev/null +++ b/structurizr-export/src/test/java/com/structurizr/export/websequencediagrams/WebSequenceDiagramsExporterTests.java @@ -0,0 +1,68 @@ +package com.structurizr.export.websequencediagrams; + +import com.structurizr.Workspace; +import com.structurizr.export.AbstractExporterTests; +import com.structurizr.export.Diagram; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.util.WorkspaceUtils; +import com.structurizr.view.DynamicView; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +public class WebSequenceDiagramsExporterTests extends AbstractExporterTests { + + @Test + public void test_BigBankPlcExample() throws Exception { + Workspace workspace = WorkspaceUtils.loadWorkspaceFromJson(new File("./src/test/resources/big-bank-plc.json")); + WebSequenceDiagramsExporter exporter = new WebSequenceDiagramsExporter(); + + Collection<Diagram> diagrams = exporter.export(workspace); + assertEquals(1, diagrams.size()); + + Diagram diagram = diagrams.stream().filter(d -> d.getKey().equals("SignIn")).findFirst().get(); + assertEquals(""" + title Dynamic View: Internet Banking System - API Application\\nSummarises how the sign in feature works in the single-page application. + + participant <<Container>>\\nSingle-Page Application as Single-Page Application + participant <<Component>>\\nSign In Controller as Sign In Controller + participant <<Component>>\\nSecurity Component as Security Component + participant <<Container>>\\nDatabase as Database + + Single-Page Application->Sign In Controller: Submits credentials to + Sign In Controller->Security Component: Validates credentials using + Security Component->Database: select * from users where username = ? + Database-->Security Component: Returns user data to + Security Component-->Sign In Controller: Returns true if the hashed password matches + Sign In Controller-->Single-Page Application: Sends back an authentication token to + """, diagram.getDefinition()); + } + + @Test + public void test_dynamicViewThatDoeNotOverrideRelationshipDescriptions() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + a.uses(b, "Uses"); + + DynamicView view = workspace.getViews().createDynamicView("key", "Description"); + view.add(a, b); + + WebSequenceDiagramsExporter exporter = new WebSequenceDiagramsExporter(); + + Collection<Diagram> diagrams = exporter.export(workspace); + Diagram diagram = diagrams.iterator().next(); + assertEquals(""" + title Dynamic View\\nDescription + + participant <<Software System>>\\nA as A + participant <<Software System>>\\nB as B + + A->B: Uses + """, diagram.getDefinition()); + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/resources/amazon-web-services.dsl b/structurizr-export/src/test/resources/amazon-web-services.dsl new file mode 100644 index 000000000..316375363 --- /dev/null +++ b/structurizr-export/src/test/resources/amazon-web-services.dsl @@ -0,0 +1,83 @@ +workspace "Amazon Web Services Example" "An example AWS deployment architecture." { + + !identifiers hierarchical + + model { + x = softwaresystem "X" { + wa = container "Web Application" { + technology "Java and Spring Boot" + tags "Application" + } + db = container "Database Schema" { + tags "Database" + } + + wa -> db "Reads from and writes to" "MySQL Protocol/SSL" + } + + live = deploymentEnvironment "Live" { + deploymentNode "Amazon Web Services" { + tags "Amazon Web Services - Cloud" + + region = deploymentNode "US-East-1" { + tags "Amazon Web Services - Region" + + dns = infrastructureNode "DNS router" { + technology "Route 53" + description "Routes incoming requests based upon domain name." + tags "Amazon Web Services - Route 53" + } + + lb = infrastructureNode "Load Balancer" { + technology "Elastic Load Balancer" + description "Automatically distributes incoming application traffic." + tags "Amazon Web Services - Elastic Load Balancing" + dns -> this "Forwards requests to" "HTTPS" + } + + deploymentNode "Autoscaling group" { + tags "Amazon Web Services - Auto Scaling" + + deploymentNode "Amazon EC2 - Ubuntu server" { + tags "Amazon Web Services - EC2" + + webApplicationInstance = containerInstance x.wa { + lb -> this "Forwards requests to" "HTTPS" + } + } + } + + deploymentNode "Amazon RDS" { + tags "Amazon Web Services - RDS" + + deploymentNode "MySQL" { + tags "Amazon Web Services - RDS MySQL instance" + + databaseInstance = containerInstance x.db + } + } + + } + } + } + } + + views { + deployment x live "AmazonWebServicesDeployment" { + include * + autolayout lr + } + + styles { + element "Application" { + shape roundedbox + } + element "Database" { + shape cylinder + } + } + + themes https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json + } + +} \ No newline at end of file diff --git a/structurizr-export/src/test/resources/amazon-web-services.json b/structurizr-export/src/test/resources/amazon-web-services.json new file mode 100644 index 000000000..5214dea5f --- /dev/null +++ b/structurizr-export/src/test/resources/amazon-web-services.json @@ -0,0 +1,257 @@ +{ + "configuration" : { }, + "description" : "An example AWS deployment architecture.", + "documentation" : { }, + "id" : 0, + "model" : { + "deploymentNodes" : [ { + "children" : [ { + "children" : [ { + "children" : [ { + "containerInstances" : [ { + "containerId" : "2", + "deploymentGroups" : [ "Default" ], + "environment" : "Live", + "id" : "12", + "instanceId" : 1, + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.d846537e-73a4-40fa-b64a-0abf118a5241.53a89646-c975-4c46-a3d5-422634503b7e.webapplicationinstance" + }, + "relationships" : [ { + "description" : "Reads from and writes to", + "destinationId" : "16", + "id" : "17", + "linkedRelationshipId" : "4", + "sourceId" : "12", + "technology" : "MySQL Protocol/SSL" + } ], + "tags" : "Container Instance" + } ], + "environment" : "Live", + "id" : "11", + "instances" : "1", + "name" : "Amazon EC2 - Ubuntu server", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.d846537e-73a4-40fa-b64a-0abf118a5241.53a89646-c975-4c46-a3d5-422634503b7e" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - EC2" + } ], + "environment" : "Live", + "id" : "10", + "instances" : "1", + "name" : "Autoscaling group", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.d846537e-73a4-40fa-b64a-0abf118a5241" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - Auto Scaling" + }, { + "children" : [ { + "containerInstances" : [ { + "containerId" : "3", + "deploymentGroups" : [ "Default" ], + "environment" : "Live", + "id" : "16", + "instanceId" : 1, + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.989c5763-5a99-4014-9a7e-397dbc6f755e.91b9b9b5-2ca3-4bcb-8395-9d9f4d49de40.databaseinstance" + }, + "tags" : "Container Instance" + } ], + "environment" : "Live", + "id" : "15", + "instances" : "1", + "name" : "MySQL", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.989c5763-5a99-4014-9a7e-397dbc6f755e.91b9b9b5-2ca3-4bcb-8395-9d9f4d49de40" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - RDS MySQL instance" + } ], + "environment" : "Live", + "id" : "14", + "instances" : "1", + "name" : "Amazon RDS", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.989c5763-5a99-4014-9a7e-397dbc6f755e" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - RDS" + } ], + "environment" : "Live", + "id" : "6", + "infrastructureNodes" : [ { + "description" : "Routes incoming requests based upon domain name.", + "environment" : "Live", + "id" : "7", + "name" : "DNS router", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.dns" + }, + "relationships" : [ { + "description" : "Forwards requests to", + "destinationId" : "8", + "id" : "9", + "sourceId" : "7", + "tags" : "Relationship", + "technology" : "HTTPS" + } ], + "tags" : "Element,Infrastructure Node,Amazon Web Services - Route 53", + "technology" : "Route 53" + }, { + "description" : "Automatically distributes incoming application traffic.", + "environment" : "Live", + "id" : "8", + "name" : "Load Balancer", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region.lb" + }, + "relationships" : [ { + "description" : "Forwards requests to", + "destinationId" : "12", + "id" : "13", + "sourceId" : "8", + "tags" : "Relationship", + "technology" : "HTTPS" + } ], + "tags" : "Element,Infrastructure Node,Amazon Web Services - Elastic Load Balancing", + "technology" : "Elastic Load Balancer" + } ], + "instances" : "1", + "name" : "US-East-1", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4.region" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - Region" + } ], + "environment" : "Live", + "id" : "5", + "instances" : "1", + "name" : "Amazon Web Services", + "properties" : { + "structurizr.dsl.identifier" : "live.4955ad68-6fbf-42a0-a268-21d790224ef4" + }, + "tags" : "Element,Deployment Node,Amazon Web Services - Cloud" + } ], + "softwareSystems" : [ { + "containers" : [ { + "documentation" : { }, + "id" : "2", + "name" : "Web Application", + "properties" : { + "structurizr.dsl.identifier" : "x.wa" + }, + "relationships" : [ { + "description" : "Reads from and writes to", + "destinationId" : "3", + "id" : "4", + "sourceId" : "2", + "tags" : "Relationship", + "technology" : "MySQL Protocol/SSL" + } ], + "tags" : "Element,Container,Application", + "technology" : "Java and Spring Boot" + }, { + "documentation" : { }, + "id" : "3", + "name" : "Database Schema", + "properties" : { + "structurizr.dsl.identifier" : "x.db" + }, + "tags" : "Element,Container,Database" + } ], + "documentation" : { }, + "id" : "1", + "location" : "Unspecified", + "name" : "X", + "properties" : { + "structurizr.dsl.identifier" : "x" + }, + "tags" : "Element,Software System" + } ] + }, + "name" : "Amazon Web Services Example", + "properties" : { + "structurizr.inspection.error" : "23", + "structurizr.dsl" : "d29ya3NwYWNlICJBbWF6b24gV2ViIFNlcnZpY2VzIEV4YW1wbGUiICJBbiBleGFtcGxlIEFXUyBkZXBsb3ltZW50IGFyY2hpdGVjdHVyZS4iIHsKCiAgICAhaWRlbnRpZmllcnMgaGllcmFyY2hpY2FsCgogICAgbW9kZWwgewogICAgICAgIHggPSBzb2Z0d2FyZXN5c3RlbSAiWCIgewogICAgICAgICAgICB3YSA9IGNvbnRhaW5lciAiV2ViIEFwcGxpY2F0aW9uIiB7CiAgICAgICAgICAgICAgICB0ZWNobm9sb2d5ICJKYXZhIGFuZCBTcHJpbmcgQm9vdCIKICAgICAgICAgICAgICAgIHRhZ3MgIkFwcGxpY2F0aW9uIgogICAgICAgICAgICB9CiAgICAgICAgICAgIGRiID0gY29udGFpbmVyICJEYXRhYmFzZSBTY2hlbWEiIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkRhdGFiYXNlIgogICAgICAgICAgICB9CgogICAgICAgICAgICB3YSAtPiBkYiAiUmVhZHMgZnJvbSBhbmQgd3JpdGVzIHRvIiAiTXlTUUwgUHJvdG9jb2wvU1NMIgogICAgICAgIH0KCiAgICAgICAgbGl2ZSA9IGRlcGxveW1lbnRFbnZpcm9ubWVudCAiTGl2ZSIgewogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQW1hem9uIFdlYiBTZXJ2aWNlcyIgewogICAgICAgICAgICAgICAgdGFncyAiQW1hem9uIFdlYiBTZXJ2aWNlcyAtIENsb3VkIgoKICAgICAgICAgICAgICAgIHJlZ2lvbiA9IGRlcGxveW1lbnROb2RlICJVUy1FYXN0LTEiIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJBbWF6b24gV2ViIFNlcnZpY2VzIC0gUmVnaW9uIgoKICAgICAgICAgICAgICAgICAgICBkbnMgPSBpbmZyYXN0cnVjdHVyZU5vZGUgIkROUyByb3V0ZXIiIHsKICAgICAgICAgICAgICAgICAgICAgICAgdGVjaG5vbG9neSAiUm91dGUgNTMiCiAgICAgICAgICAgICAgICAgICAgICAgIGRlc2NyaXB0aW9uICJSb3V0ZXMgaW5jb21pbmcgcmVxdWVzdHMgYmFzZWQgdXBvbiBkb21haW4gbmFtZS4iCiAgICAgICAgICAgICAgICAgICAgICAgIHRhZ3MgIkFtYXpvbiBXZWIgU2VydmljZXMgLSBSb3V0ZSA1MyIKICAgICAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgICAgIGxiID0gaW5mcmFzdHJ1Y3R1cmVOb2RlICJMb2FkIEJhbGFuY2VyIiB7CiAgICAgICAgICAgICAgICAgICAgICAgIHRlY2hub2xvZ3kgIkVsYXN0aWMgTG9hZCBCYWxhbmNlciIKICAgICAgICAgICAgICAgICAgICAgICAgZGVzY3JpcHRpb24gIkF1dG9tYXRpY2FsbHkgZGlzdHJpYnV0ZXMgaW5jb21pbmcgYXBwbGljYXRpb24gdHJhZmZpYy4iCiAgICAgICAgICAgICAgICAgICAgICAgIHRhZ3MgIkFtYXpvbiBXZWIgU2VydmljZXMgLSBFbGFzdGljIExvYWQgQmFsYW5jaW5nIgogICAgICAgICAgICAgICAgICAgICAgICBkbnMgLT4gdGhpcyAiRm9yd2FyZHMgcmVxdWVzdHMgdG8iICJIVFRQUyIKICAgICAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJBdXRvc2NhbGluZyBncm91cCIgewogICAgICAgICAgICAgICAgICAgICAgICB0YWdzICJBbWF6b24gV2ViIFNlcnZpY2VzIC0gQXV0byBTY2FsaW5nIgoKICAgICAgICAgICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkFtYXpvbiBFQzIgLSBVYnVudHUgc2VydmVyIiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICB0YWdzICJBbWF6b24gV2ViIFNlcnZpY2VzIC0gRUMyIgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgIHdlYkFwcGxpY2F0aW9uSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSB4LndhIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBsYiAtPiB0aGlzICJGb3J3YXJkcyByZXF1ZXN0cyB0byIgIkhUVFBTIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQW1hem9uIFJEUyIgewogICAgICAgICAgICAgICAgICAgICAgICB0YWdzICJBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIgoKICAgICAgICAgICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIk15U1FMIiB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICB0YWdzICJBbWF6b24gV2ViIFNlcnZpY2VzIC0gUkRTIE15U1FMIGluc3RhbmNlIgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGRhdGFiYXNlSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSB4LmRiCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQogICAgfQoKICAgIHZpZXdzIHsKICAgICAgICBkZXBsb3ltZW50IHggbGl2ZSAiQW1hem9uV2ViU2VydmljZXNEZXBsb3ltZW50IiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhdXRvbGF5b3V0IGxyCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJBcHBsaWNhdGlvbiIgewogICAgICAgICAgICAgICAgc2hhcGUgcm91bmRlZGJveAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkRhdGFiYXNlIiB7CiAgICAgICAgICAgICAgICBzaGFwZSBjeWxpbmRlcgogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICB0aGVtZXMgaHR0cHM6Ly9zdGF0aWMuc3RydWN0dXJpenIuY29tL3RoZW1lcy9hbWF6b24td2ViLXNlcnZpY2VzLTIwMjAuMDQuMzAvdGhlbWUuanNvbgogICAgfQoKfQ==", + "structurizr.inspection.info" : "0", + "structurizr.inspection.ignore" : "0", + "structurizr.inspection.warning" : "0" + }, + "views" : { + "configuration" : { + "branding" : { }, + "styles" : { + "elements" : [ { + "shape" : "RoundedBox", + "tag" : "Application" + }, { + "shape" : "Cylinder", + "tag" : "Database" + } ] + }, + "terminology" : { }, + "themes" : [ "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/theme.json" ] + }, + "deploymentViews" : [ { + "automaticLayout" : { + "applied" : false, + "edgeSeparation" : 0, + "implementation" : "Graphviz", + "nodeSeparation" : 300, + "rankDirection" : "LeftRight", + "rankSeparation" : 300, + "vertices" : false + }, + "elements" : [ { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "6", + "x" : 0, + "y" : 0 + }, { + "id" : "7", + "x" : 0, + "y" : 0 + }, { + "id" : "8", + "x" : 0, + "y" : 0 + }, { + "id" : "10", + "x" : 0, + "y" : 0 + }, { + "id" : "11", + "x" : 0, + "y" : 0 + }, { + "id" : "12", + "x" : 0, + "y" : 0 + }, { + "id" : "14", + "x" : 0, + "y" : 0 + }, { + "id" : "15", + "x" : 0, + "y" : 0 + }, { + "id" : "16", + "x" : 0, + "y" : 0 + } ], + "environment" : "Live", + "key" : "AmazonWebServicesDeployment", + "order" : 1, + "relationships" : [ { + "id" : "13" + }, { + "id" : "17" + }, { + "id" : "9" + } ], + "softwareSystemId" : "1" + } ] + } +} \ No newline at end of file diff --git a/structurizr-export/src/test/resources/big-bank-plc.json b/structurizr-export/src/test/resources/big-bank-plc.json new file mode 100644 index 000000000..d0162591f --- /dev/null +++ b/structurizr-export/src/test/resources/big-bank-plc.json @@ -0,0 +1,1590 @@ +{ + "id" : 0, + "name" : "Big Bank plc", + "description" : "This is an example workspace to illustrate the key features of Structurizr, via the DSL, based around a fictional online banking system.", + "properties" : { + "structurizr.dsl" : "LyoKICogVGhpcyBpcyBhIGNvbWJpbmVkIHZlcnNpb24gb2YgdGhlIGZvbGxvd2luZyB3b3Jrc3BhY2VzLCB3aXRoIGF1dG9tYXRpYyBsYXlvdXQgZW5hYmxlZDoKICoKICogLSAiQmlnIEJhbmsgcGxjIC0gU3lzdGVtIExhbmRzY2FwZSIgKGh0dHBzOi8vc3RydWN0dXJpenIuY29tL3NoYXJlLzI4MjAxLykKICogLSAiQmlnIEJhbmsgcGxjIC0gSW50ZXJuZXQgQmFua2luZyBTeXN0ZW0iIChodHRwczovL3N0cnVjdHVyaXpyLmNvbS9zaGFyZS8zNjE0MS8pCiovCndvcmtzcGFjZSAiQmlnIEJhbmsgcGxjIiAiVGhpcyBpcyBhbiBleGFtcGxlIHdvcmtzcGFjZSB0byBpbGx1c3RyYXRlIHRoZSBrZXkgZmVhdHVyZXMgb2YgU3RydWN0dXJpenIsIHZpYSB0aGUgRFNMLCBiYXNlZCBhcm91bmQgYSBmaWN0aW9uYWwgb25saW5lIGJhbmtpbmcgc3lzdGVtLiIgewoKICAgIG1vZGVsIHsKICAgICAgICBjdXN0b21lciA9IHBlcnNvbiAiUGVyc29uYWwgQmFua2luZyBDdXN0b21lciIgIkEgY3VzdG9tZXIgb2YgdGhlIGJhbmssIHdpdGggcGVyc29uYWwgYmFuayBhY2NvdW50cy4iICJDdXN0b21lciIKCiAgICAgICAgZ3JvdXAgIkJpZyBCYW5rIHBsYyIgewogICAgICAgICAgICBzdXBwb3J0U3RhZmYgPSBwZXJzb24gIkN1c3RvbWVyIFNlcnZpY2UgU3RhZmYiICJDdXN0b21lciBzZXJ2aWNlIHN0YWZmIHdpdGhpbiB0aGUgYmFuay4iICJCYW5rIFN0YWZmIgogICAgICAgICAgICBiYWNrb2ZmaWNlID0gcGVyc29uICJCYWNrIE9mZmljZSBTdGFmZiIgIkFkbWluaXN0cmF0aW9uIGFuZCBzdXBwb3J0IHN0YWZmIHdpdGhpbiB0aGUgYmFuay4iICJCYW5rIFN0YWZmIgoKICAgICAgICAgICAgbWFpbmZyYW1lID0gc29mdHdhcmVzeXN0ZW0gIk1haW5mcmFtZSBCYW5raW5nIFN5c3RlbSIgIlN0b3JlcyBhbGwgb2YgdGhlIGNvcmUgYmFua2luZyBpbmZvcm1hdGlvbiBhYm91dCBjdXN0b21lcnMsIGFjY291bnRzLCB0cmFuc2FjdGlvbnMsIGV0Yy4iICJFeGlzdGluZyBTeXN0ZW0iCiAgICAgICAgICAgIGVtYWlsID0gc29mdHdhcmVzeXN0ZW0gIkUtbWFpbCBTeXN0ZW0iICJUaGUgaW50ZXJuYWwgTWljcm9zb2Z0IEV4Y2hhbmdlIGUtbWFpbCBzeXN0ZW0uIiAiRXhpc3RpbmcgU3lzdGVtIgogICAgICAgICAgICBhdG0gPSBzb2Z0d2FyZXN5c3RlbSAiQVRNIiAiQWxsb3dzIGN1c3RvbWVycyB0byB3aXRoZHJhdyBjYXNoLiIgIkV4aXN0aW5nIFN5c3RlbSIKCiAgICAgICAgICAgIGludGVybmV0QmFua2luZ1N5c3RlbSA9IHNvZnR3YXJlc3lzdGVtICJJbnRlcm5ldCBCYW5raW5nIFN5c3RlbSIgIkFsbG93cyBjdXN0b21lcnMgdG8gdmlldyBpbmZvcm1hdGlvbiBhYm91dCB0aGVpciBiYW5rIGFjY291bnRzLCBhbmQgbWFrZSBwYXltZW50cy4iIHsKICAgICAgICAgICAgICAgIHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiA9IGNvbnRhaW5lciAiU2luZ2xlLVBhZ2UgQXBwbGljYXRpb24iICJQcm92aWRlcyBhbGwgb2YgdGhlIEludGVybmV0IGJhbmtpbmcgZnVuY3Rpb25hbGl0eSB0byBjdXN0b21lcnMgdmlhIHRoZWlyIHdlYiBicm93c2VyLiIgIkphdmFTY3JpcHQgYW5kIEFuZ3VsYXIiICJXZWIgQnJvd3NlciIKICAgICAgICAgICAgICAgIG1vYmlsZUFwcCA9IGNvbnRhaW5lciAiTW9iaWxlIEFwcCIgIlByb3ZpZGVzIGEgbGltaXRlZCBzdWJzZXQgb2YgdGhlIEludGVybmV0IGJhbmtpbmcgZnVuY3Rpb25hbGl0eSB0byBjdXN0b21lcnMgdmlhIHRoZWlyIG1vYmlsZSBkZXZpY2UuIiAiWGFtYXJpbiIgIk1vYmlsZSBBcHAiCiAgICAgICAgICAgICAgICB3ZWJBcHBsaWNhdGlvbiA9IGNvbnRhaW5lciAiV2ViIEFwcGxpY2F0aW9uIiAiRGVsaXZlcnMgdGhlIHN0YXRpYyBjb250ZW50IGFuZCB0aGUgSW50ZXJuZXQgYmFua2luZyBzaW5nbGUgcGFnZSBhcHBsaWNhdGlvbi4iICJKYXZhIGFuZCBTcHJpbmcgTVZDIgogICAgICAgICAgICAgICAgYXBpQXBwbGljYXRpb24gPSBjb250YWluZXIgIkFQSSBBcHBsaWNhdGlvbiIgIlByb3ZpZGVzIEludGVybmV0IGJhbmtpbmcgZnVuY3Rpb25hbGl0eSB2aWEgYSBKU09OL0hUVFBTIEFQSS4iICJKYXZhIGFuZCBTcHJpbmcgTVZDIiB7CiAgICAgICAgICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciA9IGNvbXBvbmVudCAiU2lnbiBJbiBDb250cm9sbGVyIiAiQWxsb3dzIHVzZXJzIHRvIHNpZ24gaW4gdG8gdGhlIEludGVybmV0IEJhbmtpbmcgU3lzdGVtLiIgIlNwcmluZyBNVkMgUmVzdCBDb250cm9sbGVyIgogICAgICAgICAgICAgICAgICAgIGFjY291bnRzU3VtbWFyeUNvbnRyb2xsZXIgPSBjb21wb25lbnQgIkFjY291bnRzIFN1bW1hcnkgQ29udHJvbGxlciIgIlByb3ZpZGVzIGN1c3RvbWVycyB3aXRoIGEgc3VtbWFyeSBvZiB0aGVpciBiYW5rIGFjY291bnRzLiIgIlNwcmluZyBNVkMgUmVzdCBDb250cm9sbGVyIgogICAgICAgICAgICAgICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyID0gY29tcG9uZW50ICJSZXNldCBQYXNzd29yZCBDb250cm9sbGVyIiAiQWxsb3dzIHVzZXJzIHRvIHJlc2V0IHRoZWlyIHBhc3N3b3JkcyB3aXRoIGEgc2luZ2xlIHVzZSBVUkwuIiAiU3ByaW5nIE1WQyBSZXN0IENvbnRyb2xsZXIiCiAgICAgICAgICAgICAgICAgICAgc2VjdXJpdHlDb21wb25lbnQgPSBjb21wb25lbnQgIlNlY3VyaXR5IENvbXBvbmVudCIgIlByb3ZpZGVzIGZ1bmN0aW9uYWxpdHkgcmVsYXRlZCB0byBzaWduaW5nIGluLCBjaGFuZ2luZyBwYXNzd29yZHMsIGV0Yy4iICJTcHJpbmcgQmVhbiIKICAgICAgICAgICAgICAgICAgICBtYWluZnJhbWVCYW5raW5nU3lzdGVtRmFjYWRlID0gY29tcG9uZW50ICJNYWluZnJhbWUgQmFua2luZyBTeXN0ZW0gRmFjYWRlIiAiQSBmYWNhZGUgb250byB0aGUgbWFpbmZyYW1lIGJhbmtpbmcgc3lzdGVtLiIgIlNwcmluZyBCZWFuIgogICAgICAgICAgICAgICAgICAgIGVtYWlsQ29tcG9uZW50ID0gY29tcG9uZW50ICJFLW1haWwgQ29tcG9uZW50IiAiU2VuZHMgZS1tYWlscyB0byB1c2Vycy4iICJTcHJpbmcgQmVhbiIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIGRhdGFiYXNlID0gY29udGFpbmVyICJEYXRhYmFzZSIgIlN0b3JlcyB1c2VyIHJlZ2lzdHJhdGlvbiBpbmZvcm1hdGlvbiwgaGFzaGVkIGF1dGhlbnRpY2F0aW9uIGNyZWRlbnRpYWxzLCBhY2Nlc3MgbG9ncywgZXRjLiIgIk9yYWNsZSBEYXRhYmFzZSBTY2hlbWEiICJEYXRhYmFzZSIKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgIyByZWxhdGlvbnNoaXBzIGJldHdlZW4gcGVvcGxlIGFuZCBzb2Z0d2FyZSBzeXN0ZW1zCiAgICAgICAgY3VzdG9tZXIgLT4gaW50ZXJuZXRCYW5raW5nU3lzdGVtICJWaWV3cyBhY2NvdW50IGJhbGFuY2VzLCBhbmQgbWFrZXMgcGF5bWVudHMgdXNpbmciCiAgICAgICAgaW50ZXJuZXRCYW5raW5nU3lzdGVtIC0+IG1haW5mcmFtZSAiR2V0cyBhY2NvdW50IGluZm9ybWF0aW9uIGZyb20sIGFuZCBtYWtlcyBwYXltZW50cyB1c2luZyIKICAgICAgICBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gLT4gZW1haWwgIlNlbmRzIGUtbWFpbCB1c2luZyIKICAgICAgICBlbWFpbCAtPiBjdXN0b21lciAiU2VuZHMgZS1tYWlscyB0byIKICAgICAgICBjdXN0b21lciAtPiBzdXBwb3J0U3RhZmYgIkFza3MgcXVlc3Rpb25zIHRvIiAiVGVsZXBob25lIgogICAgICAgIHN1cHBvcnRTdGFmZiAtPiBtYWluZnJhbWUgIlVzZXMiCiAgICAgICAgY3VzdG9tZXIgLT4gYXRtICJXaXRoZHJhd3MgY2FzaCB1c2luZyIKICAgICAgICBhdG0gLT4gbWFpbmZyYW1lICJVc2VzIgogICAgICAgIGJhY2tvZmZpY2UgLT4gbWFpbmZyYW1lICJVc2VzIgoKICAgICAgICAjIHJlbGF0aW9uc2hpcHMgdG8vZnJvbSBjb250YWluZXJzCiAgICAgICAgY3VzdG9tZXIgLT4gd2ViQXBwbGljYXRpb24gIlZpc2l0cyBiaWdiYW5rLmNvbS9pYiB1c2luZyIgIkhUVFBTIgogICAgICAgIGN1c3RvbWVyIC0+IHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiAiVmlld3MgYWNjb3VudCBiYWxhbmNlcywgYW5kIG1ha2VzIHBheW1lbnRzIHVzaW5nIgogICAgICAgIGN1c3RvbWVyIC0+IG1vYmlsZUFwcCAiVmlld3MgYWNjb3VudCBiYWxhbmNlcywgYW5kIG1ha2VzIHBheW1lbnRzIHVzaW5nIgogICAgICAgIHdlYkFwcGxpY2F0aW9uIC0+IHNpbmdsZVBhZ2VBcHBsaWNhdGlvbiAiRGVsaXZlcnMgdG8gdGhlIGN1c3RvbWVyJ3Mgd2ViIGJyb3dzZXIiCgogICAgICAgICMgcmVsYXRpb25zaGlwcyB0by9mcm9tIGNvbXBvbmVudHMKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gc2lnbmluQ29udHJvbGxlciAiTWFrZXMgQVBJIGNhbGxzIHRvIiAiSlNPTi9IVFRQUyIKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gYWNjb3VudHNTdW1tYXJ5Q29udHJvbGxlciAiTWFrZXMgQVBJIGNhbGxzIHRvIiAiSlNPTi9IVFRQUyIKICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gcmVzZXRQYXNzd29yZENvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IHNpZ25pbkNvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IGFjY291bnRzU3VtbWFyeUNvbnRyb2xsZXIgIk1ha2VzIEFQSSBjYWxscyB0byIgIkpTT04vSFRUUFMiCiAgICAgICAgbW9iaWxlQXBwIC0+IHJlc2V0UGFzc3dvcmRDb250cm9sbGVyICJNYWtlcyBBUEkgY2FsbHMgdG8iICJKU09OL0hUVFBTIgogICAgICAgIHNpZ25pbkNvbnRyb2xsZXIgLT4gc2VjdXJpdHlDb21wb25lbnQgIlVzZXMiCiAgICAgICAgYWNjb3VudHNTdW1tYXJ5Q29udHJvbGxlciAtPiBtYWluZnJhbWVCYW5raW5nU3lzdGVtRmFjYWRlICJVc2VzIgogICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyIC0+IHNlY3VyaXR5Q29tcG9uZW50ICJVc2VzIgogICAgICAgIHJlc2V0UGFzc3dvcmRDb250cm9sbGVyIC0+IGVtYWlsQ29tcG9uZW50ICJVc2VzIgogICAgICAgIHNlY3VyaXR5Q29tcG9uZW50IC0+IGRhdGFiYXNlICJSZWFkcyBmcm9tIGFuZCB3cml0ZXMgdG8iICJTUUwvVENQIgogICAgICAgIG1haW5mcmFtZUJhbmtpbmdTeXN0ZW1GYWNhZGUgLT4gbWFpbmZyYW1lICJNYWtlcyBBUEkgY2FsbHMgdG8iICJYTUwvSFRUUFMiCiAgICAgICAgZW1haWxDb21wb25lbnQgLT4gZW1haWwgIlNlbmRzIGUtbWFpbCB1c2luZyIKCiAgICAgICAgZGVwbG95bWVudEVudmlyb25tZW50ICJEZXZlbG9wbWVudCIgewogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRGV2ZWxvcGVyIExhcHRvcCIgIiIgIk1pY3Jvc29mdCBXaW5kb3dzIDEwIG9yIEFwcGxlIG1hY09TIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiV2ViIEJyb3dzZXIiICIiICJDaHJvbWUsIEZpcmVmb3gsIFNhZmFyaSwgb3IgRWRnZSIgewogICAgICAgICAgICAgICAgICAgIGRldmVsb3BlclNpbmdsZVBhZ2VBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2Ugc2luZ2xlUGFnZUFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRG9ja2VyIENvbnRhaW5lciAtIFdlYiBTZXJ2ZXIiICIiICJEb2NrZXIiIHsKICAgICAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQXBhY2hlIFRvbWNhdCIgIiIgIkFwYWNoZSBUb21jYXQgOC54IiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRldmVsb3BlcldlYkFwcGxpY2F0aW9uSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSB3ZWJBcHBsaWNhdGlvbgogICAgICAgICAgICAgICAgICAgICAgICBkZXZlbG9wZXJBcGlBcHBsaWNhdGlvbkluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgYXBpQXBwbGljYXRpb24KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiRG9ja2VyIENvbnRhaW5lciAtIERhdGFiYXNlIFNlcnZlciIgIiIgIkRvY2tlciIgewogICAgICAgICAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJEYXRhYmFzZSBTZXJ2ZXIiICIiICJPcmFjbGUgMTJjIiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGRldmVsb3BlckRhdGFiYXNlSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSBkYXRhYmFzZQogICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQogICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiQmlnIEJhbmsgcGxjIiAiIiAiQmlnIEJhbmsgcGxjIGRhdGEgY2VudGVyIiAiIiB7CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay1kZXYwMDEiICIiICIiICIiIHsKICAgICAgICAgICAgICAgICAgICBzb2Z0d2FyZVN5c3RlbUluc3RhbmNlIG1haW5mcmFtZQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgIH0KCiAgICAgICAgZGVwbG95bWVudEVudmlyb25tZW50ICJMaXZlIiB7CiAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJDdXN0b21lcidzIG1vYmlsZSBkZXZpY2UiICIiICJBcHBsZSBpT1Mgb3IgQW5kcm9pZCIgewogICAgICAgICAgICAgICAgbGl2ZU1vYmlsZUFwcEluc3RhbmNlID0gY29udGFpbmVySW5zdGFuY2UgbW9iaWxlQXBwCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkN1c3RvbWVyJ3MgY29tcHV0ZXIiICIiICJNaWNyb3NvZnQgV2luZG93cyBvciBBcHBsZSBtYWNPUyIgewogICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIldlYiBCcm93c2VyIiAiIiAiQ2hyb21lLCBGaXJlZm94LCBTYWZhcmksIG9yIEVkZ2UiIHsKICAgICAgICAgICAgICAgICAgICBsaXZlU2luZ2xlUGFnZUFwcGxpY2F0aW9uSW5zdGFuY2UgPSBjb250YWluZXJJbnN0YW5jZSBzaW5nbGVQYWdlQXBwbGljYXRpb24KICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkJpZyBCYW5rIHBsYyIgIiIgIkJpZyBCYW5rIHBsYyBkYXRhIGNlbnRlciIgewogICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgImJpZ2Jhbmstd2ViKioqIiAiIiAiVWJ1bnR1IDE2LjA0IExUUyIgIiIgNCB7CiAgICAgICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkFwYWNoZSBUb21jYXQiICIiICJBcGFjaGUgVG9tY2F0IDgueCIgewogICAgICAgICAgICAgICAgICAgICAgICBsaXZlV2ViQXBwbGljYXRpb25JbnN0YW5jZSA9IGNvbnRhaW5lckluc3RhbmNlIHdlYkFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgImJpZ2JhbmstYXBpKioqIiAiIiAiVWJ1bnR1IDE2LjA0IExUUyIgIiIgOCB7CiAgICAgICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgIkFwYWNoZSBUb21jYXQiICIiICJBcGFjaGUgVG9tY2F0IDgueCIgewogICAgICAgICAgICAgICAgICAgICAgICBsaXZlQXBpQXBwbGljYXRpb25JbnN0YW5jZSA9IGNvbnRhaW5lckluc3RhbmNlIGFwaUFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGRlcGxveW1lbnROb2RlICJiaWdiYW5rLWRiMDEiICIiICJVYnVudHUgMTYuMDQgTFRTIiB7CiAgICAgICAgICAgICAgICAgICAgcHJpbWFyeURhdGFiYXNlU2VydmVyID0gZGVwbG95bWVudE5vZGUgIk9yYWNsZSAtIFByaW1hcnkiICIiICJPcmFjbGUgMTJjIiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGxpdmVQcmltYXJ5RGF0YWJhc2VJbnN0YW5jZSA9IGNvbnRhaW5lckluc3RhbmNlIGRhdGFiYXNlCiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgZGVwbG95bWVudE5vZGUgImJpZ2JhbmstZGIwMiIgIiIgIlVidW50dSAxNi4wNCBMVFMiICJGYWlsb3ZlciIgewogICAgICAgICAgICAgICAgICAgIHNlY29uZGFyeURhdGFiYXNlU2VydmVyID0gZGVwbG95bWVudE5vZGUgIk9yYWNsZSAtIFNlY29uZGFyeSIgIiIgIk9yYWNsZSAxMmMiICJGYWlsb3ZlciIgewogICAgICAgICAgICAgICAgICAgICAgICBsaXZlU2Vjb25kYXJ5RGF0YWJhc2VJbnN0YW5jZSA9IGNvbnRhaW5lckluc3RhbmNlIGRhdGFiYXNlICJGYWlsb3ZlciIKICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBkZXBsb3ltZW50Tm9kZSAiYmlnYmFuay1wcm9kMDAxIiAiIiAiIiAiIiB7CiAgICAgICAgICAgICAgICAgICAgc29mdHdhcmVTeXN0ZW1JbnN0YW5jZSBtYWluZnJhbWUKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgcHJpbWFyeURhdGFiYXNlU2VydmVyIC0+IHNlY29uZGFyeURhdGFiYXNlU2VydmVyICJSZXBsaWNhdGVzIGRhdGEgdG8iCiAgICAgICAgfQogICAgfQoKICAgIHZpZXdzIHsKICAgICAgICBzeXN0ZW1sYW5kc2NhcGUgIlN5c3RlbUxhbmRzY2FwZSIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICAgICAgYXV0b0xheW91dAogICAgICAgIH0KCiAgICAgICAgc3lzdGVtY29udGV4dCBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gIlN5c3RlbUNvbnRleHQiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgICAgIGFuaW1hdGlvbiB7CiAgICAgICAgICAgICAgICBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0KICAgICAgICAgICAgICAgIGN1c3RvbWVyCiAgICAgICAgICAgICAgICBtYWluZnJhbWUKICAgICAgICAgICAgICAgIGVtYWlsCiAgICAgICAgICAgIH0KICAgICAgICAgICAgYXV0b0xheW91dAogICAgICAgICAgICBkZXNjcmlwdGlvbiAiVGhlIHN5c3RlbSBjb250ZXh0IGRpYWdyYW0gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgICAgIHByb3BlcnRpZXMgewogICAgICAgICAgICAgICAgc3RydWN0dXJpenIuZ3JvdXBzIGZhbHNlCiAgICAgICAgICAgIH0KICAgICAgICB9CgogICAgICAgIGNvbnRhaW5lciBpbnRlcm5ldEJhbmtpbmdTeXN0ZW0gIkNvbnRhaW5lcnMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgICAgIGFuaW1hdGlvbiB7CiAgICAgICAgICAgICAgICBjdXN0b21lciBtYWluZnJhbWUgZW1haWwKICAgICAgICAgICAgICAgIHdlYkFwcGxpY2F0aW9uCiAgICAgICAgICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24KICAgICAgICAgICAgICAgIG1vYmlsZUFwcAogICAgICAgICAgICAgICAgYXBpQXBwbGljYXRpb24KICAgICAgICAgICAgICAgIGRhdGFiYXNlCiAgICAgICAgICAgIH0KICAgICAgICAgICAgYXV0b0xheW91dAogICAgICAgICAgICBkZXNjcmlwdGlvbiAiVGhlIGNvbnRhaW5lciBkaWFncmFtIGZvciB0aGUgSW50ZXJuZXQgQmFua2luZyBTeXN0ZW0uIgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGFwaUFwcGxpY2F0aW9uICJDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhbmltYXRpb24gewogICAgICAgICAgICAgICAgc2luZ2xlUGFnZUFwcGxpY2F0aW9uIG1vYmlsZUFwcCBkYXRhYmFzZSBlbWFpbCBtYWluZnJhbWUKICAgICAgICAgICAgICAgIHNpZ25pbkNvbnRyb2xsZXIgc2VjdXJpdHlDb21wb25lbnQKICAgICAgICAgICAgICAgIGFjY291bnRzU3VtbWFyeUNvbnRyb2xsZXIgbWFpbmZyYW1lQmFua2luZ1N5c3RlbUZhY2FkZQogICAgICAgICAgICAgICAgcmVzZXRQYXNzd29yZENvbnRyb2xsZXIgZW1haWxDb21wb25lbnQKICAgICAgICAgICAgfQogICAgICAgICAgICBhdXRvTGF5b3V0CiAgICAgICAgICAgIGRlc2NyaXB0aW9uICJUaGUgY29tcG9uZW50IGRpYWdyYW0gZm9yIHRoZSBBUEkgQXBwbGljYXRpb24uIgogICAgICAgIH0KCiAgICAgICAgaW1hZ2UgbWFpbmZyYW1lQmFua2luZ1N5c3RlbUZhY2FkZSAiTWFpbmZyYW1lQmFua2luZ1N5c3RlbUZhY2FkZSIgewogICAgICAgICAgICBpbWFnZSBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vc3RydWN0dXJpenIvZXhhbXBsZXMvbWFpbi9kc2wvYmlnLWJhbmstcGxjL2ludGVybmV0LWJhbmtpbmctc3lzdGVtL21haW5mcmFtZS1iYW5raW5nLXN5c3RlbS1mYWNhZGUucG5nCiAgICAgICAgICAgIHRpdGxlICJbQ29kZV0gTWFpbmZyYW1lIEJhbmtpbmcgU3lzdGVtIEZhY2FkZSIKICAgICAgICB9CgogICAgICAgIGR5bmFtaWMgYXBpQXBwbGljYXRpb24gIlNpZ25JbiIgIlN1bW1hcmlzZXMgaG93IHRoZSBzaWduIGluIGZlYXR1cmUgd29ya3MgaW4gdGhlIHNpbmdsZS1wYWdlIGFwcGxpY2F0aW9uLiIgewogICAgICAgICAgICBzaW5nbGVQYWdlQXBwbGljYXRpb24gLT4gc2lnbmluQ29udHJvbGxlciAiU3VibWl0cyBjcmVkZW50aWFscyB0byIKICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciAtPiBzZWN1cml0eUNvbXBvbmVudCAiVmFsaWRhdGVzIGNyZWRlbnRpYWxzIHVzaW5nIgogICAgICAgICAgICBzZWN1cml0eUNvbXBvbmVudCAtPiBkYXRhYmFzZSAic2VsZWN0ICogZnJvbSB1c2VycyB3aGVyZSB1c2VybmFtZSA9ID8iCiAgICAgICAgICAgIGRhdGFiYXNlIC0+IHNlY3VyaXR5Q29tcG9uZW50ICJSZXR1cm5zIHVzZXIgZGF0YSB0byIKICAgICAgICAgICAgc2VjdXJpdHlDb21wb25lbnQgLT4gc2lnbmluQ29udHJvbGxlciAiUmV0dXJucyB0cnVlIGlmIHRoZSBoYXNoZWQgcGFzc3dvcmQgbWF0Y2hlcyIKICAgICAgICAgICAgc2lnbmluQ29udHJvbGxlciAtPiBzaW5nbGVQYWdlQXBwbGljYXRpb24gIlNlbmRzIGJhY2sgYW4gYXV0aGVudGljYXRpb24gdG9rZW4gdG8iCiAgICAgICAgICAgIGF1dG9MYXlvdXQKICAgICAgICAgICAgZGVzY3JpcHRpb24gIlN1bW1hcmlzZXMgaG93IHRoZSBzaWduIGluIGZlYXR1cmUgd29ya3MgaW4gdGhlIHNpbmdsZS1wYWdlIGFwcGxpY2F0aW9uLiIKICAgICAgICB9CgogICAgICAgIGRlcGxveW1lbnQgaW50ZXJuZXRCYW5raW5nU3lzdGVtICJEZXZlbG9wbWVudCIgIkRldmVsb3BtZW50RGVwbG95bWVudCIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICAgICAgYW5pbWF0aW9uIHsKICAgICAgICAgICAgICAgIGRldmVsb3BlclNpbmdsZVBhZ2VBcHBsaWNhdGlvbkluc3RhbmNlCiAgICAgICAgICAgICAgICBkZXZlbG9wZXJXZWJBcHBsaWNhdGlvbkluc3RhbmNlIGRldmVsb3BlckFwaUFwcGxpY2F0aW9uSW5zdGFuY2UKICAgICAgICAgICAgICAgIGRldmVsb3BlckRhdGFiYXNlSW5zdGFuY2UKICAgICAgICAgICAgfQogICAgICAgICAgICBhdXRvTGF5b3V0CiAgICAgICAgICAgIGRlc2NyaXB0aW9uICJBbiBleGFtcGxlIGRldmVsb3BtZW50IGRlcGxveW1lbnQgc2NlbmFyaW8gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgfQoKICAgICAgICBkZXBsb3ltZW50IGludGVybmV0QmFua2luZ1N5c3RlbSAiTGl2ZSIgIkxpdmVEZXBsb3ltZW50IiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhbmltYXRpb24gewogICAgICAgICAgICAgICAgbGl2ZVNpbmdsZVBhZ2VBcHBsaWNhdGlvbkluc3RhbmNlCiAgICAgICAgICAgICAgICBsaXZlTW9iaWxlQXBwSW5zdGFuY2UKICAgICAgICAgICAgICAgIGxpdmVXZWJBcHBsaWNhdGlvbkluc3RhbmNlIGxpdmVBcGlBcHBsaWNhdGlvbkluc3RhbmNlCiAgICAgICAgICAgICAgICBsaXZlUHJpbWFyeURhdGFiYXNlSW5zdGFuY2UKICAgICAgICAgICAgICAgIGxpdmVTZWNvbmRhcnlEYXRhYmFzZUluc3RhbmNlCiAgICAgICAgICAgIH0KICAgICAgICAgICAgYXV0b0xheW91dAogICAgICAgICAgICBkZXNjcmlwdGlvbiAiQW4gZXhhbXBsZSBsaXZlIGRlcGxveW1lbnQgc2NlbmFyaW8gZm9yIHRoZSBJbnRlcm5ldCBCYW5raW5nIFN5c3RlbS4iCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJQZXJzb24iIHsKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgICAgIGZvbnRTaXplIDIyCiAgICAgICAgICAgICAgICBzaGFwZSBQZXJzb24KICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDdXN0b21lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjMDg0MjdiCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiQmFuayBTdGFmZiIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjOTk5OTk5CiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiU29mdHdhcmUgU3lzdGVtIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICMxMTY4YmQKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJFeGlzdGluZyBTeXN0ZW0iIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzk5OTk5OQogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbnRhaW5lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjNDM4ZGQ1CiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiV2ViIEJyb3dzZXIiIHsKICAgICAgICAgICAgICAgIHNoYXBlIFdlYkJyb3dzZXIKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJNb2JpbGUgQXBwIiB7CiAgICAgICAgICAgICAgICBzaGFwZSBNb2JpbGVEZXZpY2VMYW5kc2NhcGUKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJEYXRhYmFzZSIgewogICAgICAgICAgICAgICAgc2hhcGUgQ3lsaW5kZXIKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDb21wb25lbnQiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzg1YmJmMAogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkZhaWxvdmVyIiB7CiAgICAgICAgICAgICAgICBvcGFjaXR5IDI1CiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICB9Cn0K" + }, + "configuration" : { }, + "model" : { + "people" : [ { + "id" : "1", + "tags" : "Element,Person,Customer", + "properties" : { + "structurizr.dsl.identifier" : "customer" + }, + "name" : "Personal Banking Customer", + "description" : "A customer of the bank, with personal bank accounts.", + "relationships" : [ { + "id" : "19", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "6d299902-3f78-4a39-a8ca-3a61e3e50f3e" + }, + "sourceId" : "1", + "destinationId" : "7", + "description" : "Views account balances, and makes payments using" + }, { + "id" : "23", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "e61b034b-4617-4f42-8831-b171bdd5f7df" + }, + "sourceId" : "1", + "destinationId" : "2", + "description" : "Asks questions to", + "technology" : "Telephone" + }, { + "id" : "25", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "2ebe5df0-e547-4c9b-9403-a83561d958ea" + }, + "sourceId" : "1", + "destinationId" : "6", + "description" : "Withdraws cash using" + }, { + "id" : "28", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "4a08e84b-acb3-4fc4-a7f1-8ebe621a0f0f" + }, + "sourceId" : "1", + "destinationId" : "10", + "description" : "Visits bigbank.com/ib using", + "technology" : "HTTPS" + }, { + "id" : "29", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "025a49a0-4557-44bb-9724-2d7ea4d7e1ed" + }, + "sourceId" : "1", + "destinationId" : "8", + "description" : "Views account balances, and makes payments using" + }, { + "id" : "30", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "c6295d75-dd8c-494e-8d69-096540a25b4b" + }, + "sourceId" : "1", + "destinationId" : "9", + "description" : "Views account balances, and makes payments using" + } ], + "location" : "Unspecified" + }, { + "id" : "2", + "tags" : "Element,Person,Bank Staff", + "properties" : { + "structurizr.dsl.identifier" : "supportstaff" + }, + "name" : "Customer Service Staff", + "description" : "Customer service staff within the bank.", + "relationships" : [ { + "id" : "24", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "ad399353-807e-4350-ad5f-2359c54844db" + }, + "sourceId" : "2", + "destinationId" : "4", + "description" : "Uses" + } ], + "group" : "Big Bank plc", + "location" : "Unspecified" + }, { + "id" : "3", + "tags" : "Element,Person,Bank Staff", + "properties" : { + "structurizr.dsl.identifier" : "backoffice" + }, + "name" : "Back Office Staff", + "description" : "Administration and support staff within the bank.", + "relationships" : [ { + "id" : "27", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "a4243927-b19d-423f-9f2c-6d84ba3604e3" + }, + "sourceId" : "3", + "destinationId" : "4", + "description" : "Uses" + } ], + "group" : "Big Bank plc", + "location" : "Unspecified" + } ], + "softwareSystems" : [ { + "id" : "4", + "tags" : "Element,Software System,Existing System", + "properties" : { + "structurizr.dsl.identifier" : "mainframe" + }, + "name" : "Mainframe Banking System", + "description" : "Stores all of the core banking information about customers, accounts, transactions, etc.", + "group" : "Big Bank plc", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "5", + "tags" : "Element,Software System,Existing System", + "properties" : { + "structurizr.dsl.identifier" : "email" + }, + "name" : "E-mail System", + "description" : "The internal Microsoft Exchange e-mail system.", + "relationships" : [ { + "id" : "22", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "93b2b770-fec1-40f4-ab14-cec2eb3c75d6" + }, + "sourceId" : "5", + "destinationId" : "1", + "description" : "Sends e-mails to" + } ], + "group" : "Big Bank plc", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "6", + "tags" : "Element,Software System,Existing System", + "properties" : { + "structurizr.dsl.identifier" : "atm" + }, + "name" : "ATM", + "description" : "Allows customers to withdraw cash.", + "relationships" : [ { + "id" : "26", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "e2e730e2-ce51-4154-9847-b8d73823cdc2" + }, + "sourceId" : "6", + "destinationId" : "4", + "description" : "Uses" + } ], + "group" : "Big Bank plc", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "7", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "internetbankingsystem" + }, + "name" : "Internet Banking System", + "description" : "Allows customers to view information about their bank accounts, and make payments.", + "relationships" : [ { + "id" : "20", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "bd46add8-7f56-4639-9f1c-1569a681ad92" + }, + "sourceId" : "7", + "destinationId" : "4", + "description" : "Gets account information from, and makes payments using" + }, { + "id" : "21", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "c58d1eb9-d9da-4f37-b9f5-e2035c651abd" + }, + "sourceId" : "7", + "destinationId" : "5", + "description" : "Sends e-mail using" + } ], + "group" : "Big Bank plc", + "location" : "Unspecified", + "containers" : [ { + "id" : "18", + "tags" : "Element,Container,Database", + "properties" : { + "structurizr.dsl.identifier" : "database" + }, + "name" : "Database", + "description" : "Stores user registration information, hashed authentication credentials, access logs, etc.", + "technology" : "Oracle Database Schema", + "documentation" : { } + }, { + "id" : "9", + "tags" : "Element,Container,Mobile App", + "properties" : { + "structurizr.dsl.identifier" : "mobileapp" + }, + "name" : "Mobile App", + "description" : "Provides a limited subset of the Internet banking functionality to customers via their mobile device.", + "relationships" : [ { + "id" : "36", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "5fce96a5-2f6c-41e1-9263-26174a28ced1" + }, + "sourceId" : "9", + "destinationId" : "12", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + }, { + "id" : "37", + "sourceId" : "9", + "destinationId" : "11", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS", + "linkedRelationshipId" : "36" + }, { + "id" : "38", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "3ee42ae6-31e2-4644-8d27-0d9f8fde6919" + }, + "sourceId" : "9", + "destinationId" : "13", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + }, { + "id" : "39", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "65f4415c-7575-4230-bd32-6d8d754d01ca" + }, + "sourceId" : "9", + "destinationId" : "14", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + } ], + "technology" : "Xamarin", + "documentation" : { } + }, { + "id" : "10", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "webapplication" + }, + "name" : "Web Application", + "description" : "Delivers the static content and the Internet banking single page application.", + "relationships" : [ { + "id" : "31", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "792ce67f-78f1-4c65-933b-84eece4334e2" + }, + "sourceId" : "10", + "destinationId" : "8", + "description" : "Delivers to the customer's web browser" + } ], + "technology" : "Java and Spring MVC", + "documentation" : { } + }, { + "id" : "11", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "apiapplication" + }, + "name" : "API Application", + "description" : "Provides Internet banking functionality via a JSON/HTTPS API.", + "relationships" : [ { + "id" : "45", + "sourceId" : "11", + "destinationId" : "18", + "description" : "Reads from and writes to", + "technology" : "SQL/TCP", + "linkedRelationshipId" : "44" + }, { + "id" : "47", + "sourceId" : "11", + "destinationId" : "4", + "description" : "Makes API calls to", + "technology" : "XML/HTTPS", + "linkedRelationshipId" : "46" + }, { + "id" : "49", + "sourceId" : "11", + "destinationId" : "5", + "description" : "Sends e-mail using", + "linkedRelationshipId" : "48" + } ], + "technology" : "Java and Spring MVC", + "components" : [ { + "id" : "16", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "mainframebankingsystemfacade" + }, + "name" : "Mainframe Banking System Facade", + "description" : "A facade onto the mainframe banking system.", + "relationships" : [ { + "id" : "46", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "2f9ddb3a-77a2-433c-9257-49bfe5bb0766" + }, + "sourceId" : "16", + "destinationId" : "4", + "description" : "Makes API calls to", + "technology" : "XML/HTTPS" + } ], + "technology" : "Spring Bean", + "documentation" : { } + }, { + "id" : "12", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "signincontroller" + }, + "name" : "Sign In Controller", + "description" : "Allows users to sign in to the Internet Banking System.", + "relationships" : [ { + "id" : "40", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "2f405114-17b0-4223-aaa1-12f87d1ca8ba" + }, + "sourceId" : "12", + "destinationId" : "15", + "description" : "Uses" + } ], + "technology" : "Spring MVC Rest Controller", + "documentation" : { } + }, { + "id" : "13", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "accountssummarycontroller" + }, + "name" : "Accounts Summary Controller", + "description" : "Provides customers with a summary of their bank accounts.", + "relationships" : [ { + "id" : "41", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "9920b188-70d1-4f83-b51d-a2f7eb4c6ffc" + }, + "sourceId" : "13", + "destinationId" : "16", + "description" : "Uses" + } ], + "technology" : "Spring MVC Rest Controller", + "documentation" : { } + }, { + "id" : "15", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "securitycomponent" + }, + "name" : "Security Component", + "description" : "Provides functionality related to signing in, changing passwords, etc.", + "relationships" : [ { + "id" : "44", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "67d3a4e5-1e3f-4046-a121-c0a342a26d25" + }, + "sourceId" : "15", + "destinationId" : "18", + "description" : "Reads from and writes to", + "technology" : "SQL/TCP" + } ], + "technology" : "Spring Bean", + "documentation" : { } + }, { + "id" : "14", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "resetpasswordcontroller" + }, + "name" : "Reset Password Controller", + "description" : "Allows users to reset their passwords with a single use URL.", + "relationships" : [ { + "id" : "42", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "bd04aed2-60d9-45f9-a75a-bd570b5a9cc4" + }, + "sourceId" : "14", + "destinationId" : "15", + "description" : "Uses" + }, { + "id" : "43", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "aa43b36b-ddc2-4aad-a021-d54f79abd36b" + }, + "sourceId" : "14", + "destinationId" : "17", + "description" : "Uses" + } ], + "technology" : "Spring MVC Rest Controller", + "documentation" : { } + }, { + "id" : "17", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "emailcomponent" + }, + "name" : "E-mail Component", + "description" : "Sends e-mails to users.", + "relationships" : [ { + "id" : "48", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "3c022388-9eb7-4e47-b524-f480fc361897" + }, + "sourceId" : "17", + "destinationId" : "5", + "description" : "Sends e-mail using" + } ], + "technology" : "Spring Bean", + "documentation" : { } + } ], + "documentation" : { } + }, { + "id" : "8", + "tags" : "Element,Container,Web Browser", + "properties" : { + "structurizr.dsl.identifier" : "singlepageapplication" + }, + "name" : "Single-Page Application", + "description" : "Provides all of the Internet banking functionality to customers via their web browser.", + "relationships" : [ { + "id" : "32", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "adb4007f-e5e8-41b9-ac61-d027fac89cdb" + }, + "sourceId" : "8", + "destinationId" : "12", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + }, { + "id" : "33", + "sourceId" : "8", + "destinationId" : "11", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS", + "linkedRelationshipId" : "32" + }, { + "id" : "34", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "235c5bc1-8163-4415-abb2-9f24d7a03155" + }, + "sourceId" : "8", + "destinationId" : "13", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + }, { + "id" : "35", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "607c5ef3-186c-4f68-a4f1-3be26eb4591f" + }, + "sourceId" : "8", + "destinationId" : "14", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS" + } ], + "technology" : "JavaScript and Angular", + "documentation" : { } + } ], + "documentation" : { } + } ], + "deploymentNodes" : [ { + "id" : "50", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "25239481-454a-4edd-bca5-7ee77ced54e1" + }, + "name" : "Developer Laptop", + "environment" : "Development", + "technology" : "Microsoft Windows 10 or Apple macOS", + "instances" : "1", + "children" : [ { + "id" : "53", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "e8ce8a91-df97-4486-b2a0-dcd171cd2ee3" + }, + "name" : "Docker Container - Web Server", + "environment" : "Development", + "technology" : "Docker", + "instances" : "1", + "children" : [ { + "id" : "54", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "af91db02-1a49-4a73-848a-ff230caf459b" + }, + "name" : "Apache Tomcat", + "environment" : "Development", + "technology" : "Apache Tomcat 8.x", + "instances" : "1", + "containerInstances" : [ { + "id" : "55", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "developerwebapplicationinstance" + }, + "relationships" : [ { + "id" : "56", + "sourceId" : "55", + "destinationId" : "52", + "description" : "Delivers to the customer's web browser", + "linkedRelationshipId" : "31" + } ], + "environment" : "Development", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "10" + }, { + "id" : "57", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "developerapiapplicationinstance" + }, + "relationships" : [ { + "id" : "62", + "sourceId" : "57", + "destinationId" : "61", + "description" : "Reads from and writes to", + "technology" : "SQL/TCP", + "linkedRelationshipId" : "45" + }, { + "id" : "66", + "sourceId" : "57", + "destinationId" : "65", + "description" : "Makes API calls to", + "technology" : "XML/HTTPS", + "linkedRelationshipId" : "47" + } ], + "environment" : "Development", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "11" + } ] + } ] + }, { + "id" : "51", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "b7da5a3d-2f5f-41f4-ab17-a81118c842ad" + }, + "name" : "Web Browser", + "environment" : "Development", + "technology" : "Chrome, Firefox, Safari, or Edge", + "instances" : "1", + "containerInstances" : [ { + "id" : "52", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "developersinglepageapplicationinstance" + }, + "relationships" : [ { + "id" : "58", + "sourceId" : "52", + "destinationId" : "57", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS", + "linkedRelationshipId" : "33" + } ], + "environment" : "Development", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "8" + } ] + }, { + "id" : "59", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "5f95573e-5028-40e6-9b55-b1402dc184b4" + }, + "name" : "Docker Container - Database Server", + "environment" : "Development", + "technology" : "Docker", + "instances" : "1", + "children" : [ { + "id" : "60", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "562a146b-9830-4fef-ab67-cadbede1cabb" + }, + "name" : "Database Server", + "environment" : "Development", + "technology" : "Oracle 12c", + "instances" : "1", + "containerInstances" : [ { + "id" : "61", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "developerdatabaseinstance" + }, + "environment" : "Development", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "18" + } ] + } ] + } ] + }, { + "id" : "63", + "tags" : "Element,Deployment Node,", + "properties" : { + "structurizr.dsl.identifier" : "77f85941-3884-4918-bfb5-a55a6d2d7448" + }, + "name" : "Big Bank plc", + "environment" : "Development", + "technology" : "Big Bank plc data center", + "instances" : "1", + "children" : [ { + "id" : "64", + "tags" : "Element,Deployment Node,", + "properties" : { + "structurizr.dsl.identifier" : "9e854be8-32f3-4d69-88fd-245871775b15" + }, + "name" : "bigbank-dev001", + "environment" : "Development", + "instances" : "1", + "softwareSystemInstances" : [ { + "id" : "65", + "tags" : "Software System Instance", + "properties" : { + "structurizr.dsl.identifier" : "437ac7c3-ebbb-44ce-8a60-80bef8b6fb6e" + }, + "environment" : "Development", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "softwareSystemId" : "4" + } ] + } ] + }, { + "id" : "67", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "80be1ff8-fe6c-413b-aac6-9bb6cb9b85c7" + }, + "name" : "Customer's mobile device", + "environment" : "Live", + "technology" : "Apple iOS or Android", + "instances" : "1", + "containerInstances" : [ { + "id" : "68", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "livemobileappinstance" + }, + "relationships" : [ { + "id" : "80", + "sourceId" : "68", + "destinationId" : "79", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS", + "linkedRelationshipId" : "37" + } ], + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "9" + } ] + }, { + "id" : "69", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "493075d0-96ce-4274-98e2-5327fc48aeb6" + }, + "name" : "Customer's computer", + "environment" : "Live", + "technology" : "Microsoft Windows or Apple macOS", + "instances" : "1", + "children" : [ { + "id" : "70", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "45b6f442-c8bf-4923-8270-b9325afe0581" + }, + "name" : "Web Browser", + "environment" : "Live", + "technology" : "Chrome, Firefox, Safari, or Edge", + "instances" : "1", + "containerInstances" : [ { + "id" : "71", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "livesinglepageapplicationinstance" + }, + "relationships" : [ { + "id" : "81", + "sourceId" : "71", + "destinationId" : "79", + "description" : "Makes API calls to", + "technology" : "JSON/HTTPS", + "linkedRelationshipId" : "33" + } ], + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "8" + } ] + } ] + }, { + "id" : "72", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "e7539131-ef26-4370-beda-31449e4c6b07" + }, + "name" : "Big Bank plc", + "environment" : "Live", + "technology" : "Big Bank plc data center", + "instances" : "1", + "children" : [ { + "id" : "86", + "tags" : "Element,Deployment Node,Failover", + "properties" : { + "structurizr.dsl.identifier" : "edeb7dc9-7738-4516-9c81-c879369b2ab5" + }, + "name" : "bigbank-db02", + "environment" : "Live", + "technology" : "Ubuntu 16.04 LTS", + "instances" : "1", + "children" : [ { + "id" : "87", + "tags" : "Element,Deployment Node,Failover", + "properties" : { + "structurizr.dsl.identifier" : "secondarydatabaseserver" + }, + "name" : "Oracle - Secondary", + "environment" : "Live", + "technology" : "Oracle 12c", + "instances" : "1", + "containerInstances" : [ { + "id" : "88", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "livesecondarydatabaseinstance" + }, + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "18" + } ] + } ] + }, { + "id" : "82", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "7ec5cdd8-2cfe-46f6-8298-575bf81c2463" + }, + "name" : "bigbank-db01", + "environment" : "Live", + "technology" : "Ubuntu 16.04 LTS", + "instances" : "1", + "children" : [ { + "id" : "83", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "primarydatabaseserver" + }, + "name" : "Oracle - Primary", + "relationships" : [ { + "id" : "93", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "982513db-e0af-4cde-a468-b98ff002d363" + }, + "sourceId" : "83", + "destinationId" : "87", + "description" : "Replicates data to" + } ], + "environment" : "Live", + "technology" : "Oracle 12c", + "instances" : "1", + "containerInstances" : [ { + "id" : "84", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "liveprimarydatabaseinstance" + }, + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "18" + } ] + } ] + }, { + "id" : "77", + "tags" : "Element,Deployment Node,", + "properties" : { + "structurizr.dsl.identifier" : "291edaeb-88f1-4c8e-9a26-54d0c113b1ae" + }, + "name" : "bigbank-api***", + "environment" : "Live", + "technology" : "Ubuntu 16.04 LTS", + "instances" : "8", + "children" : [ { + "id" : "78", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "a89ebe72-8ae6-43bf-8708-cc142a531f6b" + }, + "name" : "Apache Tomcat", + "environment" : "Live", + "technology" : "Apache Tomcat 8.x", + "instances" : "1", + "containerInstances" : [ { + "id" : "79", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "liveapiapplicationinstance" + }, + "relationships" : [ { + "id" : "85", + "sourceId" : "79", + "destinationId" : "84", + "description" : "Reads from and writes to", + "technology" : "SQL/TCP", + "linkedRelationshipId" : "45" + }, { + "id" : "89", + "sourceId" : "79", + "destinationId" : "88", + "description" : "Reads from and writes to", + "technology" : "SQL/TCP", + "linkedRelationshipId" : "45" + }, { + "id" : "92", + "sourceId" : "79", + "destinationId" : "91", + "description" : "Makes API calls to", + "technology" : "XML/HTTPS", + "linkedRelationshipId" : "47" + } ], + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "11" + } ] + } ] + }, { + "id" : "90", + "tags" : "Element,Deployment Node,", + "properties" : { + "structurizr.dsl.identifier" : "cdb15b14-58b4-4ca2-aa56-a162198908c8" + }, + "name" : "bigbank-prod001", + "environment" : "Live", + "instances" : "1", + "softwareSystemInstances" : [ { + "id" : "91", + "tags" : "Software System Instance", + "properties" : { + "structurizr.dsl.identifier" : "d3633a49-63a4-4495-80b3-e173391180bb" + }, + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "softwareSystemId" : "4" + } ] + }, { + "id" : "73", + "tags" : "Element,Deployment Node,", + "properties" : { + "structurizr.dsl.identifier" : "f55b7219-d698-485d-91c1-ee303f3f5386" + }, + "name" : "bigbank-web***", + "environment" : "Live", + "technology" : "Ubuntu 16.04 LTS", + "instances" : "4", + "children" : [ { + "id" : "74", + "tags" : "Element,Deployment Node", + "properties" : { + "structurizr.dsl.identifier" : "1d58ef82-2528-4ded-84b2-af35a7f18009" + }, + "name" : "Apache Tomcat", + "environment" : "Live", + "technology" : "Apache Tomcat 8.x", + "instances" : "1", + "containerInstances" : [ { + "id" : "75", + "tags" : "Container Instance", + "properties" : { + "structurizr.dsl.identifier" : "livewebapplicationinstance" + }, + "relationships" : [ { + "id" : "76", + "sourceId" : "75", + "destinationId" : "71", + "description" : "Delivers to the customer's web browser", + "linkedRelationshipId" : "31" + } ], + "environment" : "Live", + "deploymentGroups" : [ "Default" ], + "instanceId" : 1, + "containerId" : "10" + } ] + } ] + } ] + } ] + }, + "documentation" : { }, + "views" : { + "systemLandscapeViews" : [ { + "key" : "SystemLandscape", + "order" : 1, + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "enterpriseBoundaryVisible" : true, + "relationships" : [ { + "id" : "27" + }, { + "id" : "26" + }, { + "id" : "25" + }, { + "id" : "24" + }, { + "id" : "23" + }, { + "id" : "22" + }, { + "id" : "21" + }, { + "id" : "20" + }, { + "id" : "19" + } ], + "elements" : [ { + "id" : "1", + "x" : 0, + "y" : 0 + }, { + "id" : "2", + "x" : 0, + "y" : 0 + }, { + "id" : "3", + "x" : 0, + "y" : 0 + }, { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "6", + "x" : 0, + "y" : 0 + }, { + "id" : "7", + "x" : 0, + "y" : 0 + } ] + } ], + "systemContextViews" : [ { + "key" : "SystemContext", + "order" : 2, + "description" : "The system context diagram for the Internet Banking System.", + "properties" : { + "structurizr.groups" : "false" + }, + "softwareSystemId" : "7", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "animations" : [ { + "order" : 1, + "elements" : [ "7" ] + }, { + "order" : 2, + "elements" : [ "1" ], + "relationships" : [ "19" ] + }, { + "order" : 3, + "elements" : [ "4" ], + "relationships" : [ "20" ] + }, { + "order" : 4, + "elements" : [ "5" ], + "relationships" : [ "22", "21" ] + } ], + "enterpriseBoundaryVisible" : true, + "relationships" : [ { + "id" : "22" + }, { + "id" : "21" + }, { + "id" : "20" + }, { + "id" : "19" + } ], + "elements" : [ { + "id" : "1", + "x" : 0, + "y" : 0 + }, { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "7", + "x" : 0, + "y" : 0 + } ] + } ], + "containerViews" : [ { + "key" : "Containers", + "order" : 3, + "description" : "The container diagram for the Internet Banking System.", + "softwareSystemId" : "7", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "animations" : [ { + "order" : 1, + "elements" : [ "1", "4", "5" ], + "relationships" : [ "22" ] + }, { + "order" : 2, + "elements" : [ "10" ], + "relationships" : [ "28" ] + }, { + "order" : 3, + "elements" : [ "8" ], + "relationships" : [ "29", "31" ] + }, { + "order" : 4, + "elements" : [ "9" ], + "relationships" : [ "30" ] + }, { + "order" : 5, + "elements" : [ "11" ], + "relationships" : [ "33", "47", "37", "49" ] + }, { + "order" : 6, + "elements" : [ "18" ], + "relationships" : [ "45" ] + } ], + "externalSoftwareSystemBoundariesVisible" : true, + "relationships" : [ { + "id" : "29" + }, { + "id" : "28" + }, { + "id" : "37" + }, { + "id" : "22" + }, { + "id" : "33" + }, { + "id" : "45" + }, { + "id" : "31" + }, { + "id" : "30" + }, { + "id" : "47" + }, { + "id" : "49" + } ], + "elements" : [ { + "id" : "11", + "x" : 0, + "y" : 0 + }, { + "id" : "1", + "x" : 0, + "y" : 0 + }, { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "18", + "x" : 0, + "y" : 0 + }, { + "id" : "8", + "x" : 0, + "y" : 0 + }, { + "id" : "9", + "x" : 0, + "y" : 0 + }, { + "id" : "10", + "x" : 0, + "y" : 0 + } ] + } ], + "componentViews" : [ { + "key" : "Components", + "order" : 4, + "description" : "The component diagram for the API Application.", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "animations" : [ { + "order" : 1, + "elements" : [ "4", "5", "18", "8", "9" ] + }, { + "order" : 2, + "elements" : [ "12", "15" ], + "relationships" : [ "44", "36", "40", "32" ] + }, { + "order" : 3, + "elements" : [ "13", "16" ], + "relationships" : [ "34", "46", "38", "41" ] + }, { + "order" : 4, + "elements" : [ "14", "17" ], + "relationships" : [ "35", "48", "39", "42", "43" ] + } ], + "containerId" : "11", + "externalContainerBoundariesVisible" : true, + "relationships" : [ { + "id" : "40" + }, { + "id" : "41" + }, { + "id" : "42" + }, { + "id" : "43" + }, { + "id" : "32" + }, { + "id" : "36" + }, { + "id" : "35" + }, { + "id" : "34" + }, { + "id" : "44" + }, { + "id" : "46" + }, { + "id" : "48" + }, { + "id" : "38" + }, { + "id" : "39" + } ], + "elements" : [ { + "id" : "12", + "x" : 0, + "y" : 0 + }, { + "id" : "13", + "x" : 0, + "y" : 0 + }, { + "id" : "14", + "x" : 0, + "y" : 0 + }, { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "15", + "x" : 0, + "y" : 0 + }, { + "id" : "16", + "x" : 0, + "y" : 0 + }, { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "17", + "x" : 0, + "y" : 0 + }, { + "id" : "18", + "x" : 0, + "y" : 0 + }, { + "id" : "8", + "x" : 0, + "y" : 0 + }, { + "id" : "9", + "x" : 0, + "y" : 0 + } ] + } ], + "dynamicViews" : [ { + "key" : "SignIn", + "order" : 6, + "description" : "Summarises how the sign in feature works in the single-page application.", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "elementId" : "11", + "externalBoundariesVisible" : true, + "relationships" : [ { + "id" : "32", + "description" : "Submits credentials to", + "order" : "1", + "response" : false + }, { + "id" : "40", + "description" : "Validates credentials using", + "order" : "2", + "response" : false + }, { + "id" : "44", + "description" : "select * from users where username = ?", + "order" : "3", + "response" : false + }, { + "id" : "44", + "description" : "Returns user data to", + "order" : "4", + "response" : true + }, { + "id" : "40", + "description" : "Returns true if the hashed password matches", + "order" : "5", + "response" : true + }, { + "id" : "32", + "description" : "Sends back an authentication token to", + "order" : "6", + "response" : true + } ], + "elements" : [ { + "id" : "12", + "x" : 0, + "y" : 0 + }, { + "id" : "15", + "x" : 0, + "y" : 0 + }, { + "id" : "18", + "x" : 0, + "y" : 0 + }, { + "id" : "8", + "x" : 0, + "y" : 0 + } ] + } ], + "deploymentViews" : [ { + "key" : "LiveDeployment", + "order" : 8, + "description" : "An example live deployment scenario for the Internet Banking System.", + "softwareSystemId" : "7", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "environment" : "Live", + "animations" : [ { + "order" : 1, + "elements" : [ "69", "70", "71" ] + }, { + "order" : 2, + "elements" : [ "67", "68" ] + }, { + "order" : 3, + "elements" : [ "77", "78", "79", "72", "73", "74", "75" ], + "relationships" : [ "80", "81", "76" ] + }, { + "order" : 4, + "elements" : [ "82", "83", "84" ], + "relationships" : [ "85" ] + }, { + "order" : 5, + "elements" : [ "88", "86", "87" ], + "relationships" : [ "89", "93" ] + } ], + "relationships" : [ { + "id" : "93" + }, { + "id" : "80" + }, { + "id" : "81" + }, { + "id" : "92" + }, { + "id" : "76" + }, { + "id" : "85" + }, { + "id" : "89" + } ], + "elements" : [ { + "id" : "88", + "x" : 0, + "y" : 0 + }, { + "id" : "77", + "x" : 0, + "y" : 0 + }, { + "id" : "67", + "x" : 0, + "y" : 0 + }, { + "id" : "78", + "x" : 0, + "y" : 0 + }, { + "id" : "68", + "x" : 0, + "y" : 0 + }, { + "id" : "79", + "x" : 0, + "y" : 0 + }, { + "id" : "69", + "x" : 0, + "y" : 0 + }, { + "id" : "90", + "x" : 0, + "y" : 0 + }, { + "id" : "91", + "x" : 0, + "y" : 0 + }, { + "id" : "70", + "x" : 0, + "y" : 0 + }, { + "id" : "71", + "x" : 0, + "y" : 0 + }, { + "id" : "82", + "x" : 0, + "y" : 0 + }, { + "id" : "83", + "x" : 0, + "y" : 0 + }, { + "id" : "72", + "x" : 0, + "y" : 0 + }, { + "id" : "84", + "x" : 0, + "y" : 0 + }, { + "id" : "73", + "x" : 0, + "y" : 0 + }, { + "id" : "74", + "x" : 0, + "y" : 0 + }, { + "id" : "86", + "x" : 0, + "y" : 0 + }, { + "id" : "75", + "x" : 0, + "y" : 0 + }, { + "id" : "87", + "x" : 0, + "y" : 0 + } ] + }, { + "key" : "DevelopmentDeployment", + "order" : 7, + "description" : "An example development deployment scenario for the Internet Banking System.", + "softwareSystemId" : "7", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "environment" : "Development", + "animations" : [ { + "order" : 1, + "elements" : [ "50", "51", "52" ] + }, { + "order" : 2, + "elements" : [ "55", "57", "53", "54" ], + "relationships" : [ "56", "58" ] + }, { + "order" : 3, + "elements" : [ "59", "60", "61" ], + "relationships" : [ "62" ] + } ], + "relationships" : [ { + "id" : "62" + }, { + "id" : "56" + }, { + "id" : "66" + }, { + "id" : "58" + } ], + "elements" : [ { + "id" : "55", + "x" : 0, + "y" : 0 + }, { + "id" : "57", + "x" : 0, + "y" : 0 + }, { + "id" : "59", + "x" : 0, + "y" : 0 + }, { + "id" : "60", + "x" : 0, + "y" : 0 + }, { + "id" : "61", + "x" : 0, + "y" : 0 + }, { + "id" : "50", + "x" : 0, + "y" : 0 + }, { + "id" : "51", + "x" : 0, + "y" : 0 + }, { + "id" : "52", + "x" : 0, + "y" : 0 + }, { + "id" : "63", + "x" : 0, + "y" : 0 + }, { + "id" : "53", + "x" : 0, + "y" : 0 + }, { + "id" : "64", + "x" : 0, + "y" : 0 + }, { + "id" : "54", + "x" : 0, + "y" : 0 + }, { + "id" : "65", + "x" : 0, + "y" : 0 + } ] + } ], + "imageViews" : [ { + "key" : "MainframeBankingSystemFacade", + "order" : 5, + "title" : "[Code] Mainframe Banking System Facade", + "elementId" : "16", + "content" : "https://raw.githubusercontent.com/structurizr/examples/main/dsl/big-bank-plc/internet-banking-system/mainframe-banking-system-facade.png", + "contentType" : "image/png" + } ], + "configuration" : { + "branding" : { }, + "styles" : { + "elements" : [ { + "tag" : "Person", + "color" : "#ffffff", + "fontSize" : 22, + "shape" : "Person" + }, { + "tag" : "Customer", + "background" : "#08427b" + }, { + "tag" : "Bank Staff", + "background" : "#999999" + }, { + "tag" : "Software System", + "background" : "#1168bd", + "color" : "#ffffff" + }, { + "tag" : "Existing System", + "background" : "#999999", + "color" : "#ffffff" + }, { + "tag" : "Container", + "background" : "#438dd5", + "color" : "#ffffff" + }, { + "tag" : "Web Browser", + "shape" : "WebBrowser" + }, { + "tag" : "Mobile App", + "shape" : "MobileDeviceLandscape" + }, { + "tag" : "Database", + "shape" : "Cylinder" + }, { + "tag" : "Component", + "background" : "#85bbf0", + "color" : "#000000" + }, { + "tag" : "Failover", + "opacity" : 25 + } ] + }, + "terminology" : { } + } + } +} \ No newline at end of file diff --git a/structurizr-export/src/test/resources/groups.dsl b/structurizr-export/src/test/resources/groups.dsl new file mode 100644 index 000000000..c2f39a5bc --- /dev/null +++ b/structurizr-export/src/test/resources/groups.dsl @@ -0,0 +1,59 @@ +workspace { + + model { + properties { + structurizr.groupSeparator / + } + + a = softwareSystem "A" + + group "Group 1" { + b = softwareSystem "B" + } + + group "Group 2" { + c = softwareSystem "C" + + group "Group 3" { + d = softwareSystem "D" { + e = container "E" + + group "Group 4" { + f = container "F" { + g = component "G" + + group "Group 5" { + h = component "H" + } + } + } + } + } + } + + a -> b + b -> c + c -> e + c -> g + c -> h + + } + + views { + systemlandscape "SystemLandscape" { + include * + autolayout + } + + container d "Containers" { + include * + autolayout + } + + component f "Components" { + include * + autolayout + } + } + +} diff --git a/structurizr-export/src/test/resources/groups.json b/structurizr-export/src/test/resources/groups.json new file mode 100644 index 000000000..9ffc544f4 --- /dev/null +++ b/structurizr-export/src/test/resources/groups.json @@ -0,0 +1,255 @@ +{ + "id" : 0, + "name" : "Name", + "description" : "Description", + "properties" : { + "structurizr.dsl" : "d29ya3NwYWNlIHsKCiAgICBtb2RlbCB7CiAgICAgICAgcHJvcGVydGllcyB7CiAgICAgICAgICAgIHN0cnVjdHVyaXpyLmdyb3VwU2VwYXJhdG9yIC8KICAgICAgICB9CgogICAgICAgIGEgPSBzb2Z0d2FyZVN5c3RlbSAiQSIKCiAgICAgICAgZ3JvdXAgIkdyb3VwIDEiIHsKICAgICAgICAgICAgYiA9IHNvZnR3YXJlU3lzdGVtICJCIgogICAgICAgIH0KCiAgICAgICAgZ3JvdXAgIkdyb3VwIDIiIHsKICAgICAgICAgICAgYyA9IHNvZnR3YXJlU3lzdGVtICJDIgoKICAgICAgICAgICAgZ3JvdXAgIkdyb3VwIDMiIHsKICAgICAgICAgICAgICAgIGQgPSBzb2Z0d2FyZVN5c3RlbSAiRCIgewogICAgICAgICAgICAgICAgICAgIGUgPSBjb250YWluZXIgIkUiCgogICAgICAgICAgICAgICAgICAgIGdyb3VwICJHcm91cCA0IiB7CiAgICAgICAgICAgICAgICAgICAgICAgIGYgPSBjb250YWluZXIgIkYiIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgIGcgPSBjb21wb25lbnQgIkciCgogICAgICAgICAgICAgICAgICAgICAgICAgICAgZ3JvdXAgIkdyb3VwIDUiIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBoID0gY29tcG9uZW50ICJIIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICBhIC0+IGIKICAgICAgICBiIC0+IGMKICAgICAgICBjIC0+IGUKICAgICAgICBjIC0+IGcKICAgICAgICBjIC0+IGgKCiAgICB9CgogICAgdmlld3MgewogICAgICAgIHN5c3RlbWxhbmRzY2FwZSAiU3lzdGVtTGFuZHNjYXBlIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgICAgICBhdXRvbGF5b3V0CiAgICAgICAgfQoKICAgICAgICBjb250YWluZXIgZCAiQ29udGFpbmVycyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICAgICAgYXV0b2xheW91dAogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGYgIkNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgICAgIGF1dG9sYXlvdXQKICAgICAgICB9CiAgICB9Cgp9Cg==" + }, + "configuration" : { }, + "model" : { + "softwareSystems" : [ { + "id" : "1", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "a" + }, + "name" : "A", + "relationships" : [ { + "id" : "9", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "370d771e-c1ab-4243-b9cd-2325145b20bc" + }, + "sourceId" : "1", + "destinationId" : "2" + } ], + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "2", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "b" + }, + "name" : "B", + "relationships" : [ { + "id" : "10", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "923c8280-f475-422a-9fef-2b69220bc8e6" + }, + "sourceId" : "2", + "destinationId" : "3" + } ], + "group" : "Group 1", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "3", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "c" + }, + "name" : "C", + "relationships" : [ { + "id" : "11", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "cd5c9209-d4f5-4ffa-a323-dc0d5612cdb8" + }, + "sourceId" : "3", + "destinationId" : "5" + }, { + "id" : "12", + "sourceId" : "3", + "destinationId" : "4", + "linkedRelationshipId" : "11" + }, { + "id" : "13", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "0f23b6e5-9276-4468-b3fb-9381f53c8cd7" + }, + "sourceId" : "3", + "destinationId" : "7" + }, { + "id" : "14", + "sourceId" : "3", + "destinationId" : "6", + "linkedRelationshipId" : "13" + }, { + "id" : "15", + "tags" : "Relationship", + "properties" : { + "structurizr.dsl.identifier" : "b0b99a2e-129b-4f75-81c0-de9c67916229" + }, + "sourceId" : "3", + "destinationId" : "8" + } ], + "group" : "Group 2", + "location" : "Unspecified", + "documentation" : { } + }, { + "id" : "4", + "tags" : "Element,Software System", + "properties" : { + "structurizr.dsl.identifier" : "d" + }, + "name" : "D", + "group" : "Group 2/Group 3", + "location" : "Unspecified", + "containers" : [ { + "id" : "6", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "f" + }, + "name" : "F", + "group" : "Group 4", + "components" : [ { + "id" : "8", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "h" + }, + "name" : "H", + "group" : "Group 5", + "documentation" : { } + }, { + "id" : "7", + "tags" : "Element,Component", + "properties" : { + "structurizr.dsl.identifier" : "g" + }, + "name" : "G", + "documentation" : { } + } ], + "documentation" : { } + }, { + "id" : "5", + "tags" : "Element,Container", + "properties" : { + "structurizr.dsl.identifier" : "e" + }, + "name" : "E", + "documentation" : { } + } ], + "documentation" : { } + } ], + "properties" : { + "structurizr.groupSeparator" : "/" + } + }, + "documentation" : { }, + "views" : { + "systemLandscapeViews" : [ { + "key" : "SystemLandscape", + "order" : 1, + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "enterpriseBoundaryVisible" : true, + "relationships" : [ { + "id" : "12" + }, { + "id" : "9" + }, { + "id" : "10" + } ], + "elements" : [ { + "id" : "1", + "x" : 0, + "y" : 0 + }, { + "id" : "2", + "x" : 0, + "y" : 0 + }, { + "id" : "3", + "x" : 0, + "y" : 0 + }, { + "id" : "4", + "x" : 0, + "y" : 0 + } ] + } ], + "containerViews" : [ { + "key" : "Containers", + "order" : 2, + "softwareSystemId" : "4", + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "externalSoftwareSystemBoundariesVisible" : true, + "relationships" : [ { + "id" : "14" + }, { + "id" : "11" + } ], + "elements" : [ { + "id" : "3", + "x" : 0, + "y" : 0 + }, { + "id" : "5", + "x" : 0, + "y" : 0 + }, { + "id" : "6", + "x" : 0, + "y" : 0 + } ] + } ], + "componentViews" : [ { + "key" : "Components", + "order" : 3, + "automaticLayout" : { + "implementation" : "Graphviz", + "rankDirection" : "TopBottom", + "rankSeparation" : 300, + "nodeSeparation" : 300, + "edgeSeparation" : 0, + "vertices" : false, + "applied" : false + }, + "containerId" : "6", + "externalContainerBoundariesVisible" : true, + "relationships" : [ { + "id" : "15" + }, { + "id" : "13" + } ], + "elements" : [ { + "id" : "3", + "x" : 0, + "y" : 0 + }, { + "id" : "7", + "x" : 0, + "y" : 0 + }, { + "id" : "8", + "x" : 0, + "y" : 0 + } ] + } ], + "configuration" : { + "branding" : { }, + "styles" : { }, + "terminology" : { } + } + } +} \ No newline at end of file diff --git a/structurizr-import/README.md b/structurizr-import/README.md new file mode 100644 index 000000000..210022b6c --- /dev/null +++ b/structurizr-import/README.md @@ -0,0 +1,11 @@ +# structurizr-import + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-import.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-import) + +This library includes a number of classes for importing the following into a Structurizr workspace: + +- Diagrams from PlantUML, Mermaid, and Kroki. +- Supplementary Markdown/AsciiDoc documentation. +- Architecture decision records (ADRs). +- Images (for use in the above). + diff --git a/structurizr-import/build.gradle b/structurizr-import/build.gradle new file mode 100644 index 000000000..53caa555b --- /dev/null +++ b/structurizr-import/build.gradle @@ -0,0 +1,7 @@ +dependencies { + + api project(':structurizr-client') + +} + +description = 'Utilities to import diagrams and documentation into a Structurizr workspace' \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/AbstractDiagramImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/AbstractDiagramImporter.java new file mode 100644 index 000000000..16c951e31 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/AbstractDiagramImporter.java @@ -0,0 +1,44 @@ +package com.structurizr.importer.diagrams; + +import com.structurizr.http.HttpClient; +import com.structurizr.view.View; +import com.structurizr.view.ViewSet; + +import java.util.HashMap; +import java.util.Map; + +public abstract class AbstractDiagramImporter { + + protected static final Map<String, String> CONTENT_TYPES_BY_FORMAT = new HashMap<>(); + + protected static final String CONTENT_TYPE_IMAGE_PNG = "image/png"; + protected static final String CONTENT_TYPE_IMAGE_SVG = "image/svg+xml"; + + protected static final String PNG_FORMAT = "png"; + protected static final String SVG_FORMAT = "svg"; + + static { + CONTENT_TYPES_BY_FORMAT.put(PNG_FORMAT, CONTENT_TYPE_IMAGE_PNG); + CONTENT_TYPES_BY_FORMAT.put(SVG_FORMAT, CONTENT_TYPE_IMAGE_SVG); + } + + protected HttpClient httpClient; + + public AbstractDiagramImporter() { + this.httpClient = new HttpClient(); + } + + public AbstractDiagramImporter(HttpClient httpClient) { + this.httpClient = httpClient; + } + + protected String getViewOrViewSetProperty(View view, String name) { + ViewSet views = view.getViewSet(); + + return + view.getProperties().getOrDefault(name, + views.getConfiguration().getProperties().get(name) + ); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/image/ImageImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/image/ImageImporter.java new file mode 100644 index 000000000..600f10d21 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/image/ImageImporter.java @@ -0,0 +1,64 @@ +package com.structurizr.importer.diagrams.image; + +import com.structurizr.http.HttpClient; +import com.structurizr.http.RemoteContent; +import com.structurizr.importer.diagrams.AbstractDiagramImporter; +import com.structurizr.util.ImageUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; + +import java.io.File; + +public class ImageImporter extends AbstractDiagramImporter { + + public static final String IMAGE_INLINE_PROPERTY = "image.inline"; + + public ImageImporter() { + } + + public ImageImporter(HttpClient httpClient) { + super(httpClient); + } + + public void importDiagram(ImageView view, File file) throws Exception { + importDiagram(view, file, null); + } + + public void importDiagram(ImageView view, File file, ColorScheme colorScheme) throws Exception { + view.setContent(ImageUtils.getImageAsDataUri(file)); + view.setContentType(ImageUtils.getContentType(file)); + view.setTitle(file.getName()); + } + + public void importDiagram(ImageView view, String url) throws Exception { + importDiagram(view, url, null); + } + + public void importDiagram(ImageView view, String url, ColorScheme colorScheme) throws Exception { + RemoteContent remoteContent = httpClient.get(url, false); + + String inline = getViewOrViewSetProperty(view, IMAGE_INLINE_PROPERTY); + if ("true".equals(inline)) { + + String contentType = remoteContent.getContentType(); + + if (!contentType.equals(CONTENT_TYPE_IMAGE_PNG) && !contentType.equals(CONTENT_TYPE_IMAGE_SVG)) { + throw new IllegalArgumentException(String.format("Found %s - expected a format of %s or %s", contentType, PNG_FORMAT, SVG_FORMAT)); + } + + if (contentType.equals(CONTENT_TYPE_IMAGE_SVG)) { + view.setContent(ImageUtils.getSvgAsDataUri(remoteContent.getContentAsString()), colorScheme); + } else { + view.setContent(ImageUtils.getPngAsDataUri(remoteContent.getContentAsBytes()), colorScheme); + } + + view.setContentType(contentType); + } else { + view.setContent(url, colorScheme); + view.setContentType(remoteContent.getContentType()); + } + + view.setTitle(url.substring(url.lastIndexOf("/")+1)); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiEncoder.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiEncoder.java new file mode 100644 index 000000000..22b2c4f38 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiEncoder.java @@ -0,0 +1,30 @@ +package com.structurizr.importer.diagrams.kroki; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.zip.Deflater; + +/** + * See https://docs.kroki.io/kroki/setup/encode-diagram/#java + */ +class KrokiEncoder { + + public String encode(String decoded) throws IOException { + byte[] bytes = Base64.getUrlEncoder().encode(compress(decoded.getBytes())); + return new String(bytes, StandardCharsets.UTF_8); + } + + private byte[] compress(byte[] source) throws IOException { + Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION); + deflater.setInput(source); + deflater.finish(); + + byte[] buffer = new byte[2048]; + int compressedLength = deflater.deflate(buffer); + byte[] result = new byte[compressedLength]; + System.arraycopy(buffer, 0, result, 0, compressedLength); + return result; + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiImporter.java new file mode 100644 index 000000000..fd06aa1c3 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/kroki/KrokiImporter.java @@ -0,0 +1,76 @@ +package com.structurizr.importer.diagrams.kroki; + +import com.structurizr.http.HttpClient; +import com.structurizr.http.RemoteContent; +import com.structurizr.importer.diagrams.AbstractDiagramImporter; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class KrokiImporter extends AbstractDiagramImporter { + + public static final String KROKI_URL_PROPERTY = "kroki.url"; + public static final String KROKI_FORMAT_PROPERTY = "kroki.format"; + public static final String KROKI_INLINE_PROPERTY = "kroki.inline"; + + public KrokiImporter() { + } + + public KrokiImporter(HttpClient httpClient) { + super(httpClient); + } + + public void importDiagram(ImageView view, String format, File file) throws Exception { + importDiagram(view, format, file, null); + } + + public void importDiagram(ImageView view, String format, File file, ColorScheme colorScheme) throws Exception { + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + view.setTitle(file.getName()); + + importDiagram(view, format, content, colorScheme); + } + + public void importDiagram(ImageView view, String format, String content) throws Exception { + importDiagram(view, format, content, null); + } + + public void importDiagram(ImageView view, String format, String content, ColorScheme colorScheme) throws Exception { + String krokiServer = getViewOrViewSetProperty(view, KROKI_URL_PROPERTY); + if (StringUtils.isNullOrEmpty(krokiServer)) { + throw new IllegalArgumentException("Please define a view/viewset property named " + KROKI_URL_PROPERTY + " to specify your Kroki server"); + } + + String imageFormat = getViewOrViewSetProperty(view, KROKI_FORMAT_PROPERTY); + if (StringUtils.isNullOrEmpty(imageFormat)) { + imageFormat = PNG_FORMAT; + } + + if (!imageFormat.equals(PNG_FORMAT) && !imageFormat.equals(SVG_FORMAT)) { + throw new IllegalArgumentException(String.format("Expected a format of %s or %s", PNG_FORMAT, SVG_FORMAT)); + } + + String encodedDiagram = new KrokiEncoder().encode(content); + String url = String.format("%s/%s/%s/%s", krokiServer, format, imageFormat, encodedDiagram); + + String inline = getViewOrViewSetProperty(view, KROKI_INLINE_PROPERTY); + if ("true".equals(inline)) { + RemoteContent remoteContent = httpClient.get(url, true); + + if (imageFormat.equals(SVG_FORMAT)) { + view.setContent(ImageUtils.getSvgAsDataUri(remoteContent.getContentAsString()), colorScheme); + } else { + view.setContent(ImageUtils.getPngAsDataUri(remoteContent.getContentAsBytes()), colorScheme); + } + } else { + view.setContent(url, colorScheme); + } + view.setContentType(CONTENT_TYPES_BY_FORMAT.get(imageFormat)); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoder.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoder.java new file mode 100644 index 000000000..553d33d78 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoder.java @@ -0,0 +1,45 @@ +package com.structurizr.importer.diagrams.mermaid; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +/** + * Encodes a Mermaid diagram definition to base64 format, for use with image URLs, etc. + */ +public final class MermaidEncoder { + + private static final String TEMPLATE = "{ \"code\":\"%s\", \"mermaid\":{\"theme\":\"default\"}}"; + + public String encode(String mermaidDefinition) { + return this.encode(mermaidDefinition, false); + } + + public String encode(String mermaidDefinition, boolean compress) { + if (compress) { + try { + String content = String.format(TEMPLATE, mermaidDefinition.replaceAll("\n", "\\\\n").replaceAll("\"", "\\\\\"")); + byte[] compressedDefinition = compress(content); + return "pako:" + Base64.getUrlEncoder().encodeToString(compressedDefinition); + } catch(Exception e) { + e.printStackTrace(); + } + } + + return Base64.getUrlEncoder().encodeToString(mermaidDefinition.getBytes(StandardCharsets.UTF_8)); + } + + private byte[] compress(String content) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(); + + DeflaterOutputStream dos = new DeflaterOutputStream(baos, deflater, true); + dos.write(content.getBytes(StandardCharsets.UTF_8)); + dos.finish(); + + return baos.toByteArray(); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidImporter.java new file mode 100644 index 000000000..190b2b2d5 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/mermaid/MermaidImporter.java @@ -0,0 +1,87 @@ +package com.structurizr.importer.diagrams.mermaid; + +import com.structurizr.http.HttpClient; +import com.structurizr.http.RemoteContent; +import com.structurizr.importer.diagrams.AbstractDiagramImporter; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class MermaidImporter extends AbstractDiagramImporter { + + public static final String MERMAID_URL_PROPERTY = "mermaid.url"; + public static final String MERMAID_FORMAT_PROPERTY = "mermaid.format"; + public static final String MERMAID_COMPRESS_PROPERTY = "mermaid.compress"; + public static final String MERMAID_INLINE_PROPERTY = "mermaid.inline"; + + public MermaidImporter() { + } + + public MermaidImporter(HttpClient httpClient) { + super(httpClient); + } + + public void importDiagram(ImageView view, File file) throws Exception { + importDiagram(view, file, null); + } + + public void importDiagram(ImageView view, File file, ColorScheme colorScheme) throws Exception { + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + view.setTitle(file.getName()); + + importDiagram(view, content, colorScheme); + } + + public void importDiagram(ImageView view, String content) throws Exception { + importDiagram(view, content, null); + } + + public void importDiagram(ImageView view, String content, ColorScheme colorScheme) throws Exception { + String mermaidServer = getViewOrViewSetProperty(view, MERMAID_URL_PROPERTY); + if (StringUtils.isNullOrEmpty(mermaidServer)) { + throw new IllegalArgumentException("Please define a view/viewset property named " + MERMAID_URL_PROPERTY + " to specify your Mermaid server"); + } + + String format = getViewOrViewSetProperty(view, MERMAID_FORMAT_PROPERTY); + if (StringUtils.isNullOrEmpty(format)) { + format = SVG_FORMAT; + } + + if (!format.equals(PNG_FORMAT) && !format.equals(SVG_FORMAT)) { + throw new IllegalArgumentException(String.format("Expected a format of %s or %s", PNG_FORMAT, SVG_FORMAT)); + } + + String compress = getViewOrViewSetProperty(view, MERMAID_COMPRESS_PROPERTY); + if (StringUtils.isNullOrEmpty(compress)) { + compress = "true"; + } + + String encodedMermaid = new MermaidEncoder().encode(content, compress.equalsIgnoreCase("true")); + String url; + if (format.equals(PNG_FORMAT)) { + url = String.format("%s/img/%s?type=png", mermaidServer, encodedMermaid); + } else { + url = String.format("%s/svg/%s", mermaidServer, encodedMermaid); + } + + String inline = getViewOrViewSetProperty(view, MERMAID_INLINE_PROPERTY); + if ("true".equals(inline)) { + RemoteContent remoteContent = httpClient.get(url, true); + + if (format.equals(SVG_FORMAT)) { + view.setContent(ImageUtils.getSvgAsDataUri(remoteContent.getContentAsString()), colorScheme); + } else { + view.setContent(ImageUtils.getPngAsDataUri(remoteContent.getContentAsBytes()), colorScheme); + } + } else { + view.setContent(url, colorScheme); + } + view.setContentType(CONTENT_TYPES_BY_FORMAT.get(format)); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoder.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoder.java new file mode 100644 index 000000000..96088b150 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoder.java @@ -0,0 +1,73 @@ +package com.structurizr.importer.diagrams.plantuml; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +/** + * A Java implementation of http://plantuml.com/code-javascript-synchronous + * that uses Java's built-in Deflate algorithm. + */ +public final class PlantUMLEncoder { + + public String encode(String plantUMLDefinition) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION, true); + + DeflaterOutputStream dos = new DeflaterOutputStream(baos, deflater, true); + dos.write(plantUMLDefinition.getBytes(StandardCharsets.UTF_8)); + dos.finish(); + + return encode(baos.toByteArray()); + } + + private String encode(byte[] bytes) { + StringBuilder buf = new StringBuilder(); + for (int i = 0; i < bytes.length; i += 3) { + int b1 = (bytes[i]) & 0xFF; + int b2 = (i + 1 < bytes.length ? bytes[i + 1] : (byte)0) & 0xFF; + int b3 = (i + 2 < bytes.length ? bytes[i + 2] : (byte)0) & 0xFF; + + append3bytes(buf, b1, b2, b3); + } + + return buf.toString(); + } + + private char encode6bit(byte b) { + if (b < 10) { + return (char) ('0' + b); + } + b -= 10; + if (b < 26) { + return (char) ('A' + b); + } + b -= 26; + if (b < 26) { + return (char) ('a' + b); + } + b -= 26; + if (b == 0) { + return '-'; + } + if (b == 1) { + return '_'; + } + + return '?'; + } + + private void append3bytes(StringBuilder buf, int b1, int b2, int b3) { + int c1 = b1 >> 2; + int c2 = (b1 & 0x3) << 4 | b2 >> 4; + int c3 = (b2 & 0xF) << 2 | b3 >> 6; + int c4 = b3 & 0x3F; + + buf.append(encode6bit((byte)(c1 & 0x3F))); + buf.append(encode6bit((byte)(c2 & 0x3F))); + buf.append(encode6bit((byte)(c3 & 0x3F))); + buf.append(encode6bit((byte)(c4 & 0x3F))); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporter.java new file mode 100644 index 000000000..0e52bcad6 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporter.java @@ -0,0 +1,103 @@ +package com.structurizr.importer.diagrams.plantuml; + +import com.structurizr.http.HttpClient; +import com.structurizr.http.RemoteContent; +import com.structurizr.importer.diagrams.AbstractDiagramImporter; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ColorScheme; +import com.structurizr.view.ImageView; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class PlantUMLImporter extends AbstractDiagramImporter { + + public static final String PLANTUML_URL_PROPERTY = "plantuml.url"; + public static final String PLANTUML_FORMAT_PROPERTY = "plantuml.format"; + public static final String PLANTUML_INLINE_PROPERTY = "plantuml.inline"; + private static final String TITLE_STRING = "title "; + private static final String NEWLINE = "\n"; + + public PlantUMLImporter() { + } + + public PlantUMLImporter(HttpClient httpClient) { + super(httpClient); + } + + public void importDiagram(ImageView view, File file) throws Exception { + importDiagram(view, file, null); + } + + public void importDiagram(ImageView view, File file, ColorScheme colorScheme) throws Exception { + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + view.setTitle(file.getName()); + + importDiagram(view, content, colorScheme); + } + + public void importDiagram(ImageView view, String content) throws Exception { + importDiagram(view, content, null); + } + + public void importDiagram(ImageView view, String content, ColorScheme colorScheme) throws Exception { + String plantUMLServer = getViewOrViewSetProperty(view, PLANTUML_URL_PROPERTY); + if (StringUtils.isNullOrEmpty(plantUMLServer)) { + throw new IllegalArgumentException("Please define a view/viewset property named " + PLANTUML_URL_PROPERTY + " to specify your PlantUML server"); + } + + String format = getViewOrViewSetProperty(view, PLANTUML_FORMAT_PROPERTY); + if (StringUtils.isNullOrEmpty(format)) { + format = SVG_FORMAT; + } + + if (!format.equals(PNG_FORMAT) && !format.equals(SVG_FORMAT)) { + throw new IllegalArgumentException(String.format("Expected a format of %s or %s", PNG_FORMAT, SVG_FORMAT)); + } + + String encodedPlantUML = new PlantUMLEncoder().encode(content); + String url = String.format("%s/%s/%s", plantUMLServer, format, encodedPlantUML); + + String inline = getViewOrViewSetProperty(view, PLANTUML_INLINE_PROPERTY); + if ("true".equals(inline)) { + RemoteContent remoteContent = httpClient.get(url, true); + + if (format.equals(SVG_FORMAT)) { + view.setContent(ImageUtils.getSvgAsDataUri(remoteContent.getContentAsString()), colorScheme); + } else { + view.setContent(ImageUtils.getPngAsDataUri(remoteContent.getContentAsBytes()), colorScheme); + } + } else { + view.setContent(url, colorScheme); + } + view.setContentType(CONTENT_TYPES_BY_FORMAT.get(format)); + + String[] lines = content.split(NEWLINE); + for (String line : lines) { + if (line.startsWith(TITLE_STRING)) { + view.setTitle(extractTitle(line)); + } + } + } + + private String extractTitle(String line) { + String title = line.substring(TITLE_STRING.length()); + + if (title.contains(NEWLINE)) { + title = title.split(NEWLINE)[0]; + } + + if (title.startsWith("<size:")) { + title = title.substring(title.indexOf(">") + 1); + + if (title.endsWith("</size>")) { + title = title.substring(0, title.indexOf("</size>")); + } + } + + return title; + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/AbstractDecisionImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/AbstractDecisionImporter.java new file mode 100644 index 000000000..9d02ebea9 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/AbstractDecisionImporter.java @@ -0,0 +1,72 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; +import com.structurizr.util.StringUtils; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Imports architecture decision records created/managed by adr-tools (https://github.com/npryce/adr-tools). + * The format for ADRs is as follows: + * + * Filename: {DECISION_ID:0000}-*.md + * + * Content: + * # {DECISION_ID}. {DECISION_TITLE} + * + * Date: {DECISION_DATE:YYYY-MM-DD} + * + * ## Status + * + * {DECISION_STATUS and links} + * + * ## Context + * ... + */ +public abstract class AbstractDecisionImporter implements DocumentationImporter { + + protected TimeZone timeZone = TimeZone.getDefault(); + protected Charset characterEncoding = StandardCharsets.UTF_8; + + /** + * Sets the time zone to use when parsing dates (the default is UTC) + * + * @param timeZone a time zone as a String (e.g. "Europe/London" or "UTC") + */ + public void setTimeZone(String timeZone) { + this.timeZone = TimeZone.getTimeZone(timeZone); + } + + /** + * Sets the time zone to use when parsing dates. + * + * @param timeZone a TimeZone instance + */ + public void setTimeZone(TimeZone timeZone) { + this.timeZone = timeZone; + } + + /** + * Provides a way to change the character encoding used by the DSL parser. + * + * @param characterEncoding a Charset instance + */ + public void setCharacterEncoding(Charset characterEncoding) { + if (characterEncoding == null) { + throw new IllegalArgumentException("A character encoding must be specified"); + } + + this.characterEncoding = characterEncoding; + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/AdrToolsDecisionImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/AdrToolsDecisionImporter.java new file mode 100644 index 000000000..817aa306f --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/AdrToolsDecisionImporter.java @@ -0,0 +1,219 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; +import com.structurizr.util.StringUtils; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Imports architecture decision records created/managed by adr-tools (https://github.com/npryce/adr-tools). + * The format for ADRs is as follows: + * + * Filename: {DECISION_ID:0000}-*.md + * + * Content: + * # {DECISION_ID}. {DECISION_TITLE} + * + * Date: {DECISION_DATE:YYYY-MM-DD} + * + * ## Status + * + * {DECISION_STATUS and links} + * + * ## Context + * ... + */ +public class AdrToolsDecisionImporter extends AbstractDecisionImporter { + + private static final String STATUS_PROPOSED = "Proposed"; + private static final String STATUS_SUPERSEDED = "Superseded"; + private static final String SUPERCEDED_ALTERNATIVE_SPELLING = "Superceded"; + + private static final String DATE_PREFIX = "Date: "; + private static final String STATUS_HEADING = "## Status"; + private static final String CONTEXT_HEADING = "## Context"; + + private static final Pattern LINK_REGEX = Pattern.compile("(.*) \\[.*]\\((.*)\\)"); + + private String dateFormat = "yyyy-MM-dd"; + + /** + * Sets the date format to use when parsing dates (the default is "yyyy-MM-dd"). + * + * @param dateFormat a date format, as a String + */ + public void setDateFormat(String dateFormat) { + this.dateFormat = dateFormat; + } + + /** + * Imports Markdown files from the specified path, one per decision. + * + * @param documentable the item that decisions should be associated with + * @param path the path to import decisions from + */ + @Override + public void importDocumentation(Documentable documentable, File path) { + if (documentable == null) { + throw new IllegalArgumentException("A workspace, software system, container, or component must be specified."); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified."); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist."); + } + + if (!path.isDirectory()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " is not a directory."); + } + + try { + Map<String, Decision> decisionsById = new LinkedHashMap<>(); + + File[] markdownFiles = path.listFiles((dir, name) -> name.endsWith(".md")); + if (markdownFiles != null) { + Map<String,Decision> decisionsByFilename = new HashMap<>(); + + for (File file : markdownFiles) { + Decision decision = importDecision(file); + documentable.getDocumentation().addDecision(decision); + + decisionsById.put(decision.getId(), decision); + decisionsByFilename.put(file.getName(), decision); + } + + for (Decision decision : decisionsById.values()) { + extractLinks(decision, decisionsByFilename); + + // and replace file references, for example "0008-some-decision.md" -> "#8" + String content = decision.getContent(); + for (String filename : decisionsByFilename.keySet()) { + content = content.replace(filename, calculateUrl(decisionsByFilename.get(filename))); + } + decision.setContent(content); + } + } + } catch (Exception e) { + throw new DocumentationImportException(e); + } + } + + protected Decision importDecision(File file) throws Exception { + String id = extractIntegerIDFromFileName(file); + Decision decision = new Decision(id); + + String content = Files.readString(file.toPath(), characterEncoding); + content = content.replace("\r", ""); + decision.setContent(content); + + String[] lines = content.split("\\n"); + decision.setTitle(extractTitle(lines)); + decision.setDate(extractDate(lines)); + decision.setStatus(extractStatus(lines)); + decision.setFormat(Format.Markdown); + + return decision; + } + + protected String extractIntegerIDFromFileName(File file) { + return "" + Integer.parseInt(file.getName().substring(0, 4)); + } + + protected String extractTitle(String[] lines) { + // the title is assumed to be the first line of the content, in the format: + // # {DECISION_ID}. {DECISION_TITLE} + String titleLine = lines[0]; + + return titleLine.substring(titleLine.indexOf(".") + 2); + } + + protected Date extractDate(String[] lines) throws Exception { + // the date is on a line of its own, in the format: + // Date: {DECISION_DATE:YYYY-MM-DD} + SimpleDateFormat sdf = new SimpleDateFormat(dateFormat); + sdf.setTimeZone(timeZone); + + for (String line : lines) { + if (line.startsWith(DATE_PREFIX)) { + String dateAsString = line.substring(DATE_PREFIX.length()); + + return sdf.parse(dateAsString); + } + } + + return new Date(); + } + + protected String extractStatus(String[] lines) { + // the status is on a line of its own, after the ## Status header: + boolean inStatusSection = false; + for (String line : lines) { + if (!inStatusSection) { + if (line.startsWith(STATUS_HEADING)) { + inStatusSection = true; + } + } else { + if (!StringUtils.isNullOrEmpty(line)) { + String status = line.split(" ")[0]; + // early versions of adr-tools used the alternative spelling + if (SUPERCEDED_ALTERNATIVE_SPELLING.equals(status)) { + status = STATUS_SUPERSEDED; + } + + return status; + } + } + } + + return STATUS_PROPOSED; + } + + protected void extractLinks(Decision decision, Map<String,Decision> decisionsByFilename) { + // adr-tools allows users to create arbitrary links between ADRs, which reside inside the ## Status section + String[] lines = decision.getContent().split("\\n"); + boolean inStatusSection = false; + for (String line : lines) { + if (!inStatusSection) { + if (line.startsWith(STATUS_HEADING)) { + inStatusSection = true; + } + } else { + if (line.startsWith(CONTEXT_HEADING)) { + // we're done + return; + } else if (!StringUtils.isNullOrEmpty(line)) { + Matcher matcher = LINK_REGEX.matcher(line); + if (matcher.find()) { + String linkDescription = matcher.group(1); + String target = matcher.group(2); + + Decision targetDecision = decisionsByFilename.get(target); + if (targetDecision != null) { + decision.addLink(targetDecision, linkDescription); + } + } + } + } + } + } + + protected String calculateUrl(Decision decision) throws Exception { + return "#" + urlEncode(decision.getId()); + } + + protected String urlEncode(String value) throws Exception { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20"); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultDocumentationImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultDocumentationImporter.java new file mode 100644 index 000000000..1572a5ccd --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultDocumentationImporter.java @@ -0,0 +1,81 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; + +/** + * This implementation scans a given directory and automatically imports all Markdown or AsciiDoc + * files in that directory. + * + * See https://structurizr.com/help/documentation/headings for details of how section headings and numbering are handled. + */ +public class DefaultDocumentationImporter implements DocumentationImporter { + + /** + * Imports Markdown/AsciiDoc files from the specified path, each in its own section. + * + * @param documentable the item that documentation should be associated with + * @param path the path to import documentation from + */ + @Override + public void importDocumentation(Documentable documentable, File path) { + if (documentable == null) { + throw new IllegalArgumentException("A workspace, software system, container, or component must be specified."); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified."); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist."); + } + + try { + if (path.isDirectory()) { + File[] filesInDirectory = path.listFiles(); + if (filesInDirectory != null) { + Arrays.sort(filesInDirectory); + + for (File file : filesInDirectory) { + if (!file.isDirectory() && !file.getName().startsWith(".")) { + importFile(documentable, file); + } + } + } + } else { + importFile(documentable, path); + } + + // now trim the filenames + for (Section section : documentable.getDocumentation().getSections()) { + String filename = section.getFilename(); + + filename = filename.replace(path.getCanonicalPath(), ""); + if (filename.startsWith("/")) { + filename = filename.substring(1); + } + + section.setFilename(filename); + } + } catch (Exception e) { + throw new DocumentationImportException(e); + } + } + + protected void importFile(Documentable documentable, File file) throws Exception { + if (FormatFinder.isMarkdownOrAsciiDoc(file)) { + Format format = FormatFinder.findFormat(file); + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + + Section section = new Section(format, content); + section.setFilename(file.getCanonicalPath()); + documentable.getDocumentation().addSection(section); + } + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultImageImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultImageImporter.java new file mode 100644 index 000000000..f1cc2a3cd --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DefaultImageImporter.java @@ -0,0 +1,91 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Image; +import com.structurizr.util.ImageUtils; +import com.structurizr.util.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Base64; + +/** + * This implementation scans a given directory and automatically imports all Markdown or AsciiDoc + * files in that directory. + * + * - Each file must represent a separate documentation section. + * - The second level heading ("## Section Title" in Markdown and "== Section Title" in AsciiDoc) will be used as the section title. + */ +public class DefaultImageImporter implements DocumentationImporter { + + /** + * Imports one or more png/jpg/jpeg/gif images from the specified path. + * + * @param documentable the item that documentation should be associated with + * @param path the path to import images from + */ + public void importDocumentation(Documentable documentable, File path) { + if (documentable == null) { + throw new IllegalArgumentException("A workspace or software system must be specified."); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified."); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist."); + } + + try { + if (path.isDirectory()) { + importImages(documentable, "", path); + } else { + importImage(documentable, "", path); + } + } catch (Exception e) { + throw new DocumentationImportException(e.getMessage(), e); + } + } + + private void importImages(Documentable documentable, String root, File path) throws IOException { + File[] files = path.listFiles(); + if (files != null) { + for (File file : files) { + String name = file.getName().toLowerCase(); + if (file.isDirectory() && !file.isHidden()) { + if (StringUtils.isNullOrEmpty(root)) { + importImages(documentable, file.getName(), file); + } else { + importImages(documentable, root + "/" + file.getName(), file); + } + } else { + if (name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".gif") || name.endsWith(".svg")) { + importImage(documentable, root, file); + } + } + } + } + } + + private void importImage(Documentable documentable, String path, File file) throws IOException { + String contentType = ImageUtils.getContentType(file); + String base64Content; + + String name; + if (StringUtils.isNullOrEmpty(path)) { + name = file.getName(); + } else { + name = path + "/" + file.getName(); + } + + if (ImageUtils.CONTENT_TYPE_IMAGE_SVG.equalsIgnoreCase(contentType)) { + base64Content = Base64.getEncoder().encodeToString(Files.readAllBytes(file.toPath())); + } else { + contentType = ImageUtils.getContentType(file); + base64Content = ImageUtils.getImageAsBase64(file); + } + + documentable.getDocumentation().addImage(new Image(name, contentType, base64Content)); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImportException.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImportException.java new file mode 100644 index 000000000..3d068a298 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImportException.java @@ -0,0 +1,13 @@ +package com.structurizr.importer.documentation; + +public class DocumentationImportException extends RuntimeException { + + public DocumentationImportException(Throwable cause) { + super(cause); + } + + public DocumentationImportException(String message, Throwable cause) { + super(message, cause); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImporter.java new file mode 100644 index 000000000..ee006b3bc --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/DocumentationImporter.java @@ -0,0 +1,20 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Documentable; + +import java.io.File; + +/** + * An interface implemented by documentation importers. + */ +public interface DocumentationImporter { + + /** + * Imports documentation from the specified path. + * + * @param documentable the item that documentation should be associated with + * @param path the path to import documentation from + */ + void importDocumentation(Documentable documentable, File path); + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/FormatFinder.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/FormatFinder.java new file mode 100644 index 000000000..18c6a093e --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/FormatFinder.java @@ -0,0 +1,42 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Format; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class FormatFinder { + + private static final Set<String> MARKDOWN_EXTENSIONS = new HashSet<>(Arrays.asList(".md", ".markdown", ".text")); + private static final Set<String> ASCIIDOC_EXTENSIONS = new HashSet<>(Arrays.asList(".asciidoc", ".adoc", ".asc")); + private static final String DOT = "."; + + public static boolean isMarkdownOrAsciiDoc(File file) { + if (file.getName().contains(DOT)) { + String extension = file.getName().substring(file.getName().lastIndexOf(DOT)); + + return MARKDOWN_EXTENSIONS.contains(extension) || ASCIIDOC_EXTENSIONS.contains(extension); + } + + return false; + } + + public static Format findFormat(File file) { + if (file == null) { + throw new IllegalArgumentException("A file must be specified."); + } + + String extension = file.getName().substring(file.getName().lastIndexOf(".")); + if (MARKDOWN_EXTENSIONS.contains(extension)) { + return Format.Markdown; + } else if (ASCIIDOC_EXTENSIONS.contains(extension)) { + return Format.AsciiDoc; + } else { + // just assume Markdown + return Format.Markdown; + } + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/Log4brainsDecisionImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/Log4brainsDecisionImporter.java new file mode 100644 index 000000000..eb029e385 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/Log4brainsDecisionImporter.java @@ -0,0 +1,209 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Imports architecture decision records created/managed by Log4brains (https://github.com/thomvaill/log4brains). + * See https://github.com/thomvaill/log4brains/blob/master/docs/adr/template.md for the template. + */ +public class Log4brainsDecisionImporter extends AbstractDecisionImporter { + + private static final String DATE_PREFIX = "- Date: "; + private static final String STATUS_PREFIX = "- Status: "; + private static final Pattern STATUS_LINK_REGEX = Pattern.compile("- Status: (.*) \\[.*]\\((.*)\\)"); + private static final String SUPERSEDED = "superseded"; + private static final String LINKS_HEADING = "## Links"; + + private static final Pattern LINK_REGEX = Pattern.compile("- (.*) \\[.*]\\((.*)\\)"); + + private static final String DATE_FORMAT_IN_FILENAME = "yyyyMMdd"; + private static final String DATE_FORMAT_IN_CONTENT = "yyyy-MM-dd"; + + /** + * Imports Markdown files from the specified path, one per decision. + * + * @param documentable the item that decisions should be associated with + * @param path the path to import decisions from + */ + @Override + public void importDocumentation(Documentable documentable, File path) { + if (documentable == null) { + throw new IllegalArgumentException("A workspace, software system, container, or component must be specified."); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified."); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist."); + } + + if (!path.isDirectory()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " is not a directory."); + } + + try { + Map<String, Decision> decisionsById = new LinkedHashMap<>(); + + File[] markdownFiles = path.listFiles((dir, name) -> name.matches("\\d{4}\\d{2}\\d{2}-.+?.md")); + if (markdownFiles != null) { + Map<String,Decision> decisionsByFilename = new HashMap<>(); + + Arrays.sort(markdownFiles, Comparator.comparing(File::getName)); + + int decisionId = 1; + for (File file : markdownFiles) { + Decision decision = importDecision(decisionId, file); + documentable.getDocumentation().addDecision(decision); + + decisionsById.put(decision.getId(), decision); + decisionsByFilename.put(file.getName(), decision); + decisionId++; + } + + for (Decision decision : decisionsById.values()) { + extractLinks(decision, decisionsByFilename); + + // and replace file references, for example "0008-some-decision.md" -> "#8" + String content = decision.getContent(); + for (String filename : decisionsByFilename.keySet()) { + content = content.replace(filename, calculateUrl(decisionsByFilename.get(filename))); + } + decision.setContent(content); + } + } + } catch (Exception e) { + throw new DocumentationImportException(e); + } + } + + protected Decision importDecision(int id, File file) throws Exception { + Decision decision = new Decision("" + id); + + String content = Files.readString(file.toPath(), characterEncoding); + content = content.replace("\r", ""); + decision.setContent(content); + + String[] lines = content.split("\\n"); + decision.setTitle(extractTitle(lines)); + + decision.setDate(extractDateFromFilename(file)); + Date dateFromContent = extractDate(lines); + if (dateFromContent != null) { + decision.setDate(dateFromContent); + } + + decision.setStatus(extractStatus(lines)); + decision.setFormat(Format.Markdown); + + return decision; + } + + protected Date extractDateFromFilename(File file) throws Exception { + SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT_IN_FILENAME); + sdf.setTimeZone(timeZone); + + return sdf.parse(file.getName().substring(0, DATE_FORMAT_IN_FILENAME.length())); + } + + protected String extractTitle(String[] lines) { + // the title is assumed to be the first line of the content, in the format: + // # {DECISION_TITLE} + String titleLine = lines[0]; + + return titleLine.substring(2); + } + + protected Date extractDate(String[] lines) throws Exception { + // the date can optionally be on a line of its own, in the format: + // - Date: {DECISION_DATE:YYYY-MM-DD} + SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT_IN_CONTENT); + sdf.setTimeZone(timeZone); + + for (String line : lines) { + if (line.startsWith(DATE_PREFIX)) { + String dateAsString = line.substring(DATE_PREFIX.length()); + + return sdf.parse(dateAsString); + } + } + + return null; + } + + protected String extractStatus(String[] lines) { + // the date is on a line of its own, in the format: + // - Status: {DECISION_STATUS} + for (String line : lines) { + if (line.startsWith(STATUS_PREFIX)) { + String status = line.substring(STATUS_PREFIX.length()); + if (status.startsWith(SUPERSEDED)) { + // superseded by [slug](filename) + return SUPERSEDED; + } else { + return status; + } + } + } + + return ""; + } + + protected void extractLinks(Decision decision, Map<String,Decision> decisionsByFilename) { + // extracts links from: + // 1. the status line + // 2. the final ## Links section (if present) + boolean inLinksSection = false; + String[] lines = decision.getContent().split("\\n"); + for (String line : lines) { + if (line.startsWith(STATUS_PREFIX)) { + Matcher matcher = STATUS_LINK_REGEX.matcher(line); + if (matcher.find()) { + String linkDescription = matcher.group(1); + String markdownFile = matcher.group(2); + + Decision targetDecision = decisionsByFilename.get(markdownFile); + if (targetDecision != null) { + decision.addLink(targetDecision, linkDescription); + } + } + } + + if (line.startsWith(LINKS_HEADING)) { + inLinksSection = true; + } + + if (inLinksSection) { + Matcher matcher = LINK_REGEX.matcher(line); + if (matcher.find()) { + String linkDescription = matcher.group(1); + String target = matcher.group(2); + + Decision targetDecision = decisionsByFilename.get(target); + if (targetDecision != null) { + decision.addLink(targetDecision, linkDescription); + } + } + } + } + } + + protected String calculateUrl(Decision decision) throws Exception { + return "#" + urlEncode(decision.getId()); + } + + protected String urlEncode(String value) throws Exception { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20"); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/MadrDecisionImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/MadrDecisionImporter.java new file mode 100644 index 000000000..864642b8f --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/MadrDecisionImporter.java @@ -0,0 +1,206 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Imports architecture decision records in MADR format (see https://adr.github.io/madr). + */ +public class MadrDecisionImporter extends AbstractDecisionImporter { + + private static final Log log = LogFactory.getLog(MadrDecisionImporter.class); + + private static final String STATUS_PREFIX = "status: "; + private static final String DEFAULT_STATUS = "accepted"; + + private static final String DATE_PREFIX = "date: "; + private static final String DATE_FORMAT = "yyyy-MM-dd"; + + private static final Pattern LINK_REGEX = Pattern.compile("\\[.*]\\((.*)\\)"); + private static final String FRONT_MATTER_SEPARATOR = "---"; + private static final String DEFAULT_LINK_DESCRIPTION = "Links to"; + + /** + * Imports Markdown files from the specified path, one per decision. + * + * @param documentable the item that decisions should be associated with + * @param path the path to import decisions from + */ + @Override + public void importDocumentation(Documentable documentable, File path) { + if (documentable == null) { + throw new IllegalArgumentException("A workspace, software system, container, or component must be specified."); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified."); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist."); + } + + if (!path.isDirectory()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " is not a directory."); + } + + Map<String, Decision> decisionsById = new LinkedHashMap<>(); + + File[] markdownFiles = path.listFiles((dir, name) -> name.matches("\\d{4}-.+?.md")); + if (markdownFiles != null) { + Map<String, Decision> decisionsByFilename = new HashMap<>(); + + Arrays.sort(markdownFiles, Comparator.comparing(File::getName)); + + for (File file : markdownFiles) { + try { + Decision decision = importDecision(file); + documentable.getDocumentation().addDecision(decision); + + decisionsById.put(decision.getId(), decision); + decisionsByFilename.put(file.getName(), decision); + } catch (Exception e) { + throw new DocumentationImportException("Error importing decision from + " + file.getAbsolutePath(), e); + } + } + + for (Decision decision : decisionsById.values()) { + try { + extractLinks(decision, decisionsByFilename); + + // and replace file references, for example "0008-some-decision.md" -> "#8" + String content = decision.getContent(); + for (String filename : decisionsByFilename.keySet()) { + content = content.replace(filename, calculateUrl(decisionsByFilename.get(filename))); + } + decision.setContent(content); + } catch (Exception e) { + log.warn("Error extracting links from decision with ID " + decision.getId()); + } + } + } + } + + protected Decision importDecision(File file) throws Exception { + String id = extractIntegerIDFromFileName(file); + Decision decision = new Decision(id); + + String content = Files.readString(file.toPath(), characterEncoding); + content = content.replace("\r", ""); + + String contentWithoutFrontMatter = content.replaceFirst("(?m)^---\n(^.*\n)*?---\n", ""); + decision.setContent(contentWithoutFrontMatter); + + String[] linesWithFrontMatter = content.split("\\n"); + String[] linesWithoutFrontMatter = contentWithoutFrontMatter.split("\\n"); + + decision.setTitle(extractTitle(linesWithoutFrontMatter)); + + decision.setDate(extractDate(file)); + Date dateFromFrontMatter = extractDate(linesWithFrontMatter); + if (dateFromFrontMatter != null) { + decision.setDate(dateFromFrontMatter); + } + + decision.setStatus(extractStatus(linesWithFrontMatter)); + decision.setFormat(Format.Markdown); + + return decision; + } + + protected String extractIntegerIDFromFileName(File file) { + return "" + Integer.parseInt(file.getName().substring(0, 4)); + } + + protected String extractTitle(String[] lines) { + // the title is assumed to be the first line of the content, in the format: + // # {DECISION_TITLE} + for (String line : lines) { + if (line.startsWith("# ")) { + return line.substring(2); + } + } + + return "Title"; + } + + protected Date extractDate(File file) throws Exception { + return new Date(file.lastModified()); + } + + protected Date extractDate(String[] lines) throws Exception { + // the date is on a line of its own, in the front matter, in the format: + // date: {YYYY-MM-DD} + if (lines[0].startsWith(FRONT_MATTER_SEPARATOR)) { + for (int i = 1; i < lines.length; i++) { + String line = lines[i]; + + if (line.startsWith(FRONT_MATTER_SEPARATOR)) { + // we've hit the end of the front matter + return null; + } else if (line.startsWith(DATE_PREFIX)) { + SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT); + sdf.setTimeZone(timeZone); + + return sdf.parse(line.substring(DATE_PREFIX.length())); + } + } + } + + return null; + } + + protected String extractStatus(String[] lines) { + // the status is on a line of its own, in the front matter, in the format: + // status: {DECISION_STATUS} + if (lines[0].startsWith(FRONT_MATTER_SEPARATOR)) { + for (int i = 1; i < lines.length; i++) { + String line = lines[i]; + + if (line.startsWith(FRONT_MATTER_SEPARATOR)) { + // we've hit the end of the front matter + return DEFAULT_STATUS; + } else if (line.startsWith(STATUS_PREFIX)) { + return line.substring(STATUS_PREFIX.length()); + } + } + } + + return DEFAULT_STATUS; + } + + protected void extractLinks(Decision decision, Map<String,Decision> decisionsByFilename) { + // extracts standard Markdown links from the content + String[] lines = decision.getContent().split("\\n"); + for (String line : lines) { + Matcher matcher = LINK_REGEX.matcher(line); + while (matcher.find()) { + String target = matcher.group(1); + + Decision targetDecision = decisionsByFilename.get(target); + if (targetDecision != null) { + decision.addLink(targetDecision, DEFAULT_LINK_DESCRIPTION); + } + } + } + } + + protected String calculateUrl(Decision decision) throws Exception { + return "#" + urlEncode(decision.getId()); + } + + protected String urlEncode(String value) throws Exception { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replaceAll("\\+", "%20"); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/main/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentationImporter.java b/structurizr-import/src/main/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentationImporter.java new file mode 100644 index 000000000..26bfe6880 --- /dev/null +++ b/structurizr-import/src/main/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentationImporter.java @@ -0,0 +1,70 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Section; + +import java.io.File; +import java.util.Arrays; + +/** + * This implementation extends the DefaultDocumentationImporter to recursively import documentation. + */ +public class RecursiveDefaultDocumentationImporter extends DefaultDocumentationImporter { + + /** + * Imports Markdown/AsciiDoc files from the specified path, each in its own section. + * + * @param documentable the item that documentation should be associated with + * @param path the path to import documentation from + */ + @Override + public void importDocumentation(Documentable documentable, File path) { + try { + if (documentable == null) { + throw new IllegalArgumentException("A workspace or software system must be specified"); + } + + if (path == null) { + throw new IllegalArgumentException("A path must be specified"); + } else if (!path.exists()) { + throw new IllegalArgumentException(path.getAbsolutePath() + " does not exist"); + } + + if (path.isDirectory()) { + importDirectory(documentable, path); + } else { + importFile(documentable, path); + } + + // now trim the filenames + for (Section section : documentable.getDocumentation().getSections()) { + String filename = section.getFilename(); + + filename = filename.replace(path.getCanonicalPath(), ""); + if (filename.startsWith("/")) { + filename = filename.substring(1); + } + + section.setFilename(filename); + } + } catch (Exception e) { + throw new DocumentationImportException(e); + } + } + + private void importDirectory(Documentable documentable, File path) throws Exception { + File[] filesInDirectory = path.listFiles(); + if (filesInDirectory != null) { + Arrays.sort(filesInDirectory); + + for (File file : filesInDirectory) { + if (!file.isDirectory() && !file.getName().startsWith(".")) { + importFile(documentable, file); + } else if (file.isDirectory()) { + importDirectory(documentable, file); + } + } + } + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/image/ImageImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/image/ImageImporterTests.java new file mode 100644 index 000000000..722ccc141 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/image/ImageImporterTests.java @@ -0,0 +1,45 @@ +package com.structurizr.importer.diagrams.image; + +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ImageImporterTests { + + @Test + @Tag("IntegrationTest") + public void importDiagram_Url() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new ImageImporter(httpClient).importDiagram(view, "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/alexa-for-business.png"); + assertEquals("https://static.structurizr.com/themes/amazon-web-services-2020.04.30/alexa-for-business.png", view.getContent()); + assertEquals("image/png", view.getContentType()); + assertEquals("alexa-for-business.png", view.getTitle()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_Url_Inline() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(ImageImporter.IMAGE_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new ImageImporter(httpClient).importDiagram(view, "https://static.structurizr.com/themes/amazon-web-services-2020.04.30/alexa-for-business.png"); + assertEquals("data:image/png;base64,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", view.getContent()); + assertEquals("image/png", view.getContentType()); + assertEquals("alexa-for-business.png", view.getTitle()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiEncoderTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiEncoderTests.java new file mode 100644 index 000000000..5d24e00ab --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiEncoderTests.java @@ -0,0 +1,68 @@ +package com.structurizr.importer.diagrams.kroki; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class KrokiEncoderTests { + + @Test + public void encode_plantuml() throws Exception { + assertEquals("eNpzKC5JLCopzc3hcspPUtC1U3DMyUxOVbBSyEjNycnnckjNSwFKAgD4CQzA", + new KrokiEncoder().encode("@startuml\n" + + "Bob -> Alice : hello\n" + + "@enduml")); + } + + @Test + public void encode_seqdiag() throws Exception { + assertEquals("eNorTi1MyUxMV6jmUlBIKsovL04tUlDQtVMoT00CMsuAvOicxKTUHAVbBSV31xAF_WKIBv3isnT9pMTiVDMTpVhroGaEBpD2gqL85NTi4nxk7c75eUDpEoWS1Aogka-QmZuYnoqu2UZXF6HZGslRIAm4MmuuWgA13z1R", + new KrokiEncoder().encode("seqdiag {\n" + + " browser -> webserver [label = \"GET /seqdiag/svg/base64\"];\n" + + " webserver -> processor [label = \"Convert text to image\"];\n" + + " webserver <-- processor;\n" + + " browser <-- webserver;\n" + + "}")); + } + + @Test + public void encode_erd() throws Exception { + assertEquals( + "eNqLDkgtKs7Pi-XSykvMTeXKSM1MzyjhKodQ2kmZRSUZ8Tn5yYklmfl58ZkpXFzRPlAeUAuQn5xZUslVXJJYksqVnF-aV1JUycUFMVJBS1fXUAGmGgCFAiQX", + new KrokiEncoder().encode("[Person]\n" + + "*name\n" + + "height\n" + + "weight\n" + + "+birth_location_id\n" + + "\n" + + "[Location]\n" + + "*id\n" + + "city\n" + + "state\n" + + "country\n" + + "\n" + + "Person *--1 Location")); + } + + @Test + public void encode_graphviz() throws Exception { + assertEquals( + "eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", + new KrokiEncoder().encode("digraph G {Hello->World}\n")); + } + + @Test + public void encode_blockdiag() throws Exception { + assertEquals( + "eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==", + new KrokiEncoder().encode("blockdiag {\n" + + " Kroki -> generates -> \"Block diagrams\";\n" + + " Kroki -> is -> \"very easy!\";\n" + + "\n" + + " Kroki [color = \"greenyellow\"];\n" + + " \"Block diagrams\" [color = \"pink\"];\n" + + " \"very easy!\" [color = \"orange\"];\n" + + "}")); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiImporterTests.java new file mode 100644 index 000000000..9fac27fd2 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/kroki/KrokiImporterTests.java @@ -0,0 +1,132 @@ +package com.structurizr.importer.diagrams.kroki; + +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class KrokiImporterTests { + + @Test + public void importDiagram() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + ImageView view = workspace.getViews().createImageView("key"); + + new KrokiImporter().importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("diagram.dot", view.getTitle()); + assertEquals("https://kroki.io/graphviz/png/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + public void importDiagram_AsPNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_FORMAT_PROPERTY, "png"); + ImageView view = workspace.getViews().createImageView("key"); + + new KrokiImporter().importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("diagram.dot", view.getTitle()); + assertEquals("https://kroki.io/graphviz/png/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlinePNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_FORMAT_PROPERTY, "png"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new KrokiImporter(httpClient).importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("diagram.dot", view.getTitle()); + assertEquals("data:image/png;base64,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", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + public void importDiagram_AsSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_FORMAT_PROPERTY, "svg"); + ImageView view = workspace.getViews().createImageView("key"); + + new KrokiImporter().importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("diagram.dot", view.getTitle()); + assertEquals("https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlineSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_FORMAT_PROPERTY, "svg"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new KrokiImporter(httpClient).importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("diagram.dot", view.getTitle()); + assertEquals("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIKICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8IS0tIEdlbmVyYXRlZCBieSBncmFwaHZpeiB2ZXJzaW9uIDkuMC4wICgyMDIzMDkxMS4xODI3KQogLS0+CjwhLS0gVGl0bGU6IEcgUGFnZXM6IDEgLS0+Cjxzdmcgd2lkdGg9Ijg5cHQiIGhlaWdodD0iMTE2cHQiCiB2aWV3Qm94PSIwLjAwIDAuMDAgODkuMzcgMTE2LjAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGcgaWQ9ImdyYXBoMCIgY2xhc3M9ImdyYXBoIiB0cmFuc2Zvcm09InNjYWxlKDEgMSkgcm90YXRlKDApIHRyYW5zbGF0ZSg0IDExMikiPgo8dGl0bGU+RzwvdGl0bGU+Cjxwb2x5Z29uIGZpbGw9IndoaXRlIiBzdHJva2U9Im5vbmUiIHBvaW50cz0iLTQsNCAtNCwtMTEyIDg1LjM3LC0xMTIgODUuMzcsNCAtNCw0Ii8+CjwhLS0gSGVsbG8gLS0+CjxnIGlkPSJub2RlMSIgY2xhc3M9Im5vZGUiPgo8dGl0bGU+SGVsbG88L3RpdGxlPgo8ZWxsaXBzZSBmaWxsPSJub25lIiBzdHJva2U9ImJsYWNrIiBjeD0iNDAuNjkiIGN5PSItOTAiIHJ4PSIzNy41MyIgcnk9IjE4Ii8+Cjx0ZXh0IHRleHQtYW5jaG9yPSJtaWRkbGUiIHg9IjQwLjY5IiB5PSItODUuMzMiIGZvbnQtZmFtaWx5PSJUaW1lcyxzZXJpZiIgZm9udC1zaXplPSIxNC4wMCI+SGVsbG88L3RleHQ+CjwvZz4KPCEtLSBXb3JsZCAtLT4KPGcgaWQ9Im5vZGUyIiBjbGFzcz0ibm9kZSI+Cjx0aXRsZT5Xb3JsZDwvdGl0bGU+CjxlbGxpcHNlIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIGN4PSI0MC42OSIgY3k9Ii0xOCIgcng9IjQwLjY5IiByeT0iMTgiLz4KPHRleHQgdGV4dC1hbmNob3I9Im1pZGRsZSIgeD0iNDAuNjkiIHk9Ii0xMy4zMiIgZm9udC1mYW1pbHk9IlRpbWVzLHNlcmlmIiBmb250LXNpemU9IjE0LjAwIj5Xb3JsZDwvdGV4dD4KPC9nPgo8IS0tIEhlbGxvJiM0NTsmZ3Q7V29ybGQgLS0+CjxnIGlkPSJlZGdlMSIgY2xhc3M9ImVkZ2UiPgo8dGl0bGU+SGVsbG8mIzQ1OyZndDtXb3JsZDwvdGl0bGU+CjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIGQ9Ik00MC42OSwtNzEuN0M0MC42OSwtNjQuNDEgNDAuNjksLTU1LjczIDQwLjY5LC00Ny41NCIvPgo8cG9seWdvbiBmaWxsPSJibGFjayIgc3Ryb2tlPSJibGFjayIgcG9pbnRzPSI0NC4xOSwtNDcuNjIgNDAuNjksLTM3LjYyIDM3LjE5LC00Ny42MiA0NC4xOSwtNDcuNjIiLz4KPC9nPgo8L2c+Cjwvc3ZnPgo=", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_WhenTheKrokiUrlIsNotDefined() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new KrokiImporter().importDiagram(view, "graphviz", new File("./src/test/resources/diagrams/kroki/diagram.dot")); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Please define a view/viewset property named kroki.url to specify your Kroki server", e.getMessage()); + } + } + + @Test + public void importDiagram_WhenAnInvalidFormatIsSpecified() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_URL_PROPERTY, "https://kroki.io"); + workspace.getViews().getConfiguration().addProperty(KrokiImporter.KROKI_FORMAT_PROPERTY, "jpg"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new KrokiImporter().importDiagram(view, "graphviz", "..."); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Expected a format of png or svg", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoderTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoderTests.java new file mode 100644 index 000000000..b4b69190b --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidEncoderTests.java @@ -0,0 +1,35 @@ +package com.structurizr.importer.diagrams.mermaid; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MermaidEncoderTests { + + @Test + public void encode_flowchart() throws Exception { + File file = new File("./src/test/resources/diagrams/mermaid/flowchart.mmd"); + String mermaid = Files.readString(file.toPath()); + String encodedMermaid = new MermaidEncoder().encode(mermaid); + assertEquals("Zmxvd2NoYXJ0IFRECiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZykKICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfQogICAgQyAtLT58T25lfCBEW0xhcHRvcF0KICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdCiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXQ==", encodedMermaid); + } + + @Test + public void encode_flowchart_compressed() throws Exception { + File file = new File("./src/test/resources/diagrams/mermaid/flowchart.mmd"); + String mermaid = Files.readString(file.toPath()); + String encodedMermaid = new MermaidEncoder().encode(mermaid, true); + assertEquals("pako:eJxVj70OgjAUhV_lppMm8gIMJlKUhUQHtspwAxfbSH9SaoihvLsgi571-85JzgSssS2xlHW9HRuJPkCV3w0sOQkuvRqCxqGGJDnGggJoa-gdIdsVFgZpnVPmsd_8bJWAT-WqEQSpzHPeEP_2r4Yi5KJEF6yrf0k12ghnoW5ymf8n0tPSuogO0w6TBj1w9DU7ANPkNaqWpRMLkvR6oqUOX31g8_wBLY9E1w==", encodedMermaid); + } + + @Test + public void encode_class() throws Exception { + File file = new File("./src/test/resources/diagrams/mermaid/class.mmd"); + String mermaid = Files.readString(file.toPath()); + assertEquals("Y2xhc3NEaWFncmFtCiAgICBBbmltYWwgPHwtLSBEdWNrCiAgICBBbmltYWwgPHwtLSBGaXNoCiAgICBBbmltYWwgPHwtLSBaZWJyYQogICAgQW5pbWFsIDogK2ludCBhZ2UKICAgIEFuaW1hbCA6ICtTdHJpbmcgZ2VuZGVyCiAgICBBbmltYWw6ICtpc01hbW1hbCgpCiAgICBBbmltYWw6ICttYXRlKCkKICAgIGNsYXNzIER1Y2t7CiAgICAgICtTdHJpbmcgYmVha0NvbG9yCiAgICAgICtzd2ltKCkKICAgICAgK3F1YWNrKCkKICAgIH0KICAgIGNsYXNzIEZpc2h7CiAgICAgIC1pbnQgc2l6ZUluRmVldAogICAgICAtY2FuRWF0KCkKICAgIH0KICAgIGNsYXNzIFplYnJhewogICAgICArYm9vbCBpc193aWxkCiAgICAgICtydW4oKQogICAgfQo=", new MermaidEncoder().encode(mermaid)); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidImporterTests.java new file mode 100644 index 000000000..ca4cd4452 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/mermaid/MermaidImporterTests.java @@ -0,0 +1,137 @@ +package com.structurizr.importer.diagrams.mermaid; + +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class MermaidImporterTests { + + @Test + public void importDiagram() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_COMPRESS_PROPERTY, "false"); + ImageView view = workspace.getViews().createImageView("key"); + + new MermaidImporter().importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("flowchart.mmd", view.getTitle()); + assertEquals("https://mermaid.ink/svg/Zmxvd2NoYXJ0IFRECiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZykKICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfQogICAgQyAtLT58T25lfCBEW0xhcHRvcF0KICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdCiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXQ==", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_AsPNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_FORMAT_PROPERTY, "png"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_COMPRESS_PROPERTY, "false"); + ImageView view = workspace.getViews().createImageView("key"); + + new MermaidImporter().importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("flowchart.mmd", view.getTitle()); + assertEquals("https://mermaid.ink/img/Zmxvd2NoYXJ0IFRECiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZykKICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfQogICAgQyAtLT58T25lfCBEW0xhcHRvcF0KICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdCiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXQ==?type=png", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlinePNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_FORMAT_PROPERTY, "png"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_COMPRESS_PROPERTY, "true"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new MermaidImporter(httpClient).importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("flowchart.mmd", view.getTitle()); + assertEquals("data:image/png;base64,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z3XzCTy2cpoDbiKB16ldu/bVsLCwq/a/ly9fHsyNLd933nknLdTy5ctnJYB70K5du7jGjRsnyPXExMSCM2bMsJUsWTLlxRdfjLOvI435mzdvovZ9Bx4fctyt95OAe/bZZxP69OkT43jff/7zn4SPPvqowJgxYwpMmzbtcnh4eK716CIjI5tywF1EwHkvbkwlFy5cOG2m46+//mrx9fX14cbPHWc/8nicDRNP4G7Kli3rOHPxwhdffBHGJUxrxu3r1KlTJoIseXzIbdy4MTAoKMjG3fYbGe8zGo305ptvxrz66qtFV65cmYe79DeOHj3q07t37yLjxo27wuEYvHXr1oCCBQtan3jiicTXX3/9ho/Prf8lV69eNX722Wd5pfwpLSUOtaSOHTvGlipVytK/f/++JpPJzGNwvQngDnibKdqkSZP4V155RbW+r1+/bujUqVMQVxZ8uIyZ9ksEL730UtGmTZvGdejQIT4hIcEwefLkfHv37vWPi4szcms9pWHDhgktW7ZMIPBa3Diy8NCInETgelbrTJw4Md/PP/8cVKBAASuXLhN5X6f2i/b93rvvvhvNJfb8ISEhVu4ZXk5JSaG5c+eGyH6Q93kmGeLh7TCe94dpla/srOPOPH7iyf79+/2qVauWFBgYmGnTWAKsUqVKyQcOHJDaNnFLW603ZcqU/PXr109csWLF+X79+l3j8ZU8GzZsUGehMJvNxCXOQvv27fPnYIzhjeKylJ14vSJcjgrmVfwQcJAd1atXT5KGkv1vrjz4FypUyMbbo6/9Nh5DNl27ds3I47tqp/Hee+8VvHDhgs+QIUOieedy8fHHH785a9asfLw9+hJ4NW64J3JDqWhm9y1cuDCkatWqySNHjrzavHnzuHXr1uX55Zdf1K9dcONd7fek6iX39erVSwXl1KlT8/G+L1hCa86cORelsT927NiC3HlI+5WM7Kzjzjw+5K5cuWLincYdy5BcUrJIC8TxNvmgGjRocJNDT0qPyaGhoZbDhw+rncju3bv9zp075/P2229fk7E8fn6r9ALz5s1r+/777wO5FziGALKhRo0aSdLAspcnedvy523KIr01CTe5LSoqyo+3LWvFihXNf/31l7+EIre6r1epUiVFWuTc84uThtrXX38dQuD1eFu5wkFXJOPtERERSTxskyjlzLZt28bLfk+qAXKf/ZAV2R7l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", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + public void importDiagram_AsSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_FORMAT_PROPERTY, "svg"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_COMPRESS_PROPERTY, "false"); + ImageView view = workspace.getViews().createImageView("key"); + + new MermaidImporter().importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("flowchart.mmd", view.getTitle()); + assertEquals("https://mermaid.ink/svg/Zmxvd2NoYXJ0IFRECiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZykKICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfQogICAgQyAtLT58T25lfCBEW0xhcHRvcF0KICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdCiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXQ==", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlineSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_FORMAT_PROPERTY, "svg"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_COMPRESS_PROPERTY, "false"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new MermaidImporter(httpClient).importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("flowchart.mmd", view.getTitle()); + assertEquals("data:image/svg+xml;base64,PHN2ZyBpZD0ibWVybWFpZElua1N2ZyIgd2lkdGg9IjEwMCUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgY2xhc3M9ImZsb3djaGFydCIgc3R5bGU9Im1heC13aWR0aDogNDQxcHg7IiB2aWV3Qm94PSIwIDAgNDQxIDUxNy4xNTYyNSIgcm9sZT0iZ3JhcGhpY3MtZG9jdW1lbnQgZG9jdW1lbnQiIGFyaWEtcm9sZWRlc2NyaXB0aW9uPSJmbG93Y2hhcnQtdjIiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48c3R5bGUgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiPkBpbXBvcnQgdXJsKCJodHRwczovL2NkbmpzLmNsb3VkZmxhcmUuY29tL2FqYXgvbGlicy9mb250LWF3ZXNvbWUvNi43LjIvY3NzL2FsbC5taW4uY3NzIik7PC9zdHlsZT48c3R5bGU+I21lcm1haWRJbmtTdmd7Zm9udC1mYW1pbHk6InRyZWJ1Y2hldCBtcyIsdmVyZGFuYSxhcmlhbCxzYW5zLXNlcmlmO2ZvbnQtc2l6ZToxNnB4O2ZpbGw6IzMzMzt9QGtleWZyYW1lcyBlZGdlLWFuaW1hdGlvbi1mcmFtZXtmcm9te3N0cm9rZS1kYXNob2Zmc2V0OjA7fX1Aa2V5ZnJhbWVzIGRhc2h7dG97c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fSNtZXJtYWlkSW5rU3ZnIC5lZGdlLWFuaW1hdGlvbi1zbG93e3N0cm9rZS1kYXNoYXJyYXk6OSw1IWltcG9ydGFudDtzdHJva2UtZGFzaG9mZnNldDo5MDA7YW5pbWF0aW9uOmRhc2ggNTBzIGxpbmVhciBpbmZpbml0ZTtzdHJva2UtbGluZWNhcDpyb3VuZDt9I21lcm1haWRJbmtTdmcgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jbWVybWFpZElua1N2ZyAuZXJyb3ItaWNvbntmaWxsOiM1NTIyMjI7fSNtZXJtYWlkSW5rU3ZnIC5lcnJvci10ZXh0e2ZpbGw6IzU1MjIyMjtzdHJva2U6IzU1MjIyMjt9I21lcm1haWRJbmtTdmcgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jbWVybWFpZElua1N2ZyAuZWRnZS10aGlja25lc3MtdGhpY2t7c3Ryb2tlLXdpZHRoOjMuNXB4O30jbWVybWFpZElua1N2ZyAuZWRnZS1wYXR0ZXJuLXNvbGlke3N0cm9rZS1kYXNoYXJyYXk6MDt9I21lcm1haWRJbmtTdmcgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNtZXJtYWlkSW5rU3ZnIC5lZGdlLXBhdHRlcm4tZGFzaGVke3N0cm9rZS1kYXNoYXJyYXk6Mzt9I21lcm1haWRJbmtTdmcgLmVkZ2UtcGF0dGVybi1kb3R0ZWR7c3Ryb2tlLWRhc2hhcnJheToyO30jbWVybWFpZElua1N2ZyAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I21lcm1haWRJbmtTdmcgLm1hcmtlci5jcm9zc3tzdHJva2U6IzMzMzMzMzt9I21lcm1haWRJbmtTdmcgc3Zne2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDt9I21lcm1haWRJbmtTdmcgcHttYXJnaW46MDt9I21lcm1haWRJbmtTdmcgLmxhYmVse2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtjb2xvcjojMzMzO30jbWVybWFpZElua1N2ZyAuY2x1c3Rlci1sYWJlbCB0ZXh0e2ZpbGw6IzMzMzt9I21lcm1haWRJbmtTdmcgLmNsdXN0ZXItbGFiZWwgc3Bhbntjb2xvcjojMzMzO30jbWVybWFpZElua1N2ZyAuY2x1c3Rlci1sYWJlbCBzcGFuIHB7YmFja2dyb3VuZC1jb2xvcjp0cmFuc3BhcmVudDt9I21lcm1haWRJbmtTdmcgLmxhYmVsIHRleHQsI21lcm1haWRJbmtTdmcgc3BhbntmaWxsOiMzMzM7Y29sb3I6IzMzMzt9I21lcm1haWRJbmtTdmcgLm5vZGUgcmVjdCwjbWVybWFpZElua1N2ZyAubm9kZSBjaXJjbGUsI21lcm1haWRJbmtTdmcgLm5vZGUgZWxsaXBzZSwjbWVybWFpZElua1N2ZyAubm9kZSBwb2x5Z29uLCNtZXJtYWlkSW5rU3ZnIC5ub2RlIHBhdGh7ZmlsbDojRUNFQ0ZGO3N0cm9rZTojOTM3MERCO3N0cm9rZS13aWR0aDoxcHg7fSNtZXJtYWlkSW5rU3ZnIC5yb3VnaC1ub2RlIC5sYWJlbCB0ZXh0LCNtZXJtYWlkSW5rU3ZnIC5ub2RlIC5sYWJlbCB0ZXh0LCNtZXJtYWlkSW5rU3ZnIC5pbWFnZS1zaGFwZSAubGFiZWwsI21lcm1haWRJbmtTdmcgLmljb24tc2hhcGUgLmxhYmVse3RleHQtYW5jaG9yOm1pZGRsZTt9I21lcm1haWRJbmtTdmcgLm5vZGUgLmthdGV4IHBhdGh7ZmlsbDojMDAwO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoxcHg7fSNtZXJtYWlkSW5rU3ZnIC5yb3VnaC1ub2RlIC5sYWJlbCwjbWVybWFpZElua1N2ZyAubm9kZSAubGFiZWwsI21lcm1haWRJbmtTdmcgLmltYWdlLXNoYXBlIC5sYWJlbCwjbWVybWFpZElua1N2ZyAuaWNvbi1zaGFwZSAubGFiZWx7dGV4dC1hbGlnbjpjZW50ZXI7fSNtZXJtYWlkSW5rU3ZnIC5ub2RlLmNsaWNrYWJsZXtjdXJzb3I6cG9pbnRlcjt9I21lcm1haWRJbmtTdmcgLnJvb3QgLmFuY2hvciBwYXRoe2ZpbGw6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlLXdpZHRoOjA7c3Ryb2tlOiMzMzMzMzM7fSNtZXJtYWlkSW5rU3ZnIC5hcnJvd2hlYWRQYXRoe2ZpbGw6IzMzMzMzMzt9I21lcm1haWRJbmtTdmcgLmVkZ2VQYXRoIC5wYXRoe3N0cm9rZTojMzMzMzMzO3N0cm9rZS13aWR0aDoyLjBweDt9I21lcm1haWRJbmtTdmcgLmZsb3djaGFydC1saW5re3N0cm9rZTojMzMzMzMzO2ZpbGw6bm9uZTt9I21lcm1haWRJbmtTdmcgLmVkZ2VMYWJlbHtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7dGV4dC1hbGlnbjpjZW50ZXI7fSNtZXJtYWlkSW5rU3ZnIC5lZGdlTGFiZWwgcHtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7fSNtZXJtYWlkSW5rU3ZnIC5lZGdlTGFiZWwgcmVjdHtvcGFjaXR5OjAuNTtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7ZmlsbDpyZ2JhKDIzMiwyMzIsMjMyLCAwLjgpO30jbWVybWFpZElua1N2ZyAubGFiZWxCa2d7YmFja2dyb3VuZC1jb2xvcjpyZ2JhKDIzMiwgMjMyLCAyMzIsIDAuNSk7fSNtZXJtYWlkSW5rU3ZnIC5jbHVzdGVyIHJlY3R7ZmlsbDojZmZmZmRlO3N0cm9rZTojYWFhYTMzO3N0cm9rZS13aWR0aDoxcHg7fSNtZXJtYWlkSW5rU3ZnIC5jbHVzdGVyIHRleHR7ZmlsbDojMzMzO30jbWVybWFpZElua1N2ZyAuY2x1c3RlciBzcGFue2NvbG9yOiMzMzM7fSNtZXJtYWlkSW5rU3ZnIGRpdi5tZXJtYWlkVG9vbHRpcHtwb3NpdGlvbjphYnNvbHV0ZTt0ZXh0LWFsaWduOmNlbnRlcjttYXgtd2lkdGg6MjAwcHg7cGFkZGluZzoycHg7Zm9udC1mYW1pbHk6InRyZWJ1Y2hldCBtcyIsdmVyZGFuYSxhcmlhbCxzYW5zLXNlcmlmO2ZvbnQtc2l6ZToxMnB4O2JhY2tncm91bmQ6aHNsKDgwLCAxMDAlLCA5Ni4yNzQ1MDk4MDM5JSk7Ym9yZGVyOjFweCBzb2xpZCAjYWFhYTMzO2JvcmRlci1yYWRpdXM6MnB4O3BvaW50ZXItZXZlbnRzOm5vbmU7ei1pbmRleDoxMDA7fSNtZXJtYWlkSW5rU3ZnIC5mbG93Y2hhcnRUaXRsZVRleHR7dGV4dC1hbmNob3I6bWlkZGxlO2ZvbnQtc2l6ZToxOHB4O2ZpbGw6IzMzMzt9I21lcm1haWRJbmtTdmcgcmVjdC50ZXh0e2ZpbGw6bm9uZTtzdHJva2Utd2lkdGg6MDt9I21lcm1haWRJbmtTdmcgLmljb24tc2hhcGUsI21lcm1haWRJbmtTdmcgLmltYWdlLXNoYXBle2JhY2tncm91bmQtY29sb3I6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTt0ZXh0LWFsaWduOmNlbnRlcjt9I21lcm1haWRJbmtTdmcgLmljb24tc2hhcGUgcCwjbWVybWFpZElua1N2ZyAuaW1hZ2Utc2hhcGUgcHtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7cGFkZGluZzoycHg7fSNtZXJtYWlkSW5rU3ZnIC5pY29uLXNoYXBlIHJlY3QsI21lcm1haWRJbmtTdmcgLmltYWdlLXNoYXBlIHJlY3R7b3BhY2l0eTowLjU7YmFja2dyb3VuZC1jb2xvcjpyZ2JhKDIzMiwyMzIsMjMyLCAwLjgpO2ZpbGw6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTt9I21lcm1haWRJbmtTdmcgLmxhYmVsLWljb257ZGlzcGxheTppbmxpbmUtYmxvY2s7aGVpZ2h0OjFlbTtvdmVyZmxvdzp2aXNpYmxlO3ZlcnRpY2FsLWFsaWduOi0wLjEyNWVtO30jbWVybWFpZElua1N2ZyAubm9kZSAubGFiZWwtaWNvbiBwYXRoe2ZpbGw6Y3VycmVudENvbG9yO3N0cm9rZTpyZXZlcnQ7c3Ryb2tlLXdpZHRoOnJldmVydDt9I21lcm1haWRJbmtTdmcgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48bWFya2VyIGlkPSJtZXJtYWlkSW5rU3ZnX2Zsb3djaGFydC12Mi1wb2ludEVuZCIgY2xhc3M9Im1hcmtlciBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iNSIgcmVmWT0iNSIgbWFya2VyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBtYXJrZXJXaWR0aD0iOCIgbWFya2VySGVpZ2h0PSI4IiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMCAwIEwgMTAgNSBMIDAgMTAgeiIgY2xhc3M9ImFycm93TWFya2VyUGF0aCIgc3R5bGU9InN0cm9rZS13aWR0aDogMTsgc3Ryb2tlLWRhc2hhcnJheTogMSwgMDsiLz48L21hcmtlcj48bWFya2VyIGlkPSJtZXJtYWlkSW5rU3ZnX2Zsb3djaGFydC12Mi1wb2ludFN0YXJ0IiBjbGFzcz0ibWFya2VyIGZsb3djaGFydC12MiIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSI0LjUiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjgiIG1hcmtlckhlaWdodD0iOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDAgNSBMIDEwIDEwIEwgMTAgMCB6IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyIvPjwvbWFya2VyPjxtYXJrZXIgaWQ9Im1lcm1haWRJbmtTdmdfZmxvd2NoYXJ0LXYyLWNpcmNsZUVuZCIgY2xhc3M9Im1hcmtlciBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iMTEiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgY3g9IjUiIGN5PSI1IiByPSI1IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyIvPjwvbWFya2VyPjxtYXJrZXIgaWQ9Im1lcm1haWRJbmtTdmdfZmxvd2NoYXJ0LXYyLWNpcmNsZVN0YXJ0IiBjbGFzcz0ibWFya2VyIGZsb3djaGFydC12MiIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSItMSIgcmVmWT0iNSIgbWFya2VyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBtYXJrZXJXaWR0aD0iMTEiIG1hcmtlckhlaWdodD0iMTEiIG9yaWVudD0iYXV0byI+PGNpcmNsZSBjeD0iNSIgY3k9IjUiIHI9IjUiIGNsYXNzPSJhcnJvd01hcmtlclBhdGgiIHN0eWxlPSJzdHJva2Utd2lkdGg6IDE7IHN0cm9rZS1kYXNoYXJyYXk6IDEsIDA7Ii8+PC9tYXJrZXI+PG1hcmtlciBpZD0ibWVybWFpZElua1N2Z19mbG93Y2hhcnQtdjItY3Jvc3NFbmQiIGNsYXNzPSJtYXJrZXIgY3Jvc3MgZmxvd2NoYXJ0LXYyIiB2aWV3Qm94PSIwIDAgMTEgMTEiIHJlZlg9IjEyIiByZWZZPSI1LjIiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMSwxIGwgOSw5IE0gMTAsMSBsIC05LDkiIGNsYXNzPSJhcnJvd01hcmtlclBhdGgiIHN0eWxlPSJzdHJva2Utd2lkdGg6IDI7IHN0cm9rZS1kYXNoYXJyYXk6IDEsIDA7Ii8+PC9tYXJrZXI+PG1hcmtlciBpZD0ibWVybWFpZElua1N2Z19mbG93Y2hhcnQtdjItY3Jvc3NTdGFydCIgY2xhc3M9Im1hcmtlciBjcm9zcyBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMSAxMSIgcmVmWD0iLTEiIHJlZlk9IjUuMiIgbWFya2VyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBtYXJrZXJXaWR0aD0iMTEiIG1hcmtlckhlaWdodD0iMTEiIG9yaWVudD0iYXV0byI+PHBhdGggZD0iTSAxLDEgbCA5LDkgTSAxMCwxIGwgLTksOSIgY2xhc3M9ImFycm93TWFya2VyUGF0aCIgc3R5bGU9InN0cm9rZS13aWR0aDogMjsgc3Ryb2tlLWRhc2hhcnJheTogMSwgMDsiLz48L21hcmtlcj48ZyBjbGFzcz0icm9vdCI+PGcgY2xhc3M9ImNsdXN0ZXJzIi8+PGcgY2xhc3M9ImVkZ2VQYXRocyI+PHBhdGggZD0iTTIyMS44NTIsNjJMMjIxLjg1Miw2OC4xNjdDMjIxLjg1Miw3NC4zMzMsMjIxLjg1Miw4Ni42NjcsMjIxLjkyNiw5OC40MTdDMjIyLDExMC4xNjcsMjIyLjE0OSwxMjEuMzM0LDIyMi4yMjQsMTI2LjkxN0wyMjIuMjk4LDEzMi41IiBpZD0iTF9BX0JfMCIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBmbG93Y2hhcnQtbGluayIgc3R5bGU9IjsiIGRhdGEtZWRnZT0idHJ1ZSIgZGF0YS1ldD0iZWRnZSIgZGF0YS1pZD0iTF9BX0JfMCIgZGF0YS1wb2ludHM9Ilczc2llQ0k2TWpJeExqZzFNVFUyTWpVc0lua2lPall5ZlN4N0luZ2lPakl5TVM0NE5URTFOakkxTENKNUlqbzVPWDBzZXlKNElqb3lNakl1TXpVeE5UWXlOU3dpZVNJNk1UTTJMalY5WFE9PSIgbWFya2VyLWVuZD0idXJsKCNtZXJtYWlkSW5rU3ZnX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiLz48cGF0aCBkPSJNMjIyLjM1MiwxOTAuNUwyMjIuMjY4LDE5NC41ODNDMjIyLjE4NSwxOTguNjY3LDIyMi4wMTgsMjA2LjgzMywyMjEuOTM1LDIxNC40MTdDMjIxLjg1MiwyMjIsMjIxLjg1MiwyMjksMjIxLjg1MiwyMzIuNUwyMjEuODUyLDIzNiIgaWQ9IkxfQl9DXzAiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZmxvd2NoYXJ0LWxpbmsiIHN0eWxlPSI7IiBkYXRhLWVkZ2U9InRydWUiIGRhdGEtZXQ9ImVkZ2UiIGRhdGEtaWQ9IkxfQl9DXzAiIGRhdGEtcG9pbnRzPSJXM3NpZUNJNk1qSXlMak0xTVRVMk1qVXNJbmtpT2pFNU1DNDFmU3g3SW5naU9qSXlNUzQ0TlRFMU5qSTFMQ0o1SWpveU1UVjlMSHNpZUNJNk1qSXhMamcxTVRVMk1qVXNJbmtpT2pJME1IMWQiIG1hcmtlci1lbmQ9InVybCgjbWVybWFpZElua1N2Z19mbG93Y2hhcnQtdjItcG9pbnRFbmQpIi8+PHBhdGggZD0iTTE3OS43MTUsMzM5LjAxOUwxNjAuMTc0LDM1Mi4yMDlDMTQwLjYzMywzNjUuMzk4LDEwMS41NTEsMzkxLjc3Nyw4Mi4wMSw0MTAuNDY3QzYyLjQ2OSw0MjkuMTU2LDYyLjQ2OSw0NDAuMTU2LDYyLjQ2OSw0NDUuNjU2TDYyLjQ2OSw0NTEuMTU2IiBpZD0iTF9DX0RfMCIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBmbG93Y2hhcnQtbGluayIgc3R5bGU9IjsiIGRhdGEtZWRnZT0idHJ1ZSIgZGF0YS1ldD0iZWRnZSIgZGF0YS1pZD0iTF9DX0RfMCIgZGF0YS1wb2ludHM9Ilczc2llQ0k2TVRjNUxqY3hORFV6TmpFeU9EazFORE0yTENKNUlqb3pNemt1TURFNU1qSXpOakk0T1RVME16WjlMSHNpZUNJNk5qSXVORFk0TnpVc0lua2lPalF4T0M0eE5UWXlOWDBzZXlKNElqbzJNaTQwTmpnM05Td2llU0k2TkRVMUxqRTFOakkxZlYwPSIgbWFya2VyLWVuZD0idXJsKCNtZXJtYWlkSW5rU3ZnX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiLz48cGF0aCBkPSJNMjIxLjg1MiwzODEuMTU2TDIyMS44NTIsMzg3LjMyM0MyMjEuODUyLDM5My40OSwyMjEuODUyLDQwNS44MjMsMjIxLjg1Miw0MTcuNDlDMjIxLjg1Miw0MjkuMTU2LDIyMS44NTIsNDQwLjE1NiwyMjEuODUyLDQ0NS42NTZMMjIxLjg1Miw0NTEuMTU2IiBpZD0iTF9DX0VfMCIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBmbG93Y2hhcnQtbGluayIgc3R5bGU9IjsiIGRhdGEtZWRnZT0idHJ1ZSIgZGF0YS1ldD0iZWRnZSIgZGF0YS1pZD0iTF9DX0VfMCIgZGF0YS1wb2ludHM9Ilczc2llQ0k2TWpJeExqZzFNVFUyTWpVc0lua2lPak00TVM0eE5UWXlOWDBzZXlKNElqb3lNakV1T0RVeE5UWXlOU3dpZVNJNk5ERTRMakUxTmpJMWZTeDdJbmdpT2pJeU1TNDROVEUxTmpJMUxDSjVJam8wTlRVdU1UVTJNalY5WFE9PSIgbWFya2VyLWVuZD0idXJsKCNtZXJtYWlkSW5rU3ZnX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiLz48cGF0aCBkPSJNMjYzLjg0NCwzMzkuMTY0TDI4My4xODQsMzUyLjMyOUMzMDIuNTI0LDM2NS40OTUsMzQxLjIwMywzOTEuODI1LDM2MC41NDMsNDEwLjQ5MUMzNzkuODgzLDQyOS4xNTYsMzc5Ljg4Myw0NDAuMTU2LDM3OS44ODMsNDQ1LjY1NkwzNzkuODgzLDQ1MS4xNTYiIGlkPSJMX0NfRl8wIiBjbGFzcz0iZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGZsb3djaGFydC1saW5rIiBzdHlsZT0iOyIgZGF0YS1lZGdlPSJ0cnVlIiBkYXRhLWV0PSJlZGdlIiBkYXRhLWlkPSJMX0NfRl8wIiBkYXRhLXBvaW50cz0iVzNzaWVDSTZNall6TGpnME16ZzJOVE0xTmpBMU1ETTBMQ0o1SWpvek16a3VNVFl6T1RRM01UUXpPVFE1TmpaOUxIc2llQ0k2TXpjNUxqZzRNamd4TWpVc0lua2lPalF4T0M0eE5UWXlOWDBzZXlKNElqb3pOemt1T0RneU9ERXlOU3dpZVNJNk5EVTFMakUxTmpJMWZWMD0iIG1hcmtlci1lbmQ9InVybCgjbWVybWFpZElua1N2Z19mbG93Y2hhcnQtdjItcG9pbnRFbmQpIi8+PC9nPjxnIGNsYXNzPSJlZGdlTGFiZWxzIj48ZyBjbGFzcz0iZWRnZUxhYmVsIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjEuODUxNTYyNSwgOTkpIj48ZyBjbGFzcz0ibGFiZWwiIGRhdGEtaWQ9IkxfQV9CXzAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zOS4xMzI4MTI1LCAtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNzguMjY1NjI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBjbGFzcz0ibGFiZWxCa2ciIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0iZWRnZUxhYmVsIj48cD5HZXQgbW9uZXk8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJlZGdlTGFiZWwiPjxnIGNsYXNzPSJsYWJlbCIgZGF0YS1pZD0iTF9CX0NfMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMCkiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIwIiBoZWlnaHQ9IjAiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIGNsYXNzPSJsYWJlbEJrZyIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJlZGdlTGFiZWwiPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0iZWRnZUxhYmVsIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2Mi40Njg3NSwgNDE4LjE1NjI1KSI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJMX0NfRF8wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTUuMTI1LCAtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iMzAuMjUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIGNsYXNzPSJsYWJlbEJrZyIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJlZGdlTGFiZWwiPjxwPk9uZTwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9ImVkZ2VMYWJlbCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjIxLjg1MTU2MjUsIDQxOC4xNTYyNSkiPjxnIGNsYXNzPSJsYWJlbCIgZGF0YS1pZD0iTF9DX0VfMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE0LjY3MTg3NSwgLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjI5LjM0Mzc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBjbGFzcz0ibGFiZWxCa2ciIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0iZWRnZUxhYmVsIj48cD5Ud288L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJlZGdlTGFiZWwiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDM3OS44ODI4MTI1LCA0MTguMTU2MjUpIj48ZyBjbGFzcz0ibGFiZWwiIGRhdGEtaWQ9IkxfQ19GXzAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMC44OTg0Mzc1LCAtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNDEuNzk2ODc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBjbGFzcz0ibGFiZWxCa2ciIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0iZWRnZUxhYmVsIj48cD5UaHJlZTwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PC9nPjxnIGNsYXNzPSJub2RlcyI+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImZsb3djaGFydC1BLTAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyMS44NTE1NjI1LCAzNSkiPjxyZWN0IGNsYXNzPSJiYXNpYyBsYWJlbC1jb250YWluZXIiIHN0eWxlPSIiIHg9Ii02Ni4wMDc4MTI1IiB5PSItMjciIHdpZHRoPSIxMzIuMDE1NjI1IiBoZWlnaHQ9IjU0Ii8+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzYuMDA3ODEyNSwgLTEyKSI+PHJlY3QvPjxmb3JlaWduT2JqZWN0IHdpZHRoPSI3Mi4wMTU2MjUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD5DaHJpc3RtYXM8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJmbG93Y2hhcnQtQi0xIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjEuODUxNTYyNSwgMTYzKSI+PGcgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciBvdXRlci1wYXRoIj48cGF0aCBkPSJNLTU1LjM2NzE4NzUgLTI3IEMtMjYuMDY1Mjk0MzIwMjQwNjMyIC0yNywgMy4yMzY1OTg4NTk1MTg3MzYgLTI3LCA1NS4zNjcxODc1IC0yNyBDNTUuMzY3MTg3NSAtMjcsIDU1LjM2NzE4NzUgLTI3LCA1NS4zNjcxODc1IC0yNyBDNTUuNTI3NDU4NTE3MTQzNjYgLTI2Ljk5MzM3MTE0MTY5NzEsIDU1LjY4NzcyOTUzNDI4NzMxNCAtMjYuOTg2NzQyMjgzMzk0MiwgNTUuNzgwMDg0MjI3MzYxNjYgLTI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTUuODkxMzQ1NjE0NTE2ODIgLTI2Ljk2OTA1Mzc0NzU4NzY3NiwgNTYuMDAyNjA3MDAxNjcxOTggLTI2Ljk1NTE4NTAzMDE0MjAwNSwgNTYuMTkwMTYwNDUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBDNTYuMzE5NTkzOTgwMDgxMjE2IC0yNi45MDQ2NjcxNjAwMDE0NTYsIDU2LjQ0OTAyNzUwODc1ODc2IC0yNi44Nzc1Mjc4MDI5ODkzLCA1Ni41OTQ2MTQ5MzU3MDM5OTQgLTI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuNzQwOTk1NDAxMzg2MzE0IC0yNi44MDM0MjE5NzY5MDY3MTYsIDU2Ljg4NzM3NTg2NzA2ODY0IC0yNi43NTk4NDI2MjQxMTY3OCwgNTYuOTkwNjg0ODQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBDNTcuMTEyMjk4NDc0NzQ4ODA1IC0yNi42ODE2MzI0MjMzMjAwOCwgNTcuMjMzOTEyMTAzNDc0MTkgLTI2LjYzNDE3ODYzODEzNjk4NywgNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQzU3LjQ2MTAwMTkxNDY0NzY5NiAtMjYuNTM3MTQ3NzczNjMzNjk1LCA1Ny41NDYzMzkyMDYwMzA1NSAtMjYuNDk1NDI4OTEzOTkyMTA0LCA1Ny43NDY5MjQ0NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBDNTcuODI3MDU1Nzc2NTMzNzMgLTI2LjM0OTYyMDg1ODQzNTg4OCwgNTcuOTA3MTg3MDg3ODgyMSAtMjYuMzAxODcyOTYwODM5MzI2LCA1OC4xMDE5MjgyOTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDNTguMTgwMzI0ODY5NzgwOTIgLTI2LjEyOTg1ODMwOTY0OTY1MiwgNTguMjU4NzIxNDQ4OTQ5NyAtMjYuMDczODg0MjI3OTg2NjYsIDU4LjQzODI1MTA2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NyBDNTguNTM1NzA1MzU2OTYyNyAtMjUuODYzMTYyOTcwNzQyMDk2LCA1OC42MzMxNTk2NTA0NzcwNyAtMjUuNzgwNjIzMzk0NTAyMjI1LCA1OC43NTM1OTUzNTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBDNTguODYzMzIyODE4MzY4NTk0IC0yNS41Njg4OTIwOTMxMjU3NywgNTguOTczMDUwMjc4NjA4NDggLTI1LjQ1OTE2NDYzMjg4NTg3NywgNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBDNTkuMTA2Njk3ODUwNDczMzUgLTI1LjMxNDUxNDIyOTgzNTYzOCwgNTkuMTY3NTg4NjQ3NTgxMDUgLTI1LjI0MjYyMDYwMTU0MjU2NiwgNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IEM1OS4zNjUxNDAxOTEyNTc5MTUgLTI0Ljk5Nzg4MjY4MzEwODU0NiwgNTkuNDE3MzkwMzM1NTMzODUgLTI0LjkyNDcwMTgwMjc2ODc1NSwgNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgQzU5LjYxOTI0MDc1ODU2Mzk0IC0yNC42MjM2MDc4MzYxMDM2OSwgNTkuNjg1NDYxNjI1ODE1MjM2IC0yNC41MTI0NzQ4ODE1OTUyNSwgNTkuNzY0NTU2MjU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM3IEM1OS44MTU3NjcyMzk5NzU5NSAtMjQuMjc0OTgzMjIzMjAwNzI4LCA1OS44NjY5NzgyMjM5MTk0NCAtMjQuMTcwMjI5NDgxMjE2MDg2LCA1OS45NDYwNTQxMzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEM1OS45OTg4NTkxODcyNjUzNDYgLTIzLjg3MzE0OTM2NzkzNDE5NywgNjAuMDUxNjY0MjQxMjU1NCAtMjMuNzM3ODIxNjEyNjAzNTQ3LCA2MC4wOTYyNzM3MDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBDNjAuMTM0OTI0MjY5NzY5MjM2IC0yMy40OTM2NzIzOTkyMzU0MiwgNjAuMTczNTc0ODMxMDM1MjkgLTIzLjM2Mzg0NzQ1MjQ0NzQzLCA2MC4yMTQxODg4Mjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IEM2MC4yMzQ1MzcxMTQ3NDM5NiAtMjMuMTMwMzgyMDE0MzYxNDg0LCA2MC4yNTQ4ODUzOTk3OTEyNyAtMjMuMDMzMzM2NTkzMDE4OTcsIDYwLjI5ODk5NDAxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBDNjAuMzEzNTQyMjIzMTI3MDk0IC0yMi43MDYyNjAzODU5Mzc4NDYsIDYwLjMyODA5MDQyOTI0MDU3NSAtMjIuNTg5NTQ3ODIwNDcyMDIsIDYwLjM1MDEwOTk2NTAzMzM1IC0yMi40MTI4OTY3MjczNjE2NjIgQzYwLjM1NTgwNzMzMzM0MzE3IC0yMi4yNzUxNDcwNjE4MzM1MzYsIDYwLjM2MTUwNDcwMTY1MyAtMjIuMTM3Mzk3Mzk2MzA1NDA2LCA2MC4zNjcxODc1IC0yMiBDNjAuMzY3MTg3NSAtMjIsIDYwLjM2NzE4NzUgLTIyLCA2MC4zNjcxODc1IC0yMiBDNjAuMzY3MTg3NSAtNi42NDk0ODg2MDMwNTIwNjcsIDYwLjM2NzE4NzUgOC43MDEwMjI3OTM4OTU4NjYsIDYwLjM2NzE4NzUgMjIgQzYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIgQzYwLjM2MjUyMjUzMzM0NzkzIDIyLjExMjc4ODQ5NDgwNjM2NywgNjAuMzU3ODU3NTY2Njk1ODU2IDIyLjIyNTU3Njk4OTYxMjczMywgNjAuMzUwMTA5OTY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIEM2MC4zMzY1MzQ4NDg4NDM0MTQgMjIuNTIxODAyNzA2ODgwMDI0LCA2MC4zMjI5NTk3MzI2NTM0OCAyMi42MzA3MDg2ODYzOTgzODYsIDYwLjI5ODk5NDAxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEM2MC4yNzQyOTgzNzYzMjY0MSAyMi45NDA3NTE4NjI1MTI5MDQsIDYwLjI0OTYwMjczNTYzOTIyIDIzLjA1ODUzMDc3MzYyMjE0LCA2MC4yMTQxODg4Mjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQzYwLjE3OTUwMDUyMzQ4NjE0IDIzLjM0Mzk0MzQwMjM2NjAzNywgNjAuMTQ0ODEyMjE3Mjc1NjMgMjMuNDYwNDU5MzY5MDI4MDc2LCA2MC4wOTYyNzM3MDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IEM2MC4wNjQ2MTI5OTA1NTA2NCAyMy43MDQ2MzY4MTIyMDkzNTUsIDYwLjAzMjk1MjI3MjU5ODExNiAyMy43ODU3NzYyNzgzOTUyOTMsIDU5Ljk0NjA1NDEzMzI3NTI5IDI0LjAwODQ3NzEyMzI2NDg0NiBDNTkuODkzOTA4ODA3NDAzMDQgMjQuMTE1MTQyMDkyMjEyODAzLCA1OS44NDE3NjM0ODE1MzA3NzYgMjQuMjIxODA3MDYxMTYwNzY0LCA1OS43NjQ1NTYyNTYwMzI0NSAyNC4zNzk3MzY5NjUxODUzNjYgQzU5LjY5NDUxMjQ5Njg0OTQ5IDI0LjQ5NzI4NTU2MDE1MjI3OCwgNTkuNjI0NDY4NzM3NjY2NTMgMjQuNjE0ODM0MTU1MTE5MTksIDU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgQzU5LjQ4MjUwNTgwODI3MjU1NiAyNC44MzM1MDE5MDg3ODE5NjUsIDU5LjQxMTk5MTcyNTIzMjQ2IDI0LjkzMjI2MzAyNjk1MTgsIDU5LjMxMjg5MDA0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IEM1OS4yMjQwNjg5NTQxNCAyNS4xNzU5MzQ0Mjk2MDAyNiwgNTkuMTM1MjQ3ODYxMjk4MDI2IDI1LjI4MDgwNTI5NTc1MjE4LCA1OS4wNDU4MDcwNTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQzU4Ljk0MTgzMTU4ODIzMjAxIDI1LjQ5MDM4MzMyMzI2MjM1LCA1OC44Mzc4NTYxMjMwOTgzNyAyNS41OTQzNTg3ODgzOTU5OTQsIDU4Ljc1MzU5NTM1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NTcgQzU4LjY3MjY5NTcxMDc4NTMgMjUuNzQ3MTM4MDU5NTE3NDI1LCA1OC41OTE3OTYwNjM0NDE4OSAyNS44MTU2NTY1NjU2NjkxOSwgNTguNDM4MjUxMDYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgQzU4LjMzNDg0MzY4NjgyNjQ1IDI2LjAxOTUzMzk5NDc5NDYwMywgNTguMjMxNDM2MzEwMjA0NTY1IDI2LjA5MzM2NTQ0MjYwNzI0LCA1OC4xMDE5MjgyOTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IEM1OC4wMTQwNDc1MzI0OTY5NzQgMjYuMjM4MTk3OTU2ODg5NzMsIDU3LjkyNjE2Njc3NDM4MTgxIDI2LjI5MDU2MzUyMjQ2NjgxNSwgNTcuNzQ2OTI0NDY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBDNTcuNjUzMTQ5MzQwNzQ3NDA0IDI2LjQ0MzIxMjYyMDg5OTc4MiwgNTcuNTU5Mzc0MjE2MzA5NDUgMjYuNDg5MDU2NDg1NzY3MTE1LCA1Ny4zNzU2NjQ2MjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IEM1Ny4yODc4NjgyMjQ3Mzg3MSAyNi42MTMxMjQ4OTQyODc0MywgNTcuMjAwMDcxODI2MjEyNTcgMjYuNjQ3MzgzMTU1Mjk5NTgsIDU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDNTYuODYxMTUxMTkyMjAxMDE0IDI2Ljc2NzY1MDA0ODA5NTk5OCwgNTYuNzMxNjE3NTM4Mzc4NjE1IDI2LjgwNjIxMzg4NzY4ODgyMywgNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuNTA1NzcwMjgzMjAxMzY1IDI2Ljg2NTYzMDA5NDM0MTUzLCA1Ni40MTY5MjU2MzA2OTg3MzUgMjYuODg0MjU4ODU4OTg2NDA2LCA1Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQzU2LjA3NzExMTk1NjE5OTggMjYuOTQ1ODk3OTk3MjQ1MjQzLCA1NS45NjQwNjM0NjA5OTU5MiAyNi45NTk5ODk0Nzc0NzY4NzgsIDU1Ljc4MDA4NDIyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTUuNjgzOTkwODYyNzI0NzA2IDI2Ljk4Njg5NjkxNTk5Mzg1LCA1NS41ODc4OTc0OTgwODc3NSAyNi45OTA4NzEzNjY5NTQzNSwgNTUuMzY3MTg3NSAyNyBDNTUuMzY3MTg3NSAyNywgNTUuMzY3MTg3NSAyNywgNTUuMzY3MTg3NSAyNyBDMjUuNjQ2MTg2Mzc2MDY1NzkgMjcsIC00LjA3NDgxNDc0Nzg2ODQyMyAyNywgLTU1LjM2NzE4NzUgMjcgQy01NS4zNjcxODc1IDI3LCAtNTUuMzY3MTg3NSAyNywgLTU1LjM2NzE4NzUgMjcgQy01NS40ODI5NTI2OTQwMzAwMiAyNi45OTUyMTE5MTYxNTczMzgsIC01NS41OTg3MTc4ODgwNjAwNDQgMjYuOTkwNDIzODMyMzE0NjcyLCAtNTUuNzgwMDg0MjI3MzYxNjYgMjYuOTgyOTIyNDY1MDMzMzQ3IEMtNTUuOTExODU3MzEyMjEzNzYgMjYuOTY2NDk2OTY3MTM2MTMsIC01Ni4wNDM2MzAzOTcwNjU4NiAyNi45NTAwNzE0NjkyMzg5MTcsIC01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4zNDY1MzAzMDI5MTMzNDQgMjYuODk5MDE5MjA3MDM3NDUsIC01Ni41MDI5MDAxNTQ0MjMwMSAyNi44NjYyMzE4OTcwNjEyOTQsIC01Ni41OTQ2MTQ5MzU3MDM5OTQgMjYuODQ3MDAxMzI5Njk2NjUzIEMtNTYuNjc3NTUyNjk3NDYzNDkgMjYuODIyMzA5Njg4MjA5Njc1LCAtNTYuNzYwNDkwNDU5MjIyOTkgMjYuNzk3NjE4MDQ2NzIyNjkzLCAtNTYuOTkwNjg0ODQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIEMtNTcuMTA1MDIwMjYwNDY5MSAyNi42ODQ0NzIzOTEzNDQ3NjIsIC01Ny4yMTkzNTU2NzQ5MTQ3NyAyNi42Mzk4NTg1NzQxODYzNTYsIC01Ny4zNzU2NjQ2MjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IEMtNTcuNDg3NTAzNDYyNjE4NTQgMjYuNTI0MTkxOTU2Mjg2MjcsIC01Ny41OTkzNDIzMDE5NzIyMyAyNi40Njk1MTcyNzkyOTcyNSwgLTU3Ljc0NjkyNDQ2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgQy01Ny44ODg1MTU4NzU5MTI1MyAyNi4zMTI5OTg1ODgyODkxMzcsIC01OC4wMzAxMDcyODY2Mzk3IDI2LjIyODYyODQyMDU0NTgyNCwgLTU4LjEwMTkyODI5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgQy01OC4xODcwOTIyODg5MTA0MiAyNi4xMjUwMjY0NjUyNjI4MzUsIC01OC4yNzIyNTYyODcyMDg3MSAyNi4wNjQyMjA1MzkyMTMwMiwgLTU4LjQzODI1MTA2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IEMtNTguNTMyNzg4OTAyMzM1NzMgMjUuODY1NjMzMDgxODEwMDkyLCAtNTguNjI3MzI2NzQxMjIzMTE1IDI1Ljc4NTU2MzYxNjYzODIxNCwgLTU4Ljc1MzU5NTM1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NiBDLTU4LjgzOTAxNTE3ODQxNzEyIDI1LjU5MzE5OTczMzA3NzI0MiwgLTU4LjkyNDQzNDk5ODcwNTUzNiAyNS41MDc3Nzk5MTI3ODg4MjcsIC01OS4wNDU4MDcwNTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQy01OS4xMTIzNjYyNzI3NjY0OCAyNS4zMDc4MjE1MzYyODA4MiwgLTU5LjE3ODkyNTQ5MjE2NzMxIDI1LjIyOTIzNTIxNDQzMjkzLCAtNTkuMzEyODkwMDQ2OTgxOTcgMjUuMDcxMDYzNTYzNDQ4MzQgQy01OS4zOTQwNjMxNzM1MjIwOSAyNC45NTczNzM1MjU5ODYwOTIsIC01OS40NzUyMzYzMDAwNjIyMTQgMjQuODQzNjgzNDg4NTIzODQ0LCAtNTkuNTUzMDE5ODkxMzEyNjQ0IDI0LjczNDc0MDc5MDYxMjEzMyBDLTU5LjYwNjgwNjk3NTc1MDA3IDI0LjY0NDQ3NDQxNjAzODA1LCAtNTkuNjYwNTk0MDYwMTg3NDg1IDI0LjU1NDIwODA0MTQ2Mzk3LCAtNTkuNzY0NTU2MjU2MDMyNDQgMjQuMzc5NzM2OTY1MTg1MzcgQy01OS44MjA2MDkxNTU3NjY5NSAyNC4yNjUwNzg5MjYxNDUxNzYsIC01OS44NzY2NjIwNTU1MDE0NyAyNC4xNTA0MjA4ODcxMDQ5ODIsIC01OS45NDYwNTQxMzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBDLTU5Ljk3NzQ3MjU1ODYzOCAyMy45Mjc5NTg1OTk3Njk3MTcsIC02MC4wMDg4OTA5ODQwMDA3MiAyMy44NDc0NDAwNzYyNzQ1ODQsIC02MC4wOTYyNzM3MDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMTMwMjQ0OTA1NjIxOTUgMjMuNTA5MzkwMTA2MzQwNCwgLTYwLjE2NDIxNjEwMjc0MDcyNCAyMy4zOTUyODI4NjY2NTczODIsIC02MC4yMTQxODg4Mjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQy02MC4yMzc0MTA1NTU4MDk5MyAyMy4xMTY2Nzc5NDU1MjcxLCAtNjAuMjYwNjMyMjgxOTIzMjEgMjMuMDA1OTI4NDU1MzUwMjA4LCAtNjAuMjk4OTk0MDE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgQy02MC4zMTEyODU5OTM2NDg4NSAyMi43MjQzNjA5MjIwODg1NTcsIC02MC4zMjM1Nzc5NzAyODQwOSAyMi42MjU3NDg4OTI3NzM0NDQsIC02MC4zNTAxMDk5NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgQy02MC4zNTY0NzgyMjYxMDYwNiAyMi4yNTg5MjYzNzAzMzc1MTIsIC02MC4zNjI4NDY0ODcxNzg3NyAyMi4xMDQ5NTYwMTMzMTMzNjYsIC02MC4zNjcxODc1IDIyIEMtNjAuMzY3MTg3NSAyMiwgLTYwLjM2NzE4NzUgMjIsIC02MC4zNjcxODc1IDIyIEMtNjAuMzY3MTg3NSAxMi43MTU1Mjg2MTg3NDA0MzIsIC02MC4zNjcxODc1IDMuNDMxMDU3MjM3NDgwODY0MywgLTYwLjM2NzE4NzUgLTIyIEMtNjAuMzY3MTg3NSAtMjIsIC02MC4zNjcxODc1IC0yMiwgLTYwLjM2NzE4NzUgLTIyIEMtNjAuMzYzNjIyODk5MDY0MDMgLTIyLjA4NjE4NDEwNDY1NDY3NywgLTYwLjM2MDA1ODI5ODEyODA3NCAtMjIuMTcyMzY4MjA5MzA5MzU0LCAtNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IEMtNjAuMzM4MDM3MDc1MTY5NTk1IC0yMi41MDk3NTExMzkzNDg1MTMsIC02MC4zMjU5NjQxODUzMDU4NCAtMjIuNjA2NjA1NTUxMzM1MzcsIC02MC4yOTg5OTQwMTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgQy02MC4yNzkwMTM0MTUxOTg1MzQgLTIyLjkxODI2NDgxMTAwMzI4LCAtNjAuMjU5MDMyODEzMzgzNDYgLTIzLjAxMzU1NjY3MDYwMjg4NSwgLTYwLjIxNDE4ODgyOTY5NjY1IC0yMy4yMjc0Mjc0MzU3MDM5OTQgQy02MC4xNzIyOTkzNDc4ODA4IC0yMy4zNjgxMzE3MjQ4NDE4NDUsIC02MC4xMzA0MDk4NjYwNjQ5NDQgLTIzLjUwODgzNjAxMzk3OTY5NiwgLTYwLjA5NjI3MzcwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMDYwMjYyODU1MjM3NDI2IC0yMy43MTU3ODUyNTM1NTgwNTMsIC02MC4wMjQyNTIwMDE5NzE2NyAtMjMuODA4MDczMTYxMDkyNjksIC01OS45NDYwNTQxMzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEMtNTkuODgxNTIwNjg4MTU0NjUgLTI0LjE0MDQ4MjM5NTE2MTMyLCAtNTkuODE2OTg3MjQzMDM0MDIgLTI0LjI3MjQ4NzY2NzA1Nzc5MiwgLTU5Ljc2NDU1NjI1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNjYgQy01OS43MTIwNTcxMzA2Mjc3MSAtMjQuNDY3ODQxODY1NjExOTU1LCAtNTkuNjU5NTU4MDA1MjIyOTc2IC0yNC41NTU5NDY3NjYwMzg1NDUsIC01OS41NTMwMTk4OTEzMTI2NDQgLTI0LjczNDc0MDc5MDYxMjEzMyBDLTU5LjQ5MTI1MjA4NTgxNTI5IC0yNC44MjEyNTE5ODQ4OTg2MjcsIC01OS40Mjk0ODQyODAzMTc5NCAtMjQuOTA3NzYzMTc5MTg1MTI1LCAtNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IEMtNTkuMjE1MjA2NjAzMDE1NTggLTI1LjE4NjM5ODE4NzgwMDI5LCAtNTkuMTE3NTIzMTU5MDQ5MTg1IC0yNS4zMDE3MzI4MTIxNTIyMzUsIC01OS4wNDU4MDcwNTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTguOTYxNTM5OTY5ODEwMzk2IC0yNS40NzA2NzQ5NDE2ODM5NjcsIC01OC44NzcyNzI4ODYyNTUxMzUgLTI1LjU1NDk0MjAyNTIzOTIyOCwgLTU4Ljc1MzU5NTM1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IEMtNTguNjg1Mjg2MTI1MTM0MDQgLTI1LjczNjQ3NDUyMjUxNDAzNywgLTU4LjYxNjk3Njg5MjEzOTM3IC0yNS43OTQzMjk0OTE2NjI0MTMsIC01OC40MzgyNTEwNjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTY2IEMtNTguMzEwMjQzNTEyOTYyMTMgLTI2LjAzNzA5ODE4MTMzNjk4NywgLTU4LjE4MjIzNTk2MjQ3NTkzIC0yNi4xMjg0OTM4MTU2OTIwMDcsIC01OC4xMDE5MjgyOTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDLTU3Ljk2ODExOTg5MzcxODAwNCAtMjYuMjY1NTY0ODg5NDQ1NDIsIC01Ny44MzQzMTE0OTY4MjM4NyAtMjYuMzQ1Mjk3Mzg3NTc4MjAzLCAtNTcuNzQ2OTI0NDY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgQy01Ny42NTU3ODAwNjUxODA2NCAtMjYuNDQxOTI2NTM4MDkxMTEsIC01Ny41NjQ2MzU2NjUxNzU5MyAtMjYuNDg2NDg0MzIwMTQ5Nzc0LCAtNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQy01Ny4yNjc1ODQ5MzM5MTY4NCAtMjYuNjIxMDM5NDU4OTI1OTE0LCAtNTcuMTU5NTA1MjQ0NTY4ODM0IC0yNi42NjMyMTIyODQ1NzY1MzgsIC01Ni45OTA2ODQ4NDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEMtNTYuODY3NTk2ODExMDUzNjY2IC0yNi43NjU3MzExMDQyMjE3NiwgLTU2Ljc0NDUwODc3NjA4MzkxIC0yNi44MDIzNzU5OTk5NDAzNSwgLTU2LjU5NDYxNDkzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIEMtNTYuNDYzMzIzMTkzNzExMzYgLTI2Ljg3NDUzMDMxMzA2ODIxLCAtNTYuMzMyMDMxNDUxNzE4NzIgLTI2LjkwMjA1OTI5NjQzOTc2NiwgLTU2LjE5MDE2MDQ1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4wMjY1OTMxMTY1OTA5MjQgLTI2Ljk1MjE5NTE2NDAzNTM4OCwgLTU1Ljg2MzAyNTc4MTc3ODE4IC0yNi45NzI1ODM4MTEwNTcxNjMsIC01NS43ODAwODQyMjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IEMtNTUuNjc3MzkzMTUzNDkxMDcgLTI2Ljk4NzE2OTc5OTI2NjI4LCAtNTUuNTc0NzAyMDc5NjIwNDY1IC0yNi45OTE0MTcxMzM0OTkyMSwgLTU1LjM2NzE4NzUgLTI3IEMtNTUuMzY3MTg3NSAtMjcsIC01NS4zNjcxODc1IC0yNywgLTU1LjM2NzE4NzUgLTI3IiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCIgZmlsbD0iI0VDRUNGRiIgc3R5bGU9IiIvPjxwYXRoIGQ9Ik0tNTUuMzY3MTg3NSAtMjcgQy0yMC44NzY5OTY4ODI1OTMzNCAtMjcsIDEzLjYxMzE5MzczNDgxMzMyMiAtMjcsIDU1LjM2NzE4NzUgLTI3IE0tNTUuMzY3MTg3NSAtMjcgQy0xOC41MzAwMTUxNTE2ODU0MiAtMjcsIDE4LjMwNzE1NzE5NjYyOTE2MyAtMjcsIDU1LjM2NzE4NzUgLTI3IE01NS4zNjcxODc1IC0yNyBDNTUuMzY3MTg3NSAtMjcsIDU1LjM2NzE4NzUgLTI3LCA1NS4zNjcxODc1IC0yNyBNNTUuMzY3MTg3NSAtMjcgQzU1LjM2NzE4NzUgLTI3LCA1NS4zNjcxODc1IC0yNywgNTUuMzY3MTg3NSAtMjcgTTU1LjM2NzE4NzUgLTI3IEM1NS40ODQzMzQ1NDgwOTcxNDUgLTI2Ljk5NTE1NDc2MjI1MDM1LCA1NS42MDE0ODE1OTYxOTQyOCAtMjYuOTkwMzA5NTI0NTAwNzAyLCA1NS43ODAwODQyMjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IE01NS4zNjcxODc1IC0yNyBDNTUuNDg4NDQ1MjU0NjU4NjY0IC0yNi45OTQ5ODQ3NDIxNjkzMjYsIDU1LjYwOTcwMzAwOTMxNzMzIC0yNi45ODk5Njk0ODQzMzg2NDgsIDU1Ljc4MDA4NDIyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgTTU1Ljc4MDA4NDIyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgQzU1Ljk0MjU2MTM4MDkwODAyNSAtMjYuOTYyNjY5NzA4OTY0NzEsIDU2LjEwNTAzODUzNDQ1NDM5NCAtMjYuOTQyNDE2OTUyODk2MDc1LCA1Ni4xOTAxNjA0NTE0MDM2NyAtMjYuOTMxODA2NTE3MDEzNjEyIE01NS43ODAwODQyMjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IEM1NS45MzQ5NDA0NTU5NzQ0NCAtMjYuOTYzNjE5NjU2MjY3MDE0LCA1Ni4wODk3OTY2ODQ1ODcyMiAtMjYuOTQ0MzE2ODQ3NTAwNjgsIDU2LjE5MDE2MDQ1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgTTU2LjE5MDE2MDQ1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgQzU2LjMyMDYyNzAxMjcwMjIxIC0yNi45MDQ0NTA1NTU4Mzg3NCwgNTYuNDUxMDkzNTc0MDAwNzUgLTI2Ljg3NzA5NDU5NDY2Mzg3LCA1Ni41OTQ2MTQ5MzU3MDM5OTQgLTI2Ljg0NzAwMTMyOTY5NjY1MyBNNTYuMTkwMTYwNDUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBDNTYuMjg3MjM2NzYwNjYwNjcgLTI2LjkxMTQ1MTc1NTQ1MTcsIDU2LjM4NDMxMzA2OTkxNzY3IC0yNi44OTEwOTY5OTM4ODk3OSwgNTYuNTk0NjE0OTM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgTTU2LjU5NDYxNDkzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIEM1Ni43MzIyOTk3Mjk5NjM3NSAtMjYuODA2MDEwNzkwNDU0MzYyLCA1Ni44Njk5ODQ1MjQyMjM1MDQgLTI2Ljc2NTAyMDI1MTIxMjA3NSwgNTYuOTkwNjg0ODQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBNNTYuNTk0NjE0OTM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgQzU2Ljc0Mzk0OTkyMjYwMzcwNCAtMjYuODAyNTQyMzc3ODMwMDM2LCA1Ni44OTMyODQ5MDk1MDM0MTQgLTI2Ljc1ODA4MzQyNTk2MzQyNCwgNTYuOTkwNjg0ODQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBNNTYuOTkwNjg0ODQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBDNTcuMDkwOTU4Mjk1NTkwNTcgLTI2LjY4OTk1OTM4NzA2NzEzNCwgNTcuMTkxMjMxNzQ1MTU3NzE1IC0yNi42NTA4MzI1NjU2MzEwOTUsIDU3LjM3NTY2NDYyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IE01Ni45OTA2ODQ4NDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEM1Ny4wNzkzNzExMjc2Mzk4NDUgLTI2LjY5NDQ4MDcxNDAzMTY5LCA1Ny4xNjgwNTc0MDkyNTYyNjQgLTI2LjY1OTg3NTIxOTU2MDIxMywgNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgTTU3LjM3NTY2NDYyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IEM1Ny40NTQ1MDYxMDIwNDY5MiAtMjYuNTQwMzIzMzgyOTEwMjMsIDU3LjUzMzM0NzU4MDgyOSAtMjYuNTAxNzgwMTMyNTQ1MTc1LCA1Ny43NDY5MjQ0NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBNNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQzU3LjQ4MDMwODU4MDY2MzY2IC0yNi41Mjc3MDkzMTk3NDU0MjYsIDU3LjU4NDk1MjUzODA2MjQ3NCAtMjYuNDc2NTUyMDA2MjE1NTYzLCA1Ny43NDY5MjQ0NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBNNTcuNzQ2OTI0NDY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgQzU3Ljg0ODAwMDYwMDI0NTY3IC0yNi4zMzcxNDA0NTI0NTA0MiwgNTcuOTQ5MDc2NzM1MzA1OTcgLTI2LjI3NjkxMjE0ODg2ODM5NCwgNTguMTAxOTI4MjkwNjEyMTM2IC0yNi4xODU4MzIzOTEzMTI2NDQgTTU3Ljc0NjkyNDQ2NTE4NTM2NiAtMjYuMzk3MzY4NzU2MDMyNDQ2IEM1Ny44NDkwOTUyNzkyMTUzMSAtMjYuMzM2NDg4MTY1MzY3MzI4LCA1Ny45NTEyNjYwOTMyNDUyNjYgLTI2LjI3NTYwNzU3NDcwMjIwNywgNTguMTAxOTI4MjkwNjEyMTM2IC0yNi4xODU4MzIzOTEzMTI2NDQgTTU4LjEwMTkyODI5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IEM1OC4xNzcxNTk3NTk1OTQzOSAtMjYuMTMyMTE4MTU0ODg2OTMsIDU4LjI1MjM5MTIyODU3NjYzNCAtMjYuMDc4NDAzOTE4NDYxMjE2LCA1OC40MzgyNTEwNjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTcgTTU4LjEwMTkyODI5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IEM1OC4yMTU0MzkxMTM4NTI5NjYgLTI2LjEwNDc4NzIyMTI2NzIsIDU4LjMyODk0OTkzNzA5Mzc5IC0yNi4wMjM3NDIwNTEyMjE3NSwgNTguNDM4MjUxMDYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk3IE01OC40MzgyNTEwNjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTcgQzU4LjUzMjI0NTkxMTIyMjU5IC0yNS44NjYwOTI5NzE4MjM4MDQsIDU4LjYyNjI0MDc1ODk5NjgzIC0yNS43ODY0ODMzOTY2NjU2NCwgNTguNzUzNTk1MzU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgTTU4LjQzODI1MTA2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NyBDNTguNTM5MzI2OTE2MTk1MDE2IC0yNS44NjAwOTU2NjY1MzYzNywgNTguNjQwNDAyNzY4OTQxNyAtMjUuNzc0NDg4Nzg2MDkwNzY3LCA1OC43NTM1OTUzNTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBNNTguNzUzNTk1MzU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgQzU4Ljg2NDk4MTMyODAwMTQ2IC0yNS41NjcyMzM1ODM0OTI5MDQsIDU4Ljk3NjM2NzI5Nzg3NDIxIC0yNS40NTU4NDc2MTM2MjAxNSwgNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBNNTguNzUzNTk1MzU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgQzU4LjgzMzk2NTc4MzEwNTQwNSAtMjUuNTk4MjQ5MTI4Mzg4OTU3LCA1OC45MTQzMzYyMDgwODIxMDUgLTI1LjUxNzg3ODcwMzQxMjI1OCwgNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBNNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBDNTkuMTAzOTQ4MjQzNDczODYgLTI1LjMxNzc2MDY4NDY4NTM1NywgNTkuMTYyMDg5NDMzNTgyMDYgLTI1LjI0OTExMzUxMTI0MjAwNSwgNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IE01OS4wNDU4MDcwNTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEM1OS4xNTE3NjI2NzY1MjgzMzUgLTI1LjI2MTMwNjI5MDI1NTU2LCA1OS4yNTc3MTgyOTk2OTEwMiAtMjUuMTM2MjA0NzIyMzgyNDEzLCA1OS4zMTI4OTAwNDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgTTU5LjMxMjg5MDA0Njk4MTk3IC0yNS4wNzEwNjM1NjM0NDgzNCBDNTkuMzYzNjg2OTQ0MzcxNzcgLTI0Ljk5OTkxODA4MjAxNDE4NywgNTkuNDE0NDgzODQxNzYxNTcgLTI0LjkyODc3MjYwMDU4MDAzMiwgNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgTTU5LjMxMjg5MDA0Njk4MTk3IC0yNS4wNzEwNjM1NjM0NDgzNCBDNTkuMzgyMDIxNzk5NzM0MzQgLTI0Ljk3NDIzODUxOTMwODExLCA1OS40NTExNTM1NTI0ODY3MTYgLTI0Ljg3NzQxMzQ3NTE2Nzg3NCwgNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgTTU5LjU1MzAxOTg5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTM2IEM1OS42MjUzMjQ3ODUwNzY2MiAtMjQuNjEzMzk3NTIyMTk5MjI1LCA1OS42OTc2Mjk2Nzg4NDA1OSAtMjQuNDkyMDU0MjUzNzg2MzEsIDU5Ljc2NDU1NjI1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNyBNNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgQzU5LjYyMDMwNDY4Mzk3OTUyIC0yNC42MjE4MjIzMzg4NzcyNDgsIDU5LjY4NzU4OTQ3NjY0NjM5IC0yNC41MDg5MDM4ODcxNDIzNjMsIDU5Ljc2NDU1NjI1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNyBNNTkuNzY0NTU2MjU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM3IEM1OS44MjA5NTkyOTA1MzY4MzQgLTI0LjI2NDM2MjcxNDAyNTg0NywgNTkuODc3MzYyMzI1MDQxMjIgLTI0LjE0ODk4ODQ2Mjg2NjMyNCwgNTkuOTQ2MDU0MTMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBNNTkuNzY0NTU2MjU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM3IEM1OS44MzY4MzI2MzM5Mjg2MTYgLTI0LjIzMTg5MzI3MTE0MzYxLCA1OS45MDkxMDkwMTE4MjQ3OCAtMjQuMDg0MDQ5NTc3MTAxODUsIDU5Ljk0NjA1NDEzMzI3NTI5IC0yNC4wMDg0NzcxMjMyNjQ4NDYgTTU5Ljk0NjA1NDEzMzI3NTI5IC0yNC4wMDg0NzcxMjMyNjQ4NDYgQzYwLjAwMTgzMTQ4OTAwNzEyIC0yMy44NjU1MzIwMTE0MDMyMjUsIDYwLjA1NzYwODg0NDczODk0NCAtMjMuNzIyNTg2ODk5NTQxNjA3LCA2MC4wOTYyNzM3MDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBNNTkuOTQ2MDU0MTMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBDNTkuOTc4OTU0MTU4MDg3NDkgLTIzLjkyNDE2MTU4NTc5NzYyNCwgNjAuMDExODU0MTgyODk5NjggLTIzLjgzOTg0NjA0ODMzMDM5NywgNjAuMDk2MjczNzA4NTAzMTc2IC0yMy42MjM0OTczNDYwMjM0MTcgTTYwLjA5NjI3MzcwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEM2MC4xMjIzOTgxNTI1OTYyMDUgLTIzLjUzNTc0Njg4NTE0NjYyNiwgNjAuMTQ4NTIyNTk2Njg5MjMgLTIzLjQ0Nzk5NjQyNDI2OTg0LCA2MC4yMTQxODg4Mjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IE02MC4wOTYyNzM3MDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBDNjAuMTMwODQzOTQwOTY3Njk0IC0yMy41MDczNzc5ODIwODMwMDcsIDYwLjE2NTQxNDE3MzQzMjIxIC0yMy4zOTEyNTg2MTgxNDI2LCA2MC4yMTQxODg4Mjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IE02MC4yMTQxODg4Mjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IEM2MC4yMzI0NzQ3ODY0MDIxMiAtMjMuMTQwMjE3NzA5MjMyODA0LCA2MC4yNTA3NjA3NDMxMDc2IC0yMy4wNTMwMDc5ODI3NjE2MTcsIDYwLjI5ODk5NDAxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBNNjAuMjE0MTg4ODI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBDNjAuMjQ1MTI2MDA0MzUzMDcgLTIzLjA3OTg4MTI4NDEzODk1LCA2MC4yNzYwNjMxNzkwMDk0OSAtMjIuOTMyMzM1MTMyNTczOSwgNjAuMjk4OTk0MDE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IE02MC4yOTg5OTQwMTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgQzYwLjMxNjQ1MjIwMTc4MzAzIC0yMi42ODI5MTUxNjU3OTYyLCA2MC4zMzM5MTAzODY1NTI0NDYgLTIyLjU0Mjg1NzM4MDE4ODcyOCwgNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2MiBNNjAuMjk4OTk0MDE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IEM2MC4zMTI4ODE3NDgzNjYzOTQgLTIyLjcxMTU1OTAyNTcyNTE0LCA2MC4zMjY3Njk0Nzk3MTkxNzUgLTIyLjYwMDE0NTEwMDA0NjYxLCA2MC4zNTAxMDk5NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIE02MC4zNTAxMDk5NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIEM2MC4zNTUzNzIxMTAwNjkzNTQgLTIyLjI4NTY2OTc5MDQyMjk3LCA2MC4zNjA2MzQyNTUxMDUzNyAtMjIuMTU4NDQyODUzNDg0Mjc0LCA2MC4zNjcxODc1IC0yMiBNNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuMzU1NDQ0OTUwNDc2NjQgLTIyLjI4MzkwODY3MTc0NTk1LCA2MC4zNjA3Nzk5MzU5MTk5MzYgLTIyLjE1NDkyMDYxNjEzMDIzNywgNjAuMzY3MTg3NSAtMjIgTTYwLjM2NzE4NzUgLTIyIEM2MC4zNjcxODc1IC0yMiwgNjAuMzY3MTg3NSAtMjIsIDYwLjM2NzE4NzUgLTIyIE02MC4zNjcxODc1IC0yMiBDNjAuMzY3MTg3NSAtMjIsIDYwLjM2NzE4NzUgLTIyLCA2MC4zNjcxODc1IC0yMiBNNjAuMzY3MTg3NSAtMjIgQzYwLjM2NzE4NzUgLTExLjE3MjE4NTYxMjY0NDIwOCwgNjAuMzY3MTg3NSAtMC4zNDQzNzEyMjUyODg0MTYxLCA2MC4zNjcxODc1IDIyIE02MC4zNjcxODc1IC0yMiBDNjAuMzY3MTg3NSAtNi42MTY3ODAwMDU2MTI3NTMsIDYwLjM2NzE4NzUgOC43NjY0Mzk5ODg3NzQ0OTMsIDYwLjM2NzE4NzUgMjIgTTYwLjM2NzE4NzUgMjIgQzYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIgTTYwLjM2NzE4NzUgMjIgQzYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIsIDYwLjM2NzE4NzUgMjIgTTYwLjM2NzE4NzUgMjIgQzYwLjM2MDUzNTEzOTM4MDYgMjIuMTYwODM5MjUwNzY2NzE3LCA2MC4zNTM4ODI3Nzg3NjEyMDQgMjIuMzIxNjc4NTAxNTMzNDMsIDYwLjM1MDEwOTk2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBNNjAuMzY3MTg3NSAyMiBDNjAuMzYzMzAwMTU2NDk0NjI0IDIyLjA5Mzk4NzMwMDU0NjM1MywgNjAuMzU5NDEyODEyOTg5MjQgMjIuMTg3OTc0NjAxMDkyNzA1LCA2MC4zNTAxMDk5NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgTTYwLjM1MDEwOTk2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuMzM3NjEwNjU1NDYyNDkgMjIuNTEzMTcyMDc5MTk0NTQzLCA2MC4zMjUxMTEzNDU4OTE2MyAyMi42MTM0NDc0MzEwMjc0MiwgNjAuMjk4OTk0MDE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgTTYwLjM1MDEwOTk2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuMzMxNzcwMTAxNTgwODk0IDIyLjU2MDAyNzc1NDg2NjUzNSwgNjAuMzEzNDMwMjM4MTI4NDMgMjIuNzA3MTU4NzgyMzcxNDEyLCA2MC4yOTg5OTQwMTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBNNjAuMjk4OTk0MDE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgQzYwLjI3MTg3NzQ0NzMzMTM4IDIyLjk1MjI5NzgwMjMyMTQ2NCwgNjAuMjQ0NzYwODc3NjQ5MTUgMjMuMDgxNjIyNjUzMjM5MjU4LCA2MC4yMTQxODg4Mjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgTTYwLjI5ODk5NDAxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEM2MC4yNjkxNTcyNzQyMDkyOCAyMi45NjUyNzA5MDI4MTM0OTcsIDYwLjIzOTMyMDUzMTQwNDkzNSAyMy4xMDc1Njg4NTQyMjMzMjcsIDYwLjIxNDE4ODgyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBNNjAuMjE0MTg4ODI5Njk2NjUgMjMuMjI3NDI3NDM1NzAzOTk0IEM2MC4xNzU4ODc5MDM2MTY2MiAyMy4zNTYwNzc5Nzg2MDQwNzMsIDYwLjEzNzU4Njk3NzUzNjU4NiAyMy40ODQ3Mjg1MjE1MDQxNTYsIDYwLjA5NjI3MzcwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgTTYwLjIxNDE4ODgyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBDNjAuMTg3MzI4NDI3ODI1ODc1IDIzLjMxNzY0OTkzNTE4MDA5MywgNjAuMTYwNDY4MDI1OTU1MSAyMy40MDc4NzI0MzQ2NTYxOTUsIDYwLjA5NjI3MzcwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgTTYwLjA5NjI3MzcwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQzYwLjA0NzI0NTYzMDE4MTM3IDIzLjc0OTE0NTU0MjI4NzI1LCA1OS45OTgyMTc1NTE4NTk1NzUgMjMuODc0NzkzNzM4NTUxMDc3LCA1OS45NDYwNTQxMzMyNzUyOSAyNC4wMDg0NzcxMjMyNjQ4NDYgTTYwLjA5NjI3MzcwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQzYwLjA2MzcyNjE3NDE0OTQ5NCAyMy43MDY5MDk1Mjc4NjYwODgsIDYwLjAzMTE3ODYzOTc5NTgyIDIzLjc5MDMyMTcwOTcwODc2LCA1OS45NDYwNTQxMzMyNzUyOSAyNC4wMDg0NzcxMjMyNjQ4NDYgTTU5Ljk0NjA1NDEzMzI3NTI5IDI0LjAwODQ3NzEyMzI2NDg0NiBDNTkuODg0ODA5ODA3MzMwMTM2IDI0LjEzMzc1NDM5NDM1MTA4LCA1OS44MjM1NjU0ODEzODQ5OCAyNC4yNTkwMzE2NjU0MzczMSwgNTkuNzY0NTU2MjU2MDMyNDUgMjQuMzc5NzM2OTY1MTg1MzY2IE01OS45NDYwNTQxMzMyNzUyOSAyNC4wMDg0NzcxMjMyNjQ4NDYgQzU5Ljg5MzY0NzM0MDk3NzA3NiAyNC4xMTU2NzY5MzAzMzU2NjcsIDU5Ljg0MTI0MDU0ODY3ODg2IDI0LjIyMjg3NjczNzQwNjQ5LCA1OS43NjQ1NTYyNTYwMzI0NSAyNC4zNzk3MzY5NjUxODUzNjYgTTU5Ljc2NDU1NjI1NjAzMjQ1IDI0LjM3OTczNjk2NTE4NTM2NiBDNTkuNzA5Nzc5OTM3NTg0OTggMjQuNDcxNjYzNDg4NjMzOTY1LCA1OS42NTUwMDM2MTkxMzc1MSAyNC41NjM1OTAwMTIwODI1NjQsIDU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTTU5Ljc2NDU1NjI1NjAzMjQ1IDI0LjM3OTczNjk2NTE4NTM2NiBDNTkuNjk0NTk5NDQ2NDQ3OTYgMjQuNDk3MTM5NjM5ODk4MjU4LCA1OS42MjQ2NDI2MzY4NjM0NiAyNC42MTQ1NDIzMTQ2MTExNDYsIDU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTTU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgQzU5LjQ3MjYwMzAwOTA3NzY0IDI0Ljg0NzM3MTY0MjAyMjk4NSwgNTkuMzkyMTg2MTI2ODQyNjQgMjQuOTYwMDAyNDkzNDMzODM3LCA1OS4zMTI4OTAwNDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBNNTkuNTUzMDE5ODkxMzEyNjQ0IDI0LjczNDc0MDc5MDYxMjEzMyBDNTkuNDY2OTA5ODYyNDY0NjkgMjQuODU1MzQ1Mzg5OTc3NDIsIDU5LjM4MDc5OTgzMzYxNjc0IDI0Ljk3NTk0OTk4OTM0MjcwMiwgNTkuMzEyODkwMDQ2OTgxOTcgMjUuMDcxMDYzNTYzNDQ4MzQgTTU5LjMxMjg5MDA0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IEM1OS4yNDYxNTg1OTk0MDcyNyAyNS4xNDk4NTMyMzQ3MTY4NiwgNTkuMTc5NDI3MTUxODMyNTggMjUuMjI4NjQyOTA1OTg1Mzg1LCA1OS4wNDU4MDcwNTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgTTU5LjMxMjg5MDA0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IEM1OS4yMTcwNDA3NTM0NTc2IDI1LjE4NDIzMjYxMDQ2NTUxOCwgNTkuMTIxMTkxNDU5OTMzMjM2IDI1LjI5NzQwMTY1NzQ4MjY5NSwgNTkuMDQ1ODA3MDUzMzY1NjYgMjUuMzg2NDA3ODU4MTI4NzA2IE01OS4wNDU4MDcwNTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQzU4Ljk1NTE2NDQ4MDI3NjA0NiAyNS40NzcwNTA0MzEyMTgzMiwgNTguODY0NTIxOTA3MTg2NDMgMjUuNTY3NjkzMDA0MzA3OTM0LCA1OC43NTM1OTUzNTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IE01OS4wNDU4MDcwNTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQzU4Ljk1NjMxOTkyODEzMjcyIDI1LjQ3NTg5NDk4MzM2MTY0LCA1OC44NjY4MzI4MDI4OTk3ODYgMjUuNTY1MzgyMTA4NTk0NTc2LCA1OC43NTM1OTUzNTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IE01OC43NTM1OTUzNTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IEM1OC42NTg4OTc0MDkxMTg3NSAyNS43NTg4MjQ2MjQ4OTQwMzYsIDU4LjU2NDE5OTQ2MDEwODc5IDI1LjgzOTAyOTY5NjQyMjQxLCA1OC40MzgyNTEwNjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBNNTguNzUzNTk1MzU4MTI4NzA2IDI1LjY3ODYxOTU1MzM2NTY1NyBDNTguNjQ4MDgyODk0MDUxNyAyNS43Njc5ODQwNTE5MzA5LCA1OC41NDI1NzA0Mjk5NzQ2OTUgMjUuODU3MzQ4NTUwNDk2MTM4LCA1OC40MzgyNTEwNjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBNNTguNDM4MjUxMDYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgQzU4LjMzMjQ3NDU0OTQ4NDcxIDI2LjAyMTIyNTUyNjM0NDExOCwgNTguMjI2Njk4MDM1NTIxMDcgMjYuMDk2NzQ4NTA1NzA2MjY2LCA1OC4xMDE5MjgyOTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IE01OC40MzgyNTEwNjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBDNTguMzE0OTgyNzUwNTM3ODEgMjYuMDMzNzE0NDMwNzQ2MTY2LCA1OC4xOTE3MTQ0Mzc2MjcyNyAyNi4xMjE3MjYzMTQ1MTAzNiwgNTguMTAxOTI4MjkwNjEyMTM2IDI2LjE4NTgzMjM5MTMxMjY0NCBNNTguMTAxOTI4MjkwNjEyMTM2IDI2LjE4NTgzMjM5MTMxMjY0NCBDNTcuOTYxMDQwNTEwNTE0MzQgMjYuMjY5NzgzMjg2MjA2NTM2LCA1Ny44MjAxNTI3MzA0MTY1MyAyNi4zNTM3MzQxODExMDA0MjUsIDU3Ljc0NjkyNDQ2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgTTU4LjEwMTkyODI5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgQzU4LjAwMTAwMzA5Nzg5ODUzIDI2LjI0NTk3MDc1Mjc3ODUxNSwgNTcuOTAwMDc3OTA1MTg0OTIgMjYuMzA2MTA5MTE0MjQ0Mzg2LCA1Ny43NDY5MjQ0NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IE01Ny43NDY5MjQ0NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IEM1Ny42NTIxMzMwMTA1ODcyNiAyNi40NDM3MDk0NzQ0Mzk1ODYsIDU3LjU1NzM0MTU1NTk4OTE0IDI2LjQ5MDA1MDE5Mjg0NjcyNywgNTcuMzc1NjY0NjIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBNNTcuNzQ2OTI0NDY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBDNTcuNTk5OTQzNjk4OTE3NDMgMjYuNDY5MjIzMjc0MjQ1Nzc4LCA1Ny40NTI5NjI5MzI2NDk0OSAyNi41NDEwNzc3OTI0NTkxMSwgNTcuMzc1NjY0NjIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBNNTcuMzc1NjY0NjIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBDNTcuMjgwMTA3ODMyNDEyMDEgMjYuNjE2MTUzMDA4NzY5ODkyLCA1Ny4xODQ1NTEwNDE1NTkxNzUgMjYuNjUzNDM5Mzg0MjY0NDk1LCA1Ni45OTA2ODQ4NDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgTTU3LjM3NTY2NDYyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgQzU3LjI0MDA2NTc0NjUxNzQ5NCAyNi42MzE3Nzc0NzkxNzA0MjQsIDU3LjEwNDQ2Njg2OTc3MDE1IDI2LjY4NDY4ODMyNTA2NTU2LCA1Ni45OTA2ODQ4NDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgTTU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDNTYuODM5NTE3OTY2OTEwNDggMjYuNzc0MDkwNTM4MzExMTc4LCA1Ni42ODgzNTEwODc3OTc1MyAyNi44MTkwOTQ4NjgxMTkxODcsIDU2LjU5NDYxNDkzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgTTU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDNTYuOTA1Mjc2ODMzNTA0NTE2IDI2Ljc1NDUxMzI3NTUwNDksIDU2LjgxOTg2ODgyMDk4NTYwNCAyNi43Nzk5NDAzNDI1MDY2MywgNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBNNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuNTExODgzMDI3NzE2ODA2IDI2Ljg2NDM0ODM4NjU5OTkzLCA1Ni40MjkxNTExMTk3Mjk2MiAyNi44ODE2OTU0NDM1MDMyMDUsIDU2LjE5MDE2MDQ1MTQwMzY3IDI2LjkzMTgwNjUxNzAxMzYxMiBNNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuNDc2Njg2Mjg5OTE1MzYgMjYuODcxNzI4MzY2NDQ0NTM2LCA1Ni4zNTg3NTc2NDQxMjY3MjYgMjYuODk2NDU1NDAzMTkyNDIsIDU2LjE5MDE2MDQ1MTQwMzY3IDI2LjkzMTgwNjUxNzAxMzYxMiBNNTYuMTkwMTYwNDUxNDAzNjcgMjYuOTMxODA2NTE3MDEzNjEyIEM1Ni4wNDYwODE0NjY4NjMzOCAyNi45NDk3NjU5NDM3MDcyNzcsIDU1LjkwMjAwMjQ4MjMyMzA5IDI2Ljk2NzcyNTM3MDQwMDk0NSwgNTUuNzgwMDg0MjI3MzYxNjYgMjYuOTgyOTIyNDY1MDMzMzQ3IE01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQzU2LjEwNDE1NjUxNzk3MDI1IDI2Ljk0MjUyNjg5NjEzNjE2NSwgNTYuMDE4MTUyNTg0NTM2ODMgMjYuOTUzMjQ3Mjc1MjU4NzE4LCA1NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTTU1Ljc4MDA4NDIyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTUuNjc4Nzc2NTg4OTc4ODM1IDI2Ljk4NzExMjU3OTk1MTI1LCA1NS41Nzc0Njg5NTA1OTYwMTQgMjYuOTkxMzAyNjk0ODY5MTU1LCA1NS4zNjcxODc1IDI3IE01NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgQzU1LjY0OTg0OTUzOTMxNDIzIDI2Ljk4ODMwOTAxMTU3NTQxNCwgNTUuNTE5NjE0ODUxMjY2OCAyNi45OTM2OTU1NTgxMTc0ODQsIDU1LjM2NzE4NzUgMjcgTTU1LjM2NzE4NzUgMjcgQzU1LjM2NzE4NzUgMjcsIDU1LjM2NzE4NzUgMjcsIDU1LjM2NzE4NzUgMjcgTTU1LjM2NzE4NzUgMjcgQzU1LjM2NzE4NzUgMjcsIDU1LjM2NzE4NzUgMjcsIDU1LjM2NzE4NzUgMjcgTTU1LjM2NzE4NzUgMjcgQzE2LjA1MjEyNTAxODQ5NjgzOCAyNywgLTIzLjI2MjkzNzQ2MzAwNjMyNCAyNywgLTU1LjM2NzE4NzUgMjcgTTU1LjM2NzE4NzUgMjcgQzE4LjU2NzM3OTM4NDUzNjA0NiAyNywgLTE4LjIzMjQyODczMDkyNzkwNyAyNywgLTU1LjM2NzE4NzUgMjcgTS01NS4zNjcxODc1IDI3IEMtNTUuMzY3MTg3NSAyNywgLTU1LjM2NzE4NzUgMjcsIC01NS4zNjcxODc1IDI3IE0tNTUuMzY3MTg3NSAyNyBDLTU1LjM2NzE4NzUgMjcsIC01NS4zNjcxODc1IDI3LCAtNTUuMzY3MTg3NSAyNyBNLTU1LjM2NzE4NzUgMjcgQy01NS41MDY3MzE1OTc5MzMwNiAyNi45OTQyMjg0MTM0MTY5MTMsIC01NS42NDYyNzU2OTU4NjYxMiAyNi45ODg0NTY4MjY4MzM4MjIsIC01NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTS01NS4zNjcxODc1IDI3IEMtNTUuNTAyMDQwODE0NTA0NDcgMjYuOTk0NDIyNDI1NjU0NjMsIC01NS42MzY4OTQxMjkwMDg5MyAyNi45ODg4NDQ4NTEzMDkyNjMsIC01NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTS01NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgQy01NS44NzU3NDczODQzMDg5NjYgMjYuOTcwOTk4MDY0OTU1NDIsIC01NS45NzE0MTA1NDEyNTYyNyAyNi45NTkwNzM2NjQ4Nzc0OTQsIC01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgTS01NS43ODAwODQyMjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgQy01NS44OTI5MzE2OTE4NDU2NzUgMjYuOTY4ODU2MDQzMjU0ODE0LCAtNTYuMDA1Nzc5MTU2MzI5NjkgMjYuOTU0Nzg5NjIxNDc2MjgsIC01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgTS01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4yODU4NjYwNTkwMDI1MiAyNi45MTE3MzkxNjEzNjYwNTQsIC01Ni4zODE1NzE2NjY2MDEzNzQgMjYuODkxNjcxODA1NzE4NSwgLTU2LjU5NDYxNDkzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgTS01Ni4xOTAxNjA0NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4zMjg1OTE4ODQ3OTQzNSAyNi45MDI3ODA0OTc3ODI4OTIsIC01Ni40NjcwMjMzMTgxODUwMyAyNi44NzM3NTQ0Nzg1NTIxNzYsIC01Ni41OTQ2MTQ5MzU3MDM5OTQgMjYuODQ3MDAxMzI5Njk2NjUzIE0tNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDLTU2LjczNjY3NjEwMjE3NTY5IDI2LjgwNDcwNzg4Nzk5NjIsIC01Ni44Nzg3MzcyNjg2NDczODQgMjYuNzYyNDE0NDQ2Mjk1NzU0LCAtNTYuOTkwNjg0ODQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIE0tNTYuNTk0NjE0OTM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDLTU2Ljc1MDk0MjgxMDcxNDUzIDI2LjgwMDQ2MDUwNDgzODQzNywgLTU2LjkwNzI3MDY4NTcyNTA2IDI2Ljc1MzkxOTY3OTk4MDIxOCwgLTU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBNLTU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDLTU3LjA3ODUxMzIyNjUzMTY5IDI2LjY5NDgxNTQ2ODA4Mjg3MiwgLTU3LjE2NjM0MTYwNzAzOTk2IDI2LjY2MDU0NDcyNzY2MjU3LCAtNTcuMzc1NjY0NjIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBNLTU2Ljk5MDY4NDg0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDLTU3LjEzNjM0MzYxMTMzMDc3IDI2LjY3MjI0OTk4MTkxMTAyMywgLTU3LjI4MjAwMjM3NjYzODEzIDI2LjYxNTQxMzc1NTMxODg3MywgLTU3LjM3NTY2NDYyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgTS01Ny4zNzU2NjQ2MjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IEMtNTcuNTEzMDUzMjM0NDk2NDMgMjYuNTExNzAxNDMzOTE4MTk0LCAtNTcuNjUwNDQxODQ1NzI4MDIgMjYuNDQ0NTM2MjM0NTYxMSwgLTU3Ljc0NjkyNDQ2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgTS01Ny4zNzU2NjQ2MjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IEMtNTcuNTAwMjY5NjUxMjc1NzkgMjYuNTE3OTUwOTQ2OTQ0MTcsIC01Ny42MjQ4NzQ2NzkyODY3MyAyNi40NTcwMzUyNjA2MTMwNTQsIC01Ny43NDY5MjQ0NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IE0tNTcuNzQ2OTI0NDY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBDLTU3LjgyMDEwNDI3MDEzNTEzIDI2LjM1Mzc2MzA1NzE2MDQxOCwgLTU3Ljg5MzI4NDA3NTA4NDkxIDI2LjMxMDE1NzM1ODI4ODM4NiwgLTU4LjEwMTkyODI5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgTS01Ny43NDY5MjQ0NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IEMtNTcuODMxNzI2MTE1MjkyMjMgMjYuMzQ2ODM3OTQwNTg0ODA1LCAtNTcuOTE2NTI3NzY1Mzk5MDk2IDI2LjI5NjMwNzEyNTEzNzE2NSwgLTU4LjEwMTkyODI5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgTS01OC4xMDE5MjgyOTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IEMtNTguMTkzOTgzNTYwNjc4MDUgMjYuMTIwMTA2MTkxNzkyMDY1LCAtNTguMjg2MDM4ODMwNzQzOTcgMjYuMDU0Mzc5OTkyMjcxNDg1LCAtNTguNDM4MjUxMDYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgTS01OC4xMDE5MjgyOTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IEMtNTguMTc1NDIzMTk3OTAxNDggMjYuMTMzMzU4MDM2MTA0MjIzLCAtNTguMjQ4OTE4MTA1MTkwODI1IDI2LjA4MDg4MzY4MDg5NTgwNiwgLTU4LjQzODI1MTA2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IE0tNTguNDM4MjUxMDYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgQy01OC41NDYzNDY5MDM4NDQyMyAyNS44NTQxNTAwNDAyODU1MzYsIC01OC42NTQ0NDI3NDQyNDAxMyAyNS43NjI1OTc1MzM1ODkxMDUsIC01OC43NTM1OTUzNTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjYgTS01OC40MzgyNTEwNjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBDLTU4LjUzOTgzMTk2MTk2MTcyNCAyNS44NTk2Njc5MTQ1OTE3MTgsIC01OC42NDE0MTI4NjA0NzUxIDI1Ljc3MzYzMzI4MjIwMTQ3LCAtNTguNzUzNTk1MzU4MTI4NzA2IDI1LjY3ODYxOTU1MzM2NTY2IE0tNTguNzUzNTk1MzU4MTI4NzA2IDI1LjY3ODYxOTU1MzM2NTY2IEMtNTguODQ0OTAxNTE3NTcyNDQgMjUuNTg3MzEzMzkzOTIxOTMsIC01OC45MzYyMDc2NzcwMTYxNjQgMjUuNDk2MDA3MjM0NDc4MiwgLTU5LjA0NTgwNzA1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBNLTU4Ljc1MzU5NTM1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NiBDLTU4Ljg0MzYzNDEyMDk4MTIgMjUuNTg4NTgwNzkwNTEzMTY2LCAtNTguOTMzNjcyODgzODMzNjk0IDI1LjQ5ODU0MjAyNzY2MDY3MiwgLTU5LjA0NTgwNzA1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBNLTU5LjA0NTgwNzA1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBDLTU5LjEyNTc3NDEyNDg0MDcgMjUuMjkxOTkwOTE1MjUwNzIsIC01OS4yMDU3NDExOTYzMTU3NSAyNS4xOTc1NzM5NzIzNzI3MzcsIC01OS4zMTI4OTAwNDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBNLTU5LjA0NTgwNzA1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBDLTU5LjE0MjQxMTUyNjk5MDk3NiAyNS4yNzIzNDcxNzE2NTExOTgsIC01OS4yMzkwMTYwMDA2MTYyOTYgMjUuMTU4Mjg2NDg1MTczNjksIC01OS4zMTI4OTAwNDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBNLTU5LjMxMjg5MDA0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IEMtNTkuMzg5MjE3MTg5NjE2MzMgMjQuOTY0MTYwNzQ4NzQ0MDQyLCAtNTkuNDY1NTQ0MzMyMjUwNyAyNC44NTcyNTc5MzQwMzk3NDQsIC01OS41NTMwMTk4OTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIE0tNTkuMzEyODkwMDQ2OTgxOTcgMjUuMDcxMDYzNTYzNDQ4MzQgQy01OS4zOTc1NzY2MTQ4NDA1MjQgMjQuOTUyNDUyNjQ1MjQ0NjUsIC01OS40ODIyNjMxODI2OTkwOCAyNC44MzM4NDE3MjcwNDA5NiwgLTU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTS01OS41NTMwMTk4OTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuNjA3Mjc4NjI3MTgwMTkgMjQuNjQzNjgyODgyODA4MzU2LCAtNTkuNjYxNTM3MzYzMDQ3NzUgMjQuNTUyNjI0OTc1MDA0NTgsIC01OS43NjQ1NTYyNTYwMzI0NCAyNC4zNzk3MzY5NjUxODUzNyBNLTU5LjU1MzAxOTg5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgQy01OS42MTE0ODE3MzgyNTI2NyAyNC42MzY2MjkxNTIzMDAxMzQsIC01OS42Njk5NDM1ODUxOTI3IDI0LjUzODUxNzUxMzk4ODE0LCAtNTkuNzY0NTU2MjU2MDMyNDQgMjQuMzc5NzM2OTY1MTg1MzcgTS01OS43NjQ1NTYyNTYwMzI0NCAyNC4zNzk3MzY5NjUxODUzNyBDLTU5LjgxMTMwOTg2NjA3NzMxIDI0LjI4NDEwMDkyNzI0MjkxNCwgLTU5Ljg1ODA2MzQ3NjEyMjE3NiAyNC4xODg0NjQ4ODkzMDA0NjIsIC01OS45NDYwNTQxMzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBNLTU5Ljc2NDU1NjI1NjAzMjQ0IDI0LjM3OTczNjk2NTE4NTM3IEMtNTkuODI2NTI3Mzk5OTI3OTcgMjQuMjUyOTcyOTY0MjE4NTU3LCAtNTkuODg4NDk4NTQzODIzNDkgMjQuMTI2MjA4OTYzMjUxNzQ1LCAtNTkuOTQ2MDU0MTMzMjc1MjggMjQuMDA4NDc3MTIzMjY0ODUgTS01OS45NDYwNTQxMzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBDLTYwLjAwNTc5MTA0NTQ4MTA3IDIzLjg1NTM4NDUzNzgzMzI1LCAtNjAuMDY1NTI3OTU3Njg2ODU0IDIzLjcwMjI5MTk1MjQwMTY1LCAtNjAuMDk2MjczNzA4NTAzMTc2IDIzLjYyMzQ5NzM0NjAyMzQxNyBNLTU5Ljk0NjA1NDEzMzI3NTI4IDI0LjAwODQ3NzEyMzI2NDg1IEMtNTkuOTk4MzgzODI4NDU0MzE1IDIzLjg3NDM2NzYwODE1OTU2LCAtNjAuMDUwNzEzNTIzNjMzMzQgMjMuNzQwMjU4MDkzMDU0MjcsIC02MC4wOTYyNzM3MDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IE0tNjAuMDk2MjczNzA4NTAzMTc2IDIzLjYyMzQ5NzM0NjAyMzQxNyBDLTYwLjEzMDc5MjA2MjEwOTA5NCAyMy41MDc1NTIyNDAwOTcyODcsIC02MC4xNjUzMTA0MTU3MTUwMSAyMy4zOTE2MDcxMzQxNzExNTYsIC02MC4yMTQxODg4Mjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgTS02MC4wOTYyNzM3MDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMTM0Mjk5NzA3NjE4NzUgMjMuNDk1NzcwMjY2NTE4NSwgLTYwLjE3MjMyNTcwNjczNDMyIDIzLjM2ODA0MzE4NzAxMzU4MywgLTYwLjIxNDE4ODgyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBNLTYwLjIxNDE4ODgyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBDLTYwLjIzNDU2NDM2NDE3OTExIDIzLjEzMDI1MjA1NTg0NjEwNywgLTYwLjI1NDkzOTg5ODY2MTU2IDIzLjAzMzA3NjY3NTk4ODIyLCAtNjAuMjk4OTk0MDE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgTS02MC4yMTQxODg4Mjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQy02MC4yNDMyMjYxNTYyNzYyNiAyMy4wODg5NDIwNzUwOTM2ODQsIC02MC4yNzIyNjM0ODI4NTU4NiAyMi45NTA0NTY3MTQ0ODMzNzYsIC02MC4yOTg5OTQwMTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBNLTYwLjI5ODk5NDAxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEMtNjAuMzE2NzI1MzI1MTQ5MTMgMjIuNjgwNzI0MDQxNDQwNTksIC02MC4zMzQ0NTY2MzMyODQ2NCAyMi41Mzg0NzUxMzE0Nzc1MSwgLTYwLjM1MDEwOTk2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBNLTYwLjI5ODk5NDAxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEMtNjAuMzEwMTYwMzAxMTUwMDU2IDIyLjczMzM5MTc1NzgxMDU0OCwgLTYwLjMyMTMyNjU4NTI4NjQ5IDIyLjY0MzgxMDU2NDIxNzQzLCAtNjAuMzUwMTA5OTY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIE0tNjAuMzUwMTA5OTY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIEMtNjAuMzU1NjE1NzMwNzgxNjMgMjIuMjc5Nzc5NTg0NjYzNjgsIC02MC4zNjExMjE0OTY1Mjk5MSAyMi4xNDY2NjI0NDE5NjU2OTUsIC02MC4zNjcxODc1IDIyIE0tNjAuMzUwMTA5OTY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIEMtNjAuMzU2NTIwMTAzODYzNDEgMjIuMjU3OTEzODU5NTE1OTYsIC02MC4zNjI5MzAyNDI2OTM0NyAyMi4xMDI5MzA5OTE2NzAyNTIsIC02MC4zNjcxODc1IDIyIE0tNjAuMzY3MTg3NSAyMiBDLTYwLjM2NzE4NzUgMjIsIC02MC4zNjcxODc1IDIyLCAtNjAuMzY3MTg3NSAyMiBNLTYwLjM2NzE4NzUgMjIgQy02MC4zNjcxODc1IDIyLCAtNjAuMzY3MTg3NSAyMiwgLTYwLjM2NzE4NzUgMjIgTS02MC4zNjcxODc1IDIyIEMtNjAuMzY3MTg3NSA0LjQ1MDM4OTkzODg2MTMzNSwgLTYwLjM2NzE4NzUgLTEzLjA5OTIyMDEyMjI3NzMzLCAtNjAuMzY3MTg3NSAtMjIgTS02MC4zNjcxODc1IDIyIEMtNjAuMzY3MTg3NSA1LjM3ODAwNDcwMDYxNzUxNywgLTYwLjM2NzE4NzUgLTExLjI0Mzk5MDU5ODc2NDk2NywgLTYwLjM2NzE4NzUgLTIyIE0tNjAuMzY3MTg3NSAtMjIgQy02MC4zNjcxODc1IC0yMiwgLTYwLjM2NzE4NzUgLTIyLCAtNjAuMzY3MTg3NSAtMjIgTS02MC4zNjcxODc1IC0yMiBDLTYwLjM2NzE4NzUgLTIyLCAtNjAuMzY3MTg3NSAtMjIsIC02MC4zNjcxODc1IC0yMiBNLTYwLjM2NzE4NzUgLTIyIEMtNjAuMzYxNDYwMTkyNjMwNzkgLTIyLjEzODQ3MzUyNTI0NTkxNywgLTYwLjM1NTczMjg4NTI2MTU3NCAtMjIuMjc2OTQ3MDUwNDkxODM1LCAtNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IE0tNjAuMzY3MTg3NSAtMjIgQy02MC4zNjE1OTQ3MjczNDAyNiAtMjIuMTM1MjIwNzc1ODY2NzksIC02MC4zNTYwMDE5NTQ2ODA1MyAtMjIuMjcwNDQxNTUxNzMzNTgzLCAtNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IE0tNjAuMzUwMTA5OTY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IEMtNjAuMzMyODUxNzMyODY2MTMgLTIyLjU1MTM1MDM5ODk2NDEzLCAtNjAuMzE1NTkzNTAwNjk4OTEgLTIyLjY4OTgwNDA3MDU2NjYsIC02MC4yOTg5OTQwMTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgTS02MC4zNTAxMDk5NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYgQy02MC4zMzEwMzAwNzEzMzY0MyAtMjIuNTY1OTY0NjI2MjM2MTEsIC02MC4zMTE5NTAxNzc2Mzk1IC0yMi43MTkwMzI1MjUxMTA1NiwgLTYwLjI5ODk5NDAxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBNLTYwLjI5ODk5NDAxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBDLTYwLjI4MTgyNDc2NjE5MjM1IC0yMi45MDQ4NTY4NjMzMDE0NDcsIC02MC4yNjQ2NTU1MTUzNzEwOSAtMjIuOTg2NzQwNzc1MTk5MjIzLCAtNjAuMjE0MTg4ODI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBNLTYwLjI5ODk5NDAxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBDLTYwLjI3MzgzMzQ2NzEwNzI0NSAtMjIuOTQyOTY5MTE2MjQ5Nzg1LCAtNjAuMjQ4NjcyOTE3MjAwODg1IC0yMy4wNjI5NjUyODEwOTU5LCAtNjAuMjE0MTg4ODI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBNLTYwLjIxNDE4ODgyOTY5NjY1IC0yMy4yMjc0Mjc0MzU3MDM5OTQgQy02MC4xNzIwMDkyNjUyMjY3OCAtMjMuMzY5MTA2MDk1MzAwODg2LCAtNjAuMTI5ODI5NzAwNzU2ODkgLTIzLjUxMDc4NDc1NDg5Nzc4LCAtNjAuMDk2MjczNzA4NTAzMTc2IC0yMy42MjM0OTczNDYwMjM0MTcgTS02MC4yMTQxODg4Mjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IEMtNjAuMTc0Nzg2NzQ5MjUyOTUgLTIzLjM1OTc3NjY5MDkxMjEzNSwgLTYwLjEzNTM4NDY2ODgwOTI2IC0yMy40OTIxMjU5NDYxMjAyNzUsIC02MC4wOTYyNzM3MDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBNLTYwLjA5NjI3MzcwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMDQ2MjgzODUzMzk3OTggLTIzLjc1MTYxMDM2NDkzOTQ1MiwgLTU5Ljk5NjI5Mzk5ODI5Mjc5IC0yMy44Nzk3MjMzODM4NTU0ODUsIC01OS45NDYwNTQxMzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IE0tNjAuMDk2MjczNzA4NTAzMTc2IC0yMy42MjM0OTczNDYwMjM0MTcgQy02MC4wNTE0Njg3NDUyMjMyNiAtMjMuNzM4MzIyNjI1OTk1MzgsIC02MC4wMDY2NjM3ODE5NDMzNDUgLTIzLjg1MzE0NzkwNTk2NzM0NywgLTU5Ljk0NjA1NDEzMzI3NTI5IC0yNC4wMDg0NzcxMjMyNjQ4NDYgTS01OS45NDYwNTQxMzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEMtNTkuODgzMzkwNzcwNjUwNzIgLTI0LjEzNjY1NzA4MDI3MzgxNCwgLTU5LjgyMDcyNzQwODAyNjE1IC0yNC4yNjQ4MzcwMzcyODI3ODUsIC01OS43NjQ1NTYyNTYwMzI0NSAtMjQuMzc5NzM2OTY1MTg1MzY2IE0tNTkuOTQ2MDU0MTMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBDLTU5LjkwNzUxNDgxNTMyMDU3IC0yNC4wODczMTA1NTgxNzMwMiwgLTU5Ljg2ODk3NTQ5NzM2NTg0NSAtMjQuMTY2MTQzOTkzMDgxMTk2LCAtNTkuNzY0NTU2MjU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM2NiBNLTU5Ljc2NDU1NjI1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNjYgQy01OS42ODYxOTkyNjI0NjUwNiAtMjQuNTExMjM2OTY3NTcxMjksIC01OS42MDc4NDIyNjg4OTc2NyAtMjQuNjQyNzM2OTY5OTU3MjEzLCAtNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzMgTS01OS43NjQ1NTYyNTYwMzI0NSAtMjQuMzc5NzM2OTY1MTg1MzY2IEMtNTkuNjk4NjE2NjM0MDczODggLTI0LjQ5MDM5NzkyOTE5MjYzMiwgLTU5LjYzMjY3NzAxMjExNTMxNiAtMjQuNjAxMDU4ODkzMTk5ODk4LCAtNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzMgTS01OS41NTMwMTk4OTEzMTI2NDQgLTI0LjczNDc0MDc5MDYxMjEzMyBDLTU5LjQ3NDcyMzM3OTAxODE5NCAtMjQuODQ0NDAxODc5MTQzOTM0LCAtNTkuMzk2NDI2ODY2NzIzNzQgLTI0Ljk1NDA2Mjk2NzY3NTc0LCAtNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IE0tNTkuNTUzMDE5ODkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzMgQy01OS41MDQ1Nzk2OTc4MjI0NyAtMjQuODAyNTg1NTAyODYyNDg4LCAtNTkuNDU2MTM5NTA0MzMyMyAtMjQuODcwNDMwMjE1MTEyODQ3LCAtNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IE0tNTkuMzEyODkwMDQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IEMtNTkuMjQ0MDkzMTg2OTk1MjYgLTI1LjE1MjI5MTg2MjU0MzY0MiwgLTU5LjE3NTI5NjMyNzAwODU0NiAtMjUuMjMzNTIwMTYxNjM4OTQzLCAtNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBNLTU5LjMxMjg5MDA0Njk4MTk3IC0yNS4wNzEwNjM1NjM0NDgzNCBDLTU5LjIxNDgxNjAwNzI3MDc3IC0yNS4xODY4NTkzNjMzMjQ3MzIsIC01OS4xMTY3NDE5Njc1NTk1OCAtMjUuMzAyNjU1MTYzMjAxMTIzLCAtNTkuMDQ1ODA3MDUzMzY1NjYgLTI1LjM4NjQwNzg1ODEyODcwNiBNLTU5LjA0NTgwNzA1MzM2NTY2IC0yNS4zODY0MDc4NTgxMjg3MDYgQy01OC45NDYzMDU2MjE3MjExNSAtMjUuNDg1OTA5Mjg5NzczMjE1LCAtNTguODQ2ODA0MTkwMDc2NjQgLTI1LjU4NTQxMDcyMTQxNzcyLCAtNTguNzUzNTk1MzU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgTS01OS4wNDU4MDcwNTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTguOTU4NzgyNjI2OTY3NjA0IC0yNS40NzM0MzIyODQ1MjY3NiwgLTU4Ljg3MTc1ODIwMDU2OTU1IC0yNS41NjA0NTY3MTA5MjQ4MDcsIC01OC43NTM1OTUzNTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBNLTU4Ljc1MzU5NTM1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IEMtNTguNjMxMjA4MDE4MTQ4NTM0IC0yNS43ODIyNzYzNDI3NjcxMzcsIC01OC41MDg4MjA2NzgxNjgzNiAtMjUuODg1OTMzMTMyMTY4NjE3LCAtNTguNDM4MjUxMDYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk2NiBNLTU4Ljc1MzU5NTM1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IEMtNTguNjc2MTM4MTg1NjgwNTg1IC0yNS43NDQyMjI0MzIwMDgwODcsIC01OC41OTg2ODEwMTMyMzI0NjUgLTI1LjgwOTgyNTMxMDY1MDUyLCAtNTguNDM4MjUxMDYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk2NiBNLTU4LjQzODI1MTA2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NjYgQy01OC4zMzQxMjA3OTEzMDE1IC0yNi4wMjAwNTAxMzIyNzg4NzUsIC01OC4yMjk5OTA1MTkxNTQ2NTQgLTI2LjA5NDM5NzcxNzU3NTc4NCwgLTU4LjEwMTkyODI5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IE0tNTguNDM4MjUxMDYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk2NiBDLTU4LjMzMzQxNTUwNzUwNTUxIC0yNi4wMjA1NTM2OTUyMzAzNTgsIC01OC4yMjg1Nzk5NTE1NjI2NyAtMjYuMDk1NDA0ODQzNDc4NzUsIC01OC4xMDE5MjgyOTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBNLTU4LjEwMTkyODI5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IEMtNTcuOTg1NTUyMjI5Mjg0MDk2IC0yNi4yNTUxNzc0NzIwOTkxNjMsIC01Ny44NjkxNzYxNjc5NTYwNTYgLTI2LjMyNDUyMjU1Mjg4NTY4LCAtNTcuNzQ2OTI0NDY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgTS01OC4xMDE5MjgyOTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDLTU3Ljk2MjYxMzUyNTg2NDYgLTI2LjI2ODg0NTk3MjUwNzMzLCAtNTcuODIzMjk4NzYxMTE3MDYgLTI2LjM1MTg1OTU1MzcwMjAxMywgLTU3Ljc0NjkyNDQ2NTE4NTM2NiAtMjYuMzk3MzY4NzU2MDMyNDQ2IE0tNTcuNzQ2OTI0NDY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgQy01Ny42MDI1MDg3OTcyNDUwMyAtMjYuNDY3OTY5Mjc0MDg1MjEzLCAtNTcuNDU4MDkzMTI5MzA0NjkgLTI2LjUzODU2OTc5MjEzNzk4NCwgLTU3LjM3NTY2NDYyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IE0tNTcuNzQ2OTI0NDY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgQy01Ny42MTMwMjA2NzMwNjY4MDYgLTI2LjQ2MjgzMDMzMTEzNDU3NiwgLTU3LjQ3OTExNjg4MDk0ODI1NCAtMjYuNTI4MjkxOTA2MjM2NzAzLCAtNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgTS01Ny4zNzU2NjQ2MjMyNjQ4NDYgLTI2LjU3ODg2NjYzMzI3NTI4NiBDLTU3LjI2MTk3MTEzNjQzNzAzIC0yNi42MjMyMjk5Njk0OTk5MzUsIC01Ny4xNDgyNzc2NDk2MDkyMyAtMjYuNjY3NTkzMzA1NzI0NTgsIC01Ni45OTA2ODQ4NDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIE0tNTcuMzc1NjY0NjIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQy01Ny4yNTk3MzY4Nzc0MjY3MiAtMjYuNjI0MTAxNzgwMDcxMDg2LCAtNTcuMTQzODA5MTMxNTg4NTkgLTI2LjY2OTMzNjkyNjg2Njg4MywgLTU2Ljk5MDY4NDg0NjAyMzQyIC0yNi43MjkwODYyMDg1MDMxNzMgTS01Ni45OTA2ODQ4NDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEMtNTYuODMzMDQ3MzMxNjM3Nzg2IC0yNi43NzYwMTY5Mjk4OTYzNSwgLTU2LjY3NTQwOTgxNzI1MjE0NSAtMjYuODIyOTQ3NjUxMjg5NTI1LCAtNTYuNTk0NjE0OTM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgTS01Ni45OTA2ODQ4NDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEMtNTYuOTA4MTY4MjY0ODA3NDkgLTI2Ljc1MzY1MjQ1OTEwOTU0LCAtNTYuODI1NjUxNjgzNTkxNTYgLTI2Ljc3ODIxODcwOTcxNTkwNSwgLTU2LjU5NDYxNDkzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIE0tNTYuNTk0NjE0OTM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgQy01Ni40OTE3NzM4ODQ4OTkyNDYgLTI2Ljg2ODU2NDgzMDQ1MTE1LCAtNTYuMzg4OTMyODM0MDk0NDkgLTI2Ljg5MDEyODMzMTIwNTY1LCAtNTYuMTkwMTYwNDUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBNLTU2LjU5NDYxNDkzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIEMtNTYuNDM5NDQxOTE2NjE2MTI2IC0yNi44Nzk1Mzc2OTAzMDM1ODIsIC01Ni4yODQyNjg4OTc1MjgyNSAtMjYuOTEyMDc0MDUwOTEwNTEyLCAtNTYuMTkwMTYwNDUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBNLTU2LjE5MDE2MDQ1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4wMjk3NzYyNjk4MDI4MyAtMjYuOTUxNzk4Mzg0NDAxMzEsIC01NS44NjkzOTIwODgyMDIgLTI2Ljk3MTc5MDI1MTc4OTAxLCAtNTUuNzgwMDg0MjI3MzYxNjYgLTI2Ljk4MjkyMjQ2NTAzMzM0NyBNLTU2LjE5MDE2MDQ1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgQy01Ni4wNTk0MzcxNzg5NDQ2OSAtMjYuOTQ4MTAxMTU1OTMyNDk1LCAtNTUuOTI4NzEzOTA2NDg1NzA0IC0yNi45NjQzOTU3OTQ4NTEzNzcsIC01NS43ODAwODQyMjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IE0tNTUuNzgwMDg0MjI3MzYxNjYgLTI2Ljk4MjkyMjQ2NTAzMzM0NyBDLTU1LjY2NTIzNDQ0MDkyNDg4IC0yNi45ODc2NzI2ODczMzc4OSwgLTU1LjU1MDM4NDY1NDQ4ODA5IC0yNi45OTI0MjI5MDk2NDI0MzYsIC01NS4zNjcxODc1IC0yNyBNLTU1Ljc4MDA4NDIyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgQy01NS42NjM0NzM5NDM5NzU3NCAtMjYuOTg3NzQ1NTAyMDMwMzIsIC01NS41NDY4NjM2NjA1ODk4MiAtMjYuOTkyNTY4NTM5MDI3MjkyLCAtNTUuMzY3MTg3NSAtMjcgTS01NS4zNjcxODc1IC0yNyBDLTU1LjM2NzE4NzUgLTI3LCAtNTUuMzY3MTg3NSAtMjcsIC01NS4zNjcxODc1IC0yNyBNLTU1LjM2NzE4NzUgLTI3IEMtNTUuMzY3MTg3NSAtMjcsIC01NS4zNjcxODc1IC0yNywgLTU1LjM2NzE4NzUgLTI3IiBzdHJva2U9IiM5MzcwREIiIHN0cm9rZS13aWR0aD0iMS4zIiBmaWxsPSJub25lIiBzdHJva2UtZGFzaGFycmF5PSIwIDAiIHN0eWxlPSIiLz48L2c+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNDUuMzY3MTg3NSwgLTEyKSI+PHJlY3QvPjxmb3JlaWduT2JqZWN0IHdpZHRoPSI5MC43MzQzNzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD5HbyBzaG9wcGluZzwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImZsb3djaGFydC1DLTMiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyMS44NTE1NjI1LCAzMTAuNTc4MTI1KSI+PHBvbHlnb24gcG9pbnRzPSI3MC41NzgxMjUsMCAxNDEuMTU2MjUsLTcwLjU3ODEyNSA3MC41NzgxMjUsLTE0MS4xNTYyNSAwLC03MC41NzgxMjUiIGNsYXNzPSJsYWJlbC1jb250YWluZXIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC03MC4wNzgxMjUsIDcwLjU3ODEyNSkiLz48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC00My41NzgxMjUsIC0xMikiPjxyZWN0Lz48Zm9yZWlnbk9iamVjdCB3aWR0aD0iODcuMTU2MjUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD5MZXQgbWUgdGhpbms8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJmbG93Y2hhcnQtRC01IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2Mi40Njg3NSwgNDgyLjE1NjI1KSI+PHJlY3QgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciIgc3R5bGU9IiIgeD0iLTU0LjQ2ODc1IiB5PSItMjciIHdpZHRoPSIxMDguOTM3NSIgaGVpZ2h0PSI1NCIvPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI0LjQ2ODc1LCAtMTIpIj48cmVjdC8+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjQ4LjkzNzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD5MYXB0b3A8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJmbG93Y2hhcnQtRS03IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjEuODUxNTYyNSwgNDgyLjE1NjI1KSI+PHJlY3QgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciIgc3R5bGU9IiIgeD0iLTU0LjkxNDA2MjUiIHk9Ii0yNyIgd2lkdGg9IjEwOS44MjgxMjUiIGhlaWdodD0iNTQiLz48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yNC45MTQwNjI1LCAtMTIpIj48cmVjdC8+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjQ5LjgyODEyNSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPjxwPmlQaG9uZTwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImZsb3djaGFydC1GLTkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDM3OS44ODI4MTI1LCA0ODIuMTU2MjUpIj48cmVjdCBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIiBzdHlsZT0iIiB4PSItNTMuMTE3MTg3NSIgeT0iLTI3IiB3aWR0aD0iMTA2LjIzNDM3NSIgaGVpZ2h0PSI1NCIvPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTIzLjExNzE4NzUsIC0xMikiPjxyZWN0Lz48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNDYuMjM0Mzc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+PHA+PGkgY2xhc3M9ImZhIGZhLWNhciI+PC9pPiBDYXI8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjwvZz48L2c+PC9nPjwvc3ZnPg==", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_WhenTheMermaidUrlIsNotDefined() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new MermaidImporter().importDiagram(view, new File("./src/test/resources/diagrams/mermaid/flowchart.mmd")); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Please define a view/viewset property named mermaid.url to specify your Mermaid server", e.getMessage()); + } + } + + @Test + public void importDiagram_WhenAnInvalidFormatIsSpecified() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_URL_PROPERTY, "https://mermaid.ink"); + workspace.getViews().getConfiguration().addProperty(MermaidImporter.MERMAID_FORMAT_PROPERTY, "jpg"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new MermaidImporter().importDiagram(view, "..."); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Expected a format of png or svg", e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoderTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoderTests.java new file mode 100644 index 000000000..e13f848ee --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLEncoderTests.java @@ -0,0 +1,21 @@ +package com.structurizr.importer.diagrams.plantuml; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlantUMLEncoderTests { + + @Test + public void encode() throws Exception { + File file = new File("./src/test/resources/diagrams/plantuml/with-title.puml"); + String plantuml = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + String encodedPlantuml = new PlantUMLEncoder().encode(plantuml); + assertEquals("SoWkIImgAStDuIh9BCb9LGXEBInDpKjELKZ9J4mlIinLIAr8p2t8IULooazIqBLJSCp914fQAMIavkJaSpcavgK0zG80", encodedPlantuml); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporterTests.java new file mode 100644 index 000000000..a9c270024 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/diagrams/plantuml/PlantUMLImporterTests.java @@ -0,0 +1,132 @@ +package com.structurizr.importer.diagrams.plantuml; + +import com.structurizr.Workspace; +import com.structurizr.http.HttpClient; +import com.structurizr.view.ImageView; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; + +public class PlantUMLImporterTests { + + @Test + public void importDiagram_WhenATitleIsDefined() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + ImageView view = workspace.getViews().createImageView("key"); + + new PlantUMLImporter().importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("Sequence diagram example", view.getTitle()); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuIh9BCb9LGXEBInDpKjELKZ9J4mlIinLIAr8p2t8IULooazIqBLJSCp914fQAMIavkJaSpcavgK0zG80", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_WhenATitleIsNotDefined() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + ImageView view = workspace.getViews().createImageView("key"); + + new PlantUMLImporter().importDiagram(view, new File("./src/test/resources/diagrams/plantuml/without-title.puml")); + assertEquals("key", view.getKey()); + assertNull(view.getElement()); + assertNull(view.getElementId()); + assertEquals("without-title.puml", view.getTitle()); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vt98pKi1IW80", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_AsPNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_FORMAT_PROPERTY, "png"); + ImageView view = workspace.getViews().createImageView("key"); + + new PlantUMLImporter().importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + assertEquals("https://plantuml.com/plantuml/png/SoWkIImgAStDuIh9BCb9LGXEBInDpKjELKZ9J4mlIinLIAr8p2t8IULooazIqBLJSCp914fQAMIavkJaSpcavgK0zG80", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlinePNG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_FORMAT_PROPERTY, "png"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new PlantUMLImporter(httpClient).importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + assertEquals("Sequence diagram example", view.getTitle()); + assertEquals("data:image/png;base64,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", view.getContent()); + assertEquals("image/png", view.getContentType()); + } + + @Test + public void importDiagram_AsSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_FORMAT_PROPERTY, "svg"); + ImageView view = workspace.getViews().createImageView("key"); + + new PlantUMLImporter().importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + assertEquals("https://plantuml.com/plantuml/svg/SoWkIImgAStDuIh9BCb9LGXEBInDpKjELKZ9J4mlIinLIAr8p2t8IULooazIqBLJSCp914fQAMIavkJaSpcavgK0zG80", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + @Tag("IntegrationTest") + public void importDiagram_AsInlineSVG() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_FORMAT_PROPERTY, "svg"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_INLINE_PROPERTY, "true"); + ImageView view = workspace.getViews().createImageView("key"); + + HttpClient httpClient = new HttpClient(); + httpClient.allow(".*"); + + new PlantUMLImporter(httpClient).importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + assertEquals("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBjb250ZW50U3R5bGVUeXBlPSJ0ZXh0L2NzcyIgZGF0YS1kaWFncmFtLXR5cGU9IlNFUVVFTkNFIiBoZWlnaHQ9IjE1OHB4IiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIiBzdHlsZT0id2lkdGg6MjQ2cHg7aGVpZ2h0OjE1OHB4O2JhY2tncm91bmQ6I0ZGRkZGRjsiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDI0NiAxNTgiIHdpZHRoPSIyNDZweCIgem9vbUFuZFBhbj0ibWFnbmlmeSI+PHRpdGxlPlNlcXVlbmNlIGRpYWdyYW0gZXhhbXBsZTwvdGl0bGU+PGRlZnMvPjxnPjx0ZXh0IGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iYm9sZCIgbGVuZ3RoQWRqdXN0PSJzcGFjaW5nIiB0ZXh0TGVuZ3RoPSIyMTguNjc0OCIgeD0iMTAiIHk9IjI3Ljk5NTEiPlNlcXVlbmNlIGRpYWdyYW0gZXhhbXBsZTwvdGV4dD48Zz48dGl0bGU+Qm9iPC90aXRsZT48cmVjdCBmaWxsPSIjMDAwMDAwIiBmaWxsLW9wYWNpdHk9IjAuMDAwMDAiIGhlaWdodD0iNDkuMTMyOCIgd2lkdGg9IjgiIHg9Ijg2LjQ3NzUiIHk9IjczLjU5MzgiLz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjAuNTtzdHJva2UtZGFzaGFycmF5OjUsNTsiIHgxPSI4OS45NDkyIiB4Mj0iODkuOTQ5MiIgeTE9IjczLjU5MzgiIHkyPSIxMjIuNzI2NiIvPjwvZz48Zz48dGl0bGU+QWxpY2U8L3RpdGxlPjxyZWN0IGZpbGw9IiMwMDAwMDAiIGZpbGwtb3BhY2l0eT0iMC4wMDAwMCIgaGVpZ2h0PSI0OS4xMzI4IiB3aWR0aD0iOCIgeD0iMTQxLjg5MjEiIHk9IjczLjU5MzgiLz48bGluZSBzdHlsZT0ic3Ryb2tlOiMxODE4MTg7c3Ryb2tlLXdpZHRoOjAuNTtzdHJva2UtZGFzaGFycmF5OjUsNTsiIHgxPSIxNDUuMDU4NiIgeDI9IjE0NS4wNTg2IiB5MT0iNzMuNTkzOCIgeTI9IjEyMi43MjY2Ii8+PC9nPjxnIGNsYXNzPSJwYXJ0aWNpcGFudCBwYXJ0aWNpcGFudC1oZWFkIiBkYXRhLXBhcnRpY2lwYW50PSJCb2IiPjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDEuMDU2NiIgeD0iNjkuOTQ5MiIgeT0iNDIuMjk2OSIvPjx0ZXh0IGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0IiBsZW5ndGhBZGp1c3Q9InNwYWNpbmciIHRleHRMZW5ndGg9IjI3LjA1NjYiIHg9Ijc2Ljk0OTIiIHk9IjYyLjI5MiI+Qm9iPC90ZXh0PjwvZz48ZyBjbGFzcz0icGFydGljaXBhbnQgcGFydGljaXBhbnQtdGFpbCIgZGF0YS1wYXJ0aWNpcGFudD0iQm9iIj48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQxLjA1NjYiIHg9IjY5Ljk0OTIiIHk9IjEyMS43MjY2Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTQiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMjcuMDU2NiIgeD0iNzYuOTQ5MiIgeT0iMTQxLjcyMTciPkJvYjwvdGV4dD48L2c+PGcgY2xhc3M9InBhcnRpY2lwYW50IHBhcnRpY2lwYW50LWhlYWQiIGRhdGEtcGFydGljaXBhbnQ9IkFsaWNlIj48cmVjdCBmaWxsPSIjRTJFMkYwIiBoZWlnaHQ9IjMwLjI5NjkiIHJ4PSIyLjUiIHJ5PSIyLjUiIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MC41OyIgd2lkdGg9IjQ3LjY2NyIgeD0iMTIyLjA1ODYiIHk9IjQyLjI5NjkiLz48dGV4dCBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgbGVuZ3RoQWRqdXN0PSJzcGFjaW5nIiB0ZXh0TGVuZ3RoPSIzMy42NjciIHg9IjEyOS4wNTg2IiB5PSI2Mi4yOTIiPkFsaWNlPC90ZXh0PjwvZz48ZyBjbGFzcz0icGFydGljaXBhbnQgcGFydGljaXBhbnQtdGFpbCIgZGF0YS1wYXJ0aWNpcGFudD0iQWxpY2UiPjxyZWN0IGZpbGw9IiNFMkUyRjAiIGhlaWdodD0iMzAuMjk2OSIgcng9IjIuNSIgcnk9IjIuNSIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDowLjU7IiB3aWR0aD0iNDcuNjY3IiB4PSIxMjIuMDU4NiIgeT0iMTIxLjcyNjYiLz48dGV4dCBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgbGVuZ3RoQWRqdXN0PSJzcGFjaW5nIiB0ZXh0TGVuZ3RoPSIzMy42NjciIHg9IjEyOS4wNTg2IiB5PSIxNDEuNzIxNyI+QWxpY2U8L3RleHQ+PC9nPjxnIGNsYXNzPSJtZXNzYWdlIiBkYXRhLXBhcnRpY2lwYW50LTE9IkJvYiIgZGF0YS1wYXJ0aWNpcGFudC0yPSJBbGljZSI+PHBvbHlnb24gZmlsbD0iIzE4MTgxOCIgcG9pbnRzPSIxMzMuODkyMSwxMDAuNzI2NiwxNDMuODkyMSwxMDQuNzI2NiwxMzMuODkyMSwxMDguNzI2NiwxMzcuODkyMSwxMDQuNzI2NiIgc3R5bGU9InN0cm9rZTojMTgxODE4O3N0cm9rZS13aWR0aDoxOyIvPjxsaW5lIHN0eWxlPSJzdHJva2U6IzE4MTgxODtzdHJva2Utd2lkdGg6MTsiIHgxPSI5MC40Nzc1IiB4Mj0iMTM5Ljg5MjEiIHkxPSIxMDQuNzI2NiIgeTI9IjEwNC43MjY2Ii8+PHRleHQgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTMiIGxlbmd0aEFkanVzdD0ic3BhY2luZyIgdGV4dExlbmd0aD0iMzEuNDE0NiIgeD0iOTcuNDc3NSIgeT0iOTkuNjYwNiI+aGVsbG88L3RleHQ+PC9nPjwhLS1TUkM9W0F5YWlvS2JMMjR1akI0dERJcXZMSUNiQ0oyekFwNUw4aEtaQ0JTWDl2TkJBSnJCR2pMRG1wQ2E0SWJlZlBBSmN2RUczMDAwMF0tLT48L2c+PC9zdmc+", view.getContent()); + assertEquals("image/svg+xml", view.getContentType()); + } + + @Test + public void importDiagram_WhenThePlantUMLURLIsNotSpecified() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new PlantUMLImporter().importDiagram(view, new File("./src/test/resources/diagrams/plantuml/with-title.puml")); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Please define a view/viewset property named plantuml.url to specify your PlantUML server", e.getMessage()); + } + } + + @Test + public void importDiagram_WhenAnInvalidFormatIsSpecified() throws Exception { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_URL_PROPERTY, "https://plantuml.com/plantuml"); + workspace.getViews().getConfiguration().addProperty(PlantUMLImporter.PLANTUML_FORMAT_PROPERTY, "jpg"); + ImageView view = workspace.getViews().createImageView("key"); + + try { + new PlantUMLImporter().importDiagram(view, "..."); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Expected a format of png or svg", e.getMessage()); + } + } + +} diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/AdrToolsDecisionImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/AdrToolsDecisionImporterTests.java new file mode 100644 index 000000000..7634ea81a --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/AdrToolsDecisionImporterTests.java @@ -0,0 +1,129 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentation; +import com.structurizr.documentation.Format; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + + +public class AdrToolsDecisionImporterTests { + + private static final File DECISIONS_FOLDER = new File("./src/test/resources/decisions/adrtools"); + + private AdrToolsDecisionImporter decisionImporter; + private Workspace workspace; + private Documentation documentation; + + @BeforeEach + public void setUp() { + decisionImporter = new AdrToolsDecisionImporter(); + workspace = new Workspace("Name", "Description"); + documentation = workspace.getDocumentation(); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenANullDocumentableIsSpecified() { + try { + decisionImporter.importDocumentation(null, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A workspace, software system, container, or component must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsNotSpecified() { + try { + decisionImporter.importDocumentation(workspace, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A path must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsSpecifiedButItDoesNotExist() { + try { + File directory = new File("foo"); + assertFalse(directory.exists()); + decisionImporter.importDocumentation(workspace, directory); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo does not exist.")); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenAPathIsSpecifiedButItIsNotADirectory() { + try { + decisionImporter.importDocumentation(workspace, new File("build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("/build.gradle is not a directory.")); + } + } + + @Test + public void test_importDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + + assertEquals(9, documentation.getDecisions().size()); + + Decision decision1 = documentation.getDecisions().stream().filter(d -> d.getId().equals("1")).findFirst().get(); + assertEquals("1", decision1.getId()); + assertEquals("Record architecture decisions", decision1.getTitle()); + SimpleDateFormat sdf = new SimpleDateFormat("dd-MMM-yyyy", Locale.US); + assertEquals("12-Feb-2016", sdf.format(decision1.getDate())); + assertEquals("Accepted", decision1.getStatus()); + Assertions.assertEquals(Format.Markdown, decision1.getFormat()); + assertEquals("# 1. Record architecture decisions\n" + + "\n" + + "Date: 2016-02-12\n" + + "\n" + + "## Status\n" + + "\n" + + "Accepted\n" + + "\n" + + "## Context\n" + + "\n" + + "We need to record the architectural decisions made on this project.\n" + + "\n" + + "## Decision\n" + + "\n" + + "We will use Architecture Decision Records, as described by Michael Nygard in this article: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions\n" + + "\n" + + "## Consequences\n" + + "\n" + + "See Michael Nygard's article, linked above.\n", + decision1.getContent()); + } + + @Test + public void test_importDocumentation_CapturesLinksBetweenDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + + Decision decision5 = documentation.getDecisions().stream().filter(d -> d.getId().equals("5")).findFirst().get(); + assertEquals(1, decision5.getLinks().size()); + Decision.Link link = decision5.getLinks().iterator().next(); + assertEquals("9", link.getId()); + assertEquals("Amended by", link.getDescription()); + } + + @Test + public void test_importDocumentation_RewritesLinksBetweenDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + + Decision decision5 = documentation.getDecisions().stream().filter(d -> d.getId().equals("5")).findFirst().get(); + assertTrue(decision5.getContent().contains("Amended by [9. Help scripts](#9)")); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultDocumentImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultDocumentImporterTests.java new file mode 100644 index 000000000..5b3835ff1 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultDocumentImporterTests.java @@ -0,0 +1,81 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class DefaultDocumentImporterTests { + + private Workspace workspace; + private DefaultDocumentationImporter documentationImporter; + + @BeforeEach + public void setUp() { + documentationImporter = new DefaultDocumentationImporter(); + workspace = new Workspace("Name", "Description"); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenANullDocumentableIsSpecified() { + try { + documentationImporter.importDocumentation(null, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A workspace, software system, container, or component must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenANullPathIsSpecified() { + try { + documentationImporter.importDocumentation(workspace, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A path must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenThePathDoesNotExist() { + try { + File directory = new File("foo"); + assertFalse(directory.exists()); + documentationImporter.importDocumentation(workspace, directory); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo does not exist.")); + } + } + + @Test + public void test_importDocumentation() { + File directory = new File("./src/test/resources/docs/docs"); + documentationImporter.importDocumentation(workspace, directory); + Collection<Section> sections = workspace.getDocumentation().getSections(); + assertEquals(6, sections.size()); + + assertSection(Format.Markdown, "## Section 1", 1, "01-section-1.md", sections.stream().filter(s -> s.getOrder() == 1).findFirst().get()); + assertSection(Format.Markdown, "## Section 2", 2, "02-section-2.markdown", sections.stream().filter(s -> s.getOrder() == 2).findFirst().get()); + assertSection(Format.Markdown, "## Section 3", 3, "03-section-3.text", sections.stream().filter(s -> s.getOrder() == 3).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 4", 4, "04-section-4.adoc", sections.stream().filter(s -> s.getOrder() == 4).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 5", 5, "05-section-5.asciidoc", sections.stream().filter(s -> s.getOrder() == 5).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 6", 6, "06-section-6.asc", sections.stream().filter(s -> s.getOrder() == 6).findFirst().get()); + } + + private void assertSection(Format format, String content, int order, String filename, Section section) { + assertTrue(workspace.getDocumentation().getSections().contains(section)); + assertEquals(format, section.getFormat()); + assertEquals(content, section.getContent()); + assertEquals(order, section.getOrder()); + assertEquals(filename, section.getFilename()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultImageImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultImageImporterTests.java new file mode 100644 index 000000000..58324c1b0 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/DefaultImageImporterTests.java @@ -0,0 +1,181 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Documentation; +import com.structurizr.documentation.Image; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class DefaultImageImporterTests { + + private Workspace workspace; + private DefaultImageImporter imageImporter; + + @BeforeEach + public void setUp() { + workspace = new Workspace("Name", "Description"); + imageImporter = new DefaultImageImporter(); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenTheDocumentableIsNull() { + try { + imageImporter.importDocumentation(null, null); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("A workspace or software system must be specified.", e.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenThePathIsNull() { + try { + imageImporter.importDocumentation(workspace, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A path must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenThePathDoesNotExist() { + try { + imageImporter.importDocumentation(workspace, new File("./src/test/resources/java/com/structurizr/documentation/foo")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo does not exist.")); + } + } + + @Test + public void test_importDocumentation_DoesNothing_WhenThereAreNoImageFilesInThePath() { + File directory = new File("./src/test/resources/docs/images/noimages"); + assertTrue(directory.exists()); + imageImporter.importDocumentation(workspace, directory); + assertTrue(workspace.getDocumentation().getImages().isEmpty()); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenTheSpecifiedDirectoryIsNotAnImage() throws IOException { + try { + imageImporter.importDocumentation(workspace, new File("README.md")); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage().endsWith("README.md is not a supported image file.")); + } + } + + @Test + public void test_importDocumentation_AddsAllImages_NonRecursively() { + Documentation documentation = workspace.getDocumentation(); + assertTrue(documentation.getImages().isEmpty()); + + imageImporter.importDocumentation(workspace, new File("./src/test/resources/docs/images/images")); + + Set<Image> images = documentation.getImages(); + assertEquals(4, documentation.getImages().size()); + + Image png = documentation.getImages().stream().filter(i -> i.getName().equals("image.png")).findFirst().get(); + assertEquals("image/png", png.getType()); + assertTrue(png.getContent().startsWith("iVBORw0KGgoAAAANSUhEUgAAACAAAAAaCAYAAADWm14/AAAD")); + assertTrue(images.contains(png)); + + Image jpg = documentation.getImages().stream().filter(i -> i.getName().equals("image.jpg")).findFirst().get(); + assertEquals("image/jpeg", jpg.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpg.getContent()); + assertTrue(images.contains(jpg)); + + Image jpeg = documentation.getImages().stream().filter(i -> i.getName().equals("image.jpeg")).findFirst().get(); + assertEquals("image/jpeg", jpeg.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpeg.getContent()); + assertTrue(images.contains(jpeg)); + + Image gif = documentation.getImages().stream().filter(i -> i.getName().equals("image.gif")).findFirst().get(); + assertEquals("image/gif", gif.getType()); + assertEquals("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", gif.getContent()); + assertTrue(images.contains(gif)); + } + + @Test + public void test_importDocumentation_AddsAllImages_Recursively() { + Documentation documentation = workspace.getDocumentation(); + assertTrue(documentation.getImages().isEmpty()); + + imageImporter.importDocumentation(workspace, new File("./src/test/resources/docs/images")); + assertEquals(9, documentation.getImages().size()); + + Image pngInDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("image.png")).findFirst().get(); + assertEquals("image/png", pngInDirectory.getType()); + assertTrue(pngInDirectory.getContent().startsWith("iVBORw0KGgoAAAANSUhEUgAAACAAAAAaCAYAAADWm14/AAAD")); + + Image jpgInDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("image.jpg")).findFirst().get(); + assertEquals("image/jpeg", jpgInDirectory.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpgInDirectory.getContent()); + + Image jpegInDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("image.jpeg")).findFirst().get(); + assertEquals("image/jpeg", jpegInDirectory.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpegInDirectory.getContent()); + + Image gifInDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("image.gif")).findFirst().get(); + assertEquals("image/gif", gifInDirectory.getType()); + assertEquals("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", gifInDirectory.getContent()); + + Image svgInDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("image.svg")).findFirst().get(); + assertEquals("image/svg+xml", svgInDirectory.getType()); + assertEquals("PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj4KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI0MCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIzIiBmaWxsPSJyZWQiIC8+Cjwvc3ZnPiA=", svgInDirectory.getContent()); + + Image pngInSubDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("images/image.png")).findFirst().get(); + assertEquals("image/png", pngInSubDirectory.getType()); + assertTrue(pngInSubDirectory.getContent().startsWith("iVBORw0KGgoAAAANSUhEUgAAACAAAAAaCAYAAADWm14/AAAD")); + + Image jpgInSubDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("images/image.jpg")).findFirst().get(); + assertEquals("image/jpeg", jpgInSubDirectory.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpgInSubDirectory.getContent()); + + Image jpegInSubDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("images/image.jpeg")).findFirst().get(); + assertEquals("image/jpeg", jpegInSubDirectory.getType()); + assertEquals("/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAaACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDxzUdRu9Vv5r69nea4mcu7uxJyTnv2GeBT49I1CWNXS1cqwyDkDP5mqLfdP0NeueF9Pt9Q1AR3Kb40h3begJ4HNerhqEal+boebiK0qdra3PNP7D1L/nzf/vpf8afbnVvD17BqEPnWs0UgZJVbHIOccHvjoete8/8ACP6R/wA+EP5V598R9Ot9PttttHsSRUbaOgIcDitZ4anytpszhXqcyTS1PNHVl3KykMMgg8c16n4b1q20+6W5dt8MkWwlCCR05x+Fc78UraC1+IGqx28EcKeYW2xoFGSTk4Hc1x5RSCSoz9KwoV/Zpu17m9ah7RpXtY99/wCEy0j+9N/3x/8AXrhfH+swavEotwf4I0U43Md2ScCvPPLT+4v5V2XwttLa5+IOlRz28UqeaG2yIGGQRg4NVPFrlaUfxFHC2km5bH//2Q==", jpegInSubDirectory.getContent()); + + Image gifInSubDirectory = documentation.getImages().stream().filter(i -> i.getName().equals("images/image.gif")).findFirst().get(); + assertEquals("image/gif", gifInSubDirectory.getType()); + assertEquals("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", gifInSubDirectory.getContent()); + } + + @Test + public void test_importDocumentation_AddsASingleImage() { + Documentation documentation = workspace.getDocumentation(); + assertTrue(documentation.getImages().isEmpty()); + + imageImporter.importDocumentation(workspace, new File("./src/test/resources/docs/images/image.png")); + assertEquals(1, documentation.getImages().size()); + + Image png = documentation.getImages().stream().filter(i -> i.getName().equals("image.png")).findFirst().get(); + assertEquals("image/png", png.getType()); + assertTrue(png.getContent().startsWith("iVBORw0KGgoAAAANSUhEUgAAACAAAAAaCAYAAADWm14/AAAD")); + } + + @Test + public void test_importDocumentation_IgnoresHiddenFolders() throws Exception { + Documentation documentation = workspace.getDocumentation(); + + File tempDirectory = Files.createTempDirectory("test").toFile(); + File hiddenFolder = new File(tempDirectory, ".structurizr"); + hiddenFolder.mkdir(); + + File source = new File("./src/test/resources/docs/images/images/image.png"); + File destination = new File(hiddenFolder, "image.png"); + Files.copy(source.toPath(), destination.toPath()); + assertTrue(destination.exists()); + + imageImporter.importDocumentation(workspace, tempDirectory); + assertEquals(0, documentation.getImages().size()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/FormatFinderTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/FormatFinderTests.java new file mode 100644 index 000000000..14c530a71 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/FormatFinderTests.java @@ -0,0 +1,38 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.documentation.Format; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class FormatFinderTests { + + @Test + public void test_findFormat_ThrowsAnException_WhenAFileIsNotSpecified() { + try { + FormatFinder.findFormat(null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A file must be specified.", iae.getMessage()); + } + } + + @Test + public void test_findFormat_ReturnsMarkdown_WhenAMarkdownFileIsSpecified() { + Assertions.assertEquals(Format.Markdown, FormatFinder.findFormat(new File("foo.md"))); + assertEquals(Format.Markdown, FormatFinder.findFormat(new File("foo.markdown"))); + assertEquals(Format.Markdown, FormatFinder.findFormat(new File("foo.text"))); + } + + @Test + public void test_findFormat_ReturnsAsciiDoc_WhenAnAsciiDocFileIsSpecified() { + assertEquals(Format.AsciiDoc, FormatFinder.findFormat(new File("foo.adoc"))); + assertEquals(Format.AsciiDoc, FormatFinder.findFormat(new File("foo.asciidoc"))); + assertEquals(Format.AsciiDoc, FormatFinder.findFormat(new File("foo.asc"))); + } + +} diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/Log4brainsDecisionImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/Log4brainsDecisionImporterTests.java new file mode 100644 index 000000000..e2314c0b9 --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/Log4brainsDecisionImporterTests.java @@ -0,0 +1,230 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentation; +import com.structurizr.documentation.Format; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + + +public class Log4brainsDecisionImporterTests { + + private static final File DECISIONS_FOLDER = new File("./src/test/resources/decisions/log4brains"); + + private Log4brainsDecisionImporter decisionImporter; + private Workspace workspace; + private Documentation documentation; + + @BeforeEach + public void setUp() { + decisionImporter = new Log4brainsDecisionImporter(); + workspace = new Workspace("Name", "Description"); + documentation = workspace.getDocumentation(); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenANullDocumentableIsSpecified() { + try { + decisionImporter.importDocumentation(null, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A workspace, software system, container, or component must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsNotSpecified() { + try { + decisionImporter.importDocumentation(workspace, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A path must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsSpecifiedButItDoesNotExist() { + try { + File directory = new File("foo"); + assertFalse(directory.exists()); + decisionImporter.importDocumentation(workspace, directory); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo does not exist.")); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenAPathIsSpecifiedButItIsNotADirectory() { + try { + decisionImporter.importDocumentation(workspace, new File("build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("/build.gradle is not a directory.")); + } + } + + @Test + public void test_importDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + SimpleDateFormat sdf = new SimpleDateFormat("dd-MMM-yyyy", Locale.US); + + assertEquals(4, documentation.getDecisions().size()); + + // I think these first two decisions are found in the wrong order, which is a consequence of Log4brains not having decision IDs + + Decision decision1 = documentation.getDecisions().stream().filter(d -> d.getId().equals("1")).findFirst().get(); + assertEquals("1", decision1.getId()); + assertEquals("Use Log4brains to manage the ADRs", decision1.getTitle()); + assertEquals("10-Jan-2024", sdf.format(decision1.getDate())); + assertEquals("accepted", decision1.getStatus()); + Assertions.assertEquals(Format.Markdown, decision1.getFormat()); + assertEquals(""" +# Use Log4brains to manage the ADRs + +- Status: accepted +- Date: 2024-01-10 +- Tags: dev-tools, doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which tool(s) should we use to manage these records? + +## Considered Options + +- [Log4brains](https://github.com/thomvaill/log4brains): architecture knowledge base (command-line + static site generator) +- [ADR Tools](https://github.com/npryce/adr-tools): command-line to create ADRs +- [ADR Tools Python](https://bitbucket.org/tinkerer_/adr-tools-python/src/master/): command-line to create ADRs +- [adr-viewer](https://github.com/mrwilson/adr-viewer): static site generator +- [adr-log](https://adr.github.io/adr-log/): command-line to create a TOC of ADRs + +## Decision Outcome + +Chosen option: "Log4brains", because it includes the features of all the other tools, and even more. +""", + decision1.getContent()); + + Decision decision2 = documentation.getDecisions().stream().filter(d -> d.getId().equals("2")).findFirst().get(); + assertEquals("2", decision2.getId()); + assertEquals("Use Markdown Architectural Decision Records", decision2.getTitle()); + assertEquals("10-Jan-2024", sdf.format(decision2.getDate())); + assertEquals("accepted", decision2.getStatus()); + Assertions.assertEquals(Format.Markdown, decision2.getFormat()); + assertEquals(""" +# Use Markdown Architectural Decision Records + +- Status: accepted +- Date: 2024-01-10 +- Tags: doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with Log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with Log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. +- The Log4brains patch adds more features, like tags. + +The "Log4brains patch" performs the following modifications to the original template: + +- Change the ADR filenames format (`NNN-adr-name` becomes `YYYYMMDD-adr-name`), to avoid conflicts during Git merges. +- Add a `draft` status, to enable collaborative writing. +- Add a `Tags` field. + +## Links + +- Relates to [Use Log4brains to manage the ADRs](#1) +""", + decision2.getContent()); + + Decision decision3 = documentation.getDecisions().stream().filter(d -> d.getId().equals("3")).findFirst().get(); + assertEquals("3", decision3.getId()); + assertEquals("Decision 3", decision3.getTitle()); + assertEquals("13-Jan-2024", sdf.format(decision3.getDate())); + assertEquals("superseded", decision3.getStatus()); + Assertions.assertEquals(Format.Markdown, decision3.getFormat()); + assertEquals(""" +# Decision 3 + +- Status: superseded by [20240111-decision-4](#4) + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. +""", + decision3.getContent()); + + Decision decision4 = documentation.getDecisions().stream().filter(d -> d.getId().equals("4")).findFirst().get(); + assertEquals("4", decision4.getId()); + assertEquals("Decision 4", decision4.getTitle()); + assertEquals("14-Jan-2024", sdf.format(decision4.getDate())); + assertEquals("accepted", decision4.getStatus()); + Assertions.assertEquals(Format.Markdown, decision4.getFormat()); + assertEquals(""" +# Decision 4 + +- Status: accepted + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. + +## Links + +- Supersedes [20240111-decision-3](#3) +""", + decision4.getContent()); + } + + @Test + public void test_importDocumentation_CapturesLinksBetweenDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + + Decision decision2 = documentation.getDecisions().stream().filter(d -> d.getId().equals("2")).findFirst().get(); + assertEquals(1, decision2.getLinks().size()); + Decision.Link link = decision2.getLinks().iterator().next(); + assertEquals("1", link.getId()); + assertEquals("Relates to", link.getDescription()); + + Decision decision3 = documentation.getDecisions().stream().filter(d -> d.getId().equals("3")).findFirst().get(); + assertEquals(1, decision3.getLinks().size()); + link = decision3.getLinks().iterator().next(); + assertEquals("4", link.getId()); + assertEquals("superseded by", link.getDescription()); + + Decision decision4 = documentation.getDecisions().stream().filter(d -> d.getId().equals("4")).findFirst().get(); + assertEquals(1, decision4.getLinks().size()); + link = decision4.getLinks().iterator().next(); + assertEquals("3", link.getId()); + assertEquals("Supersedes", link.getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/MadrDecisionImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/MadrDecisionImporterTests.java new file mode 100644 index 000000000..6fa7989fc --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/MadrDecisionImporterTests.java @@ -0,0 +1,235 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Documentation; +import com.structurizr.documentation.Format; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + + +public class MadrDecisionImporterTests { + + private static final File DECISIONS_FOLDER = new File("./src/test/resources/decisions/madr"); + + private MadrDecisionImporter decisionImporter; + private Workspace workspace; + private Documentation documentation; + + @BeforeEach + public void setUp() { + decisionImporter = new MadrDecisionImporter(); + workspace = new Workspace("Name", "Description"); + documentation = workspace.getDocumentation(); + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenANullDocumentableIsSpecified() { + try { + decisionImporter.importDocumentation(null, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A workspace, software system, container, or component must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsNotSpecified() { + try { + decisionImporter.importDocumentation(workspace, null); + fail(); + } catch (IllegalArgumentException iae) { + assertEquals("A path must be specified.", iae.getMessage()); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenADirectoryIsSpecifiedButItDoesNotExist() { + try { + File directory = new File("foo"); + assertFalse(directory.exists()); + decisionImporter.importDocumentation(workspace, directory); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("foo does not exist.")); + } + } + + @Test + public void test_importDocumentation_ThrowsAnException_WhenAPathIsSpecifiedButItIsNotADirectory() { + try { + decisionImporter.importDocumentation(workspace, new File("build.gradle")); + fail(); + } catch (IllegalArgumentException iae) { + assertTrue(iae.getMessage().endsWith("/build.gradle is not a directory.")); + } + } + + @Test + public void test_importDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + SimpleDateFormat sdf = new SimpleDateFormat("dd-MMM-yyyy", Locale.US); + + assertEquals(19, documentation.getDecisions().size()); + + Decision decision0 = documentation.getDecisions().stream().filter(d -> d.getId().equals("0")).findFirst().get(); + assertEquals("0", decision0.getId()); + assertEquals("Use Markdown Any Decision Records", decision0.getTitle()); + assertEquals("11-Jan-2024", sdf.format(decision0.getDate())); + assertEquals("accepted", decision0.getStatus()); + Assertions.assertEquals(Format.Markdown, decision0.getFormat()); + assertEquals(""" +# Use Markdown Any Decision Records + +## Context and Problem Statement + +We want to record any decisions made in this project independent whether decisions concern the architecture ("architectural decision record"), the code, or other fields. +Which format and structure should these records follow? + +## Considered Options + +* [MADR](https://adr.github.io/madr/) 3.0.0 – The Markdown Any Decision Records +* [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +* [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +* Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +* Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 3.0.0", because + +* Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +* MADR allows for structured capturing of any decision. +* The MADR format is lean and fits our development style. +* The MADR structure is comprehensible and facilitates usage & maintenance. +* The MADR project is vivid. +""", decision0.getContent()); + + Decision decision3 = documentation.getDecisions().stream().filter(d -> d.getId().equals("3")).findFirst().get(); + assertEquals("on hold", decision3.getStatus()); + + Decision decision8 = documentation.getDecisions().stream().filter(d -> d.getId().equals("8")).findFirst().get(); + assertEquals(""" +# Add Status Field + +## Context and Problem Statement + +Technical Story: <https://github.com/adr/madr/issues/2> + +ADRs have a status. Should this be tracked? And if it should, how should we track it? + +## Considered Options + +* Use YAML front matter +* Use badge +* Use text line +* Use separate heading +* Use table +* Do not add status + +## Decision Outcome + +Chosen option: "Use YAML front matter", because comes out best (see below). + +## Pros and Cons of the Options + +### Use YAML front matter + +Example: + +```markdown +--- +parent: Decisions +nav_order: 3 +status: on hold +--- +# Write own MADR tooling +``` + +* Good, because YAML front matter is supported by most Markdown parsers + +### Use badge + +#### Examples + +* ![Example "Use Angular" with "status: accepted"](0008-example-badge.png) +* [![Example "status: superseded"](https://img.shields.io/badge/status-superseeded_by_ADR_0001-orange.svg?style=flat-square)](https://github.com/adr/madr/blob/main/docs/decisions/0001-use-CC0-as-license.md) + +--- + +* Good, because plain markdown +* Good, because looks good +* Bad, because hard to read in markdown source +* Bad, because relies on the online service <https://shields.io> or [local badges have to be generated](https://github.com/badges/shields#using-the-badge-library) +* Bad, because at local usages, many badges have to be generated (superseeded-by-ADR-0006, for each ADR number) +* Bad, because not easy to write + +### Use text line + +Example: `Status: Accepted` + +* Good, because plain markdown +* Good, because easy to read +* Good, because easy to write +* Good, because looks OK in both markdown-source (MD) and in rendered versions (HTML, PDF) +* Good, because no dependencies on external tools +* Good, because single line indicates the current state +* Bad, because "Status" line needs to be maintained +* Bad, because uses space at the beginning. When users read MADR, they should directly dive into the context and problem and not into the status + +### Use separate heading + +Example: ![example for separate heading](0008-example-separate-heading.png) + +* Good, because plain markdown +* Good, because easy to write +* Bad, because it uses much space: At least three lines: heading, status, separating empty line + +### Use table + +Example: ![example for table](0008-example-table.png) + +* Good, because history can be included +* Good, because multiple entries can be made +* Good, because already implemented in `adr-tools` fork +* Bad, because not covered by the [CommonMark specification 0.28 (2017-08-01)](http://spec.commonmark.org/0.28/) +* Bad, because hard to read +* Bad, because outdated entries cannot be easily identified +* Bad, because needs more markdown training + +### Do not add status + +* Good, because MADR is kept lean +* Bad, because users demand state field +* Bad, because not in line with other ADR templates + +## More Information + +See [ADR-0013](#13) for more reasoning on using YAML front matter. + """, decision8.getContent()); + + Decision decision18 = documentation.getDecisions().stream().filter(d -> d.getId().equals("18")).findFirst().get(); + assertEquals("Use \"Confirmation\" as Heading", decision18.getTitle()); + } + + @Test + public void test_importDocumentation_CapturesLinksBetweenDecisions() { + decisionImporter.importDocumentation(workspace, DECISIONS_FOLDER); + + Decision decision8 = documentation.getDecisions().stream().filter(d -> d.getId().equals("8")).findFirst().get(); + assertEquals(1, decision8.getLinks().size()); + Decision.Link link = decision8.getLinks().iterator().next(); + assertEquals("13", link.getId()); + assertEquals("Links to", link.getDescription()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentImporterTests.java b/structurizr-import/src/test/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentImporterTests.java new file mode 100644 index 000000000..76cc55dca --- /dev/null +++ b/structurizr-import/src/test/java/com/structurizr/importer/documentation/RecursiveDefaultDocumentImporterTests.java @@ -0,0 +1,51 @@ +package com.structurizr.importer.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RecursiveDefaultDocumentImporterTests { + + private Workspace workspace; + private RecursiveDefaultDocumentationImporter documentationImporter; + + @BeforeEach + public void setUp() { + documentationImporter = new RecursiveDefaultDocumentationImporter(); + workspace = new Workspace("Name", "Description"); + } + + @Test + public void test_importDocumentation_WithRecursiveSetToTrue() { + File directory = new File("./src/test/resources/docs/docs"); + + documentationImporter.importDocumentation(workspace, directory); + Collection<Section> sections = workspace.getDocumentation().getSections(); + assertEquals(7, sections.size()); + + assertSection(Format.Markdown, "## Section 1", 1, "01-section-1.md", sections.stream().filter(s -> s.getOrder() == 1).findFirst().get()); + assertSection(Format.Markdown, "## Section 2", 2, "02-section-2.markdown", sections.stream().filter(s -> s.getOrder() == 2).findFirst().get()); + assertSection(Format.Markdown, "## Section 3", 3, "03-section-3.text", sections.stream().filter(s -> s.getOrder() == 3).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 4", 4, "04-section-4.adoc", sections.stream().filter(s -> s.getOrder() == 4).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 5", 5, "05-section-5.asciidoc", sections.stream().filter(s -> s.getOrder() == 5).findFirst().get()); + assertSection(Format.AsciiDoc, "== Section 6", 6, "06-section-6.asc", sections.stream().filter(s -> s.getOrder() == 6).findFirst().get()); + assertSection(Format.Markdown, "## Section 7", 7, "07-subdirectory/01-section-1.md", sections.stream().filter(s -> s.getOrder() == 7).findFirst().get()); + } + + private void assertSection(Format format, String content, int order, String filename, Section section) { + assertTrue(workspace.getDocumentation().getSections().contains(section)); + assertEquals(format, section.getFormat()); + assertEquals(content, section.getContent()); + assertEquals(order, section.getOrder()); + assertEquals(filename, section.getFilename()); + } + +} \ No newline at end of file diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0001-record-architecture-decisions.md b/structurizr-import/src/test/resources/decisions/adrtools/0001-record-architecture-decisions.md new file mode 100644 index 000000000..f30860000 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0001-record-architecture-decisions.md @@ -0,0 +1,19 @@ +# 1. Record architecture decisions + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project. + +## Decision + +We will use Architecture Decision Records, as described by Michael Nygard in this article: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions + +## Consequences + +See Michael Nygard's article, linked above. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0002-implement-as-shell-scripts.md b/structurizr-import/src/test/resources/decisions/adrtools/0002-implement-as-shell-scripts.md new file mode 100644 index 000000000..8e6ea15e6 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0002-implement-as-shell-scripts.md @@ -0,0 +1,28 @@ +# 2. Implement as shell scripts + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +ADRs are plain text files stored in a subdirectory of the project. + +The tool needs to create new files and apply small edits to +the Status section of existing files. + +## Decision + +The tool is implemented as shell scripts that use standard Unix +tools -- grep, sed, awk, etc. + +## Consequences + +The tool won't support Windows. Being plain text files, ADRs can +be created by hand and edited in any text editor. This tool just +makes the process more convenient. + +Development will have to cope with differences between Unix +variants, particularly Linux and MacOS X. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0003-single-command-with-subcommands.md b/structurizr-import/src/test/resources/decisions/adrtools/0003-single-command-with-subcommands.md new file mode 100644 index 000000000..f64db8da1 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0003-single-command-with-subcommands.md @@ -0,0 +1,45 @@ +# 3. Single command with subcommands + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +The tool provides a number of related commands to create +and manipulate architecture decision records. + +How can the user find out about the commands that are available? + +## Decision + +The tool defines a single command, called `adr`. + +The first argument to `adr` (the subcommand) specifies the +action to perform. Further arguments are interpreted by the +subcommand. + +Running `adr` without any arguments lists the available +subcommands. + +Subcommands are implemented as scripts in the same +directory as the `adr` script. E.g. the subcommand `new` is +implemented as the script `adr-new`, the subcommand `help` +as the script `adr-help` and so on. + +Helper scripts that are part of the implementation but not +subcommands follow a different naming convention, so that +subcommands can be listed by filtering and transforming script +file names. + +## Consequences + +Users can more easily explore the capabilities of the tool. + +Users are already used to this style of command-line tool. For +example, Git works this way. + +Each subcommand can be implemented in the most appropriate +language. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0004-markdown-format.md b/structurizr-import/src/test/resources/decisions/adrtools/0004-markdown-format.md new file mode 100644 index 000000000..160d1689f --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0004-markdown-format.md @@ -0,0 +1,40 @@ +# 4. Markdown format + +Date: 2016-02-12 + +## Status + +Accepted + +## Context + +The decision records must be stored in a plain text format: + +* This works well with version control systems. + +* It allows the tool to modify the status of records and insert + hyperlinks when one decision supercedes another. + +* Decisions can be read in the terminal, IDE, version control + browser, etc. + +People will want to use some formatting: lists, code examples, +and so on. + +People will want to view the decision records in a more readable +format than plain text, and maybe print them out. + + +## Decision + +Record architecture decisions in [Markdown format](https://daringfireball.net/projects/markdown/). + +## Consequences + +Decisions can be read in the terminal. + +Decisions will be formatted nicely and hyperlinked by the +browsers of project hosting sites like GitHub and Bitbucket. + +Tools like [Pandoc](http://pandoc.org/) can be used to convert +the decision records into HTML or PDF. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0005-help-comments.md b/structurizr-import/src/test/resources/decisions/adrtools/0005-help-comments.md new file mode 100644 index 000000000..b19bf0fb1 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0005-help-comments.md @@ -0,0 +1,42 @@ +# 5. Help comments + +Date: 2016-02-13 + +## Status + +Accepted + +Amended by [9. Help scripts](0009-help-scripts.md) + +## Context + +The tool will have a `help` subcommand to provide documentation +for users. + +It's nice to have usage documentation in the script files +themselves, in comments. When reading the code, that's the first +place to look for information about how to run a script. + +## Decision + +Write usage documentation in comments in the source file. + +Distinguish between documentation comments and normal comments. +Documentation comments have two hash characters at the start of +the line. + +The `adr help` command can parse comments out from the script +using the standard Unix tools `grep` and `cut`. + +## Consequences + +No need to maintain help text in a separate file. + +Help text can easily be kept up to date as the script is edited. + +There's no automated check that the help text is up to date. The +tests do not work well as documentation for users, and the help +text is not easily cross-checked against the code. + +This won't work if any subcommands are not implemented as scripts +that use '#' as a comment character. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md b/structurizr-import/src/test/resources/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md new file mode 100644 index 000000000..4a3485f79 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0006-packaging-and-distribution-in-other-version-control-repositories.md @@ -0,0 +1,41 @@ +# 6. Packaging and distribution in other version control repositories + +Date: 2016-02-16 + +## Status + +Accepted + +## Context + +Users want to install adr-tools with their preferred package +manager. For example, Ubuntu users use `apt`, RedHat users use +`yum` and Mac OS X users use [Homebrew](http://brew.sh). + +The developers of `adr-tools` don't know how, nor have permissions, +to use all these packaging and distribution systems. Therefore packaging +and distribution must be done by "downstream" parties. + +The developers of the tool should not favour any one particular +packaging and distribution solution. + +## Decision + +The `adr-tools` project will not contain any packaging or +distribution scripts and config. + +Packaging and distribution will be managed by other projects in +separate version control repositories. + +## Consequences + +The git repo of this project will be simpler. + +Eventually, users will not have to use Git to get the software. + +We will have to tag releases in the `adr-tools` repository so that +packaging projects know what can be published and how it should be +identified. + +We will document how users can install the software in this +project's README file. diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md b/structurizr-import/src/test/resources/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md new file mode 100644 index 000000000..a649b2356 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0007-invoke-adr-config-executable-to-get-configuration.md @@ -0,0 +1,31 @@ +# 7. Invoke adr-config executable to get configuration + +Date: 2016-12-17 + +## Status + +Accepted + +## Context + +Packagers (e.g. Homebrew developers) want to configure adr-tools to match the conventions of their installation. + +Currently, this is done by sourcing a file `config.sh`, which should sit beside the `adr` executable. + +This name is too common. + +The `config.sh` file is not executable, and so doesn't belong in a bin directory. + +## Decision + +Replace `config.sh` with an executable, named `adr-config` that outputs configuration. + +Each script in ADR Tools will eval the output of `adr-config` to configure itself. + +## Consequences + +Configuration within ADR Tools is a little more complicated. + +Packagers can write their own implementation of `adr-config` that outputs configuration that matches the platform's installation conventions, and deploy it next to the `adr` script. + +To make development easier, the implementation of `adr-config` in the project's src/ directory will output configuration that lets the tool to run from the src/ directory without any installation step. (Packagers should not include this script in a deployable package.) diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0008-use-iso-8601-format-for-dates.md b/structurizr-import/src/test/resources/decisions/adrtools/0008-use-iso-8601-format-for-dates.md new file mode 100644 index 000000000..4146f11df --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0008-use-iso-8601-format-for-dates.md @@ -0,0 +1,43 @@ +# 8. Use ISO 8601 Format for Dates + +Date: 2017-02-21 + +## Status + +Accepted + +## Context + +`adr-tools` seeks to communicate the history of architectural decisions of a +project. An important component of the history is the time at which a decision +was made. + +To communicate effectively, `adr-tools` should present information as +unambiguously as possible. That means that culture-neutral data formats should +be preferred over culture-specific formats. + +Existing `adr-tools` deployments format dates as `dd/mm/yyyy` by default. That +formatting is common formatting in the United Kingdom (where the `adr-tools` +project was originally written), but is easily confused with the `mm/dd/yyyy` +format preferred in the United States. + +The default date format may be overridden by setting `ADR_DATE` in `config.sh`. + +## Decision + +`adr-tools` will use the ISO 8601 format for dates: `yyyy-mm-dd` + +## Consequences + +Dates are displayed in a standard, culture-neutral format. + +The UK-style and ISO 8601 formats can be distinguished by their separator +character. The UK-style dates used a slash (`/`), while the ISO dates use a +hyphen (`-`). + +Prior to this decision, `adr-tools` was deployed using the UK format for dates. +After adopting the ISO 8601 format, existing deployments of `adr-tools` must do +one of the following: + + * Accept mixed formatting of dates within their documentation library. + * Update existing documents to use ISO 8601 dates by running `adr upgrade-repository` diff --git a/structurizr-import/src/test/resources/decisions/adrtools/0009-help-scripts.md b/structurizr-import/src/test/resources/decisions/adrtools/0009-help-scripts.md new file mode 100644 index 000000000..0146d127c --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/adrtools/0009-help-scripts.md @@ -0,0 +1,28 @@ +# 9. Help scripts + +Date: 2018-06-26 + +## Status + +Accepted + +Amends [5. Help comments](0005-help-comments.md) + +## Context + +Currently help text is generated by extracting specially formatted comments from the top of the command script. + +This makes it easy for developers of the tool: documentation and code is all in one place. + +But, it means that help text cannot include calculated values, such as the location of files. + +## Decision + +Where necessary, help text can be generated by a script. + +The script will be called _adr_help_<command>_<subcommand> + +## Consequences + +Help scripts can include helper scripts to locate files, giving more accurate instructions to the user that reflect how the tool is deployed in their environment. + diff --git a/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md b/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md new file mode 100644 index 000000000..26cceeb7a --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-log4brains-to-manage-the-adrs.md @@ -0,0 +1,22 @@ +# Use Log4brains to manage the ADRs + +- Status: accepted +- Date: 2024-01-10 +- Tags: dev-tools, doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which tool(s) should we use to manage these records? + +## Considered Options + +- [Log4brains](https://github.com/thomvaill/log4brains): architecture knowledge base (command-line + static site generator) +- [ADR Tools](https://github.com/npryce/adr-tools): command-line to create ADRs +- [ADR Tools Python](https://bitbucket.org/tinkerer_/adr-tools-python/src/master/): command-line to create ADRs +- [adr-viewer](https://github.com/mrwilson/adr-viewer): static site generator +- [adr-log](https://adr.github.io/adr-log/): command-line to create a TOC of ADRs + +## Decision Outcome + +Chosen option: "Log4brains", because it includes the features of all the other tools, and even more. diff --git a/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md b/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md new file mode 100644 index 000000000..f5ee1949a --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/log4brains/20240111-use-markdown-architectural-decision-records.md @@ -0,0 +1,42 @@ +# Use Markdown Architectural Decision Records + +- Status: accepted +- Date: 2024-01-10 +- Tags: doc + +## Context and Problem Statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 2.1.2 with Log4brains patch +- [MADR](https://adr.github.io/madr/) 2.1.2 – The original Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.2 with Log4brains patch", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. +- Version 2.1.2 is the latest one available when starting to document ADRs. +- The Log4brains patch adds more features, like tags. + +The "Log4brains patch" performs the following modifications to the original template: + +- Change the ADR filenames format (`NNN-adr-name` becomes `YYYYMMDD-adr-name`), to avoid conflicts during Git merges. +- Add a `draft` status, to enable collaborative writing. +- Add a `Tags` field. + +## Links + +- Relates to [Use Log4brains to manage the ADRs](20240111-use-log4brains-to-manage-the-adrs.md) diff --git a/structurizr-import/src/test/resources/decisions/log4brains/20240113-decision-3.md b/structurizr-import/src/test/resources/decisions/log4brains/20240113-decision-3.md new file mode 100644 index 000000000..4a2c40918 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/log4brains/20240113-decision-3.md @@ -0,0 +1,7 @@ +# Decision 3 + +- Status: superseded by [20240111-decision-4](20240114-decision-4.md) + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. diff --git a/structurizr-import/src/test/resources/decisions/log4brains/20240114-decision-4.md b/structurizr-import/src/test/resources/decisions/log4brains/20240114-decision-4.md new file mode 100644 index 000000000..bff063a29 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/log4brains/20240114-decision-4.md @@ -0,0 +1,11 @@ +# Decision 4 + +- Status: accepted + +## Context and Problem Statement + +Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question. + +## Links + +- Supersedes [20240111-decision-3](20240113-decision-3.md) diff --git a/structurizr-import/src/test/resources/decisions/madr/0000-use-markdown-any-decision-records.md b/structurizr-import/src/test/resources/decisions/madr/0000-use-markdown-any-decision-records.md new file mode 100644 index 000000000..b8e778b3f --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0000-use-markdown-any-decision-records.md @@ -0,0 +1,31 @@ +--- +parent: Decisions +nav_order: 0 +date: 2024-01-11 +--- +# Use Markdown Any Decision Records + +## Context and Problem Statement + +We want to record any decisions made in this project independent whether decisions concern the architecture ("architectural decision record"), the code, or other fields. +Which format and structure should these records follow? + +## Considered Options + +* [MADR](https://adr.github.io/madr/) 3.0.0 – The Markdown Any Decision Records +* [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +* [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +* Other templates listed at <https://github.com/joelparkerhenderson/architecture_decision_record> +* Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 3.0.0", because + +* Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). +* MADR allows for structured capturing of any decision. +* The MADR format is lean and fits our development style. +* The MADR structure is comprehensible and facilitates usage & maintenance. +* The MADR project is vivid. diff --git a/structurizr-import/src/test/resources/decisions/madr/0001-use-CC0-or-MIT-as-license.md b/structurizr-import/src/test/resources/decisions/madr/0001-use-CC0-or-MIT-as-license.md new file mode 100644 index 000000000..09ec7aaf0 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0001-use-CC0-or-MIT-as-license.md @@ -0,0 +1,49 @@ +--- +parent: Decisions +nav_order: 1 +--- +# Dual License the Work + +## Context and Problem Statement + +Everything needs to be licensed, otherwise the default copyright laws apply. +For instance, in Germany that means users may not alter anything without explicitly asking for permission. +For more information see <https://help.github.com/articles/licensing-a-repository/>. + +We want to have MADR used without any hassle and that users can just go ahead and write MADRs. + +## Considered Options + +* [CC0](https://creativecommons.org/share-your-work/public-domain/cc0/) +* BSD3 +* MIT +* Dual license with MIT and CC0 +* No license +* Other open source licenses + +## Decision Outcome + +Chosen option: "Dual license", because this lets users choose whether CC0 or MIT fits better on their work. + +## Pros and Cons of the Options + +## CC0 + +* Good, because this license donates the content to "public domain" and does so as legally as possible. +* Bad, because it does not contain attribution - and [attribution is important](https://opensource.stackexchange.com/a/9126/5671). + +## BSD3 + +* Bad, because it [is unclear whether it can be used for documentation](https://opensource.stackexchange.com/a/9545/5671) + +## MIT + +* Good, because it [explicitly may be used for documentation](https://opensource.stackexchange.com/a/9545/5671) +* Good, because it is lean. + +## Dual license with MIT and CC0 + +With the SPDX identifier `MIT OR CC0-1.0`, the receiver of the documents can decide which license thay want to use. + +* Good, because offers freedom at the receiver +* Bad, because dual licensing is not widely known diff --git a/structurizr-import/src/test/resources/decisions/madr/0002-do-not-use-numbers-in-headings.md b/structurizr-import/src/test/resources/decisions/madr/0002-do-not-use-numbers-in-headings.md new file mode 100644 index 000000000..9f3197305 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0002-do-not-use-numbers-in-headings.md @@ -0,0 +1,24 @@ +--- +parent: Decisions +nav_order: 2 +--- +# Do Not Use Numbers in Headings + +## Context and Problem Statement + +How to render the first line in an ADR? +ADRs have to take a unique identifier. + +## Considered Options + +* Use the title only +* Add the ADR number in front of the title (e.g., "# 2. Do not use numbers in headings") + +## Decision Outcome + +Chosen option: "Use the title only", because + +* This is common in other markdown files, too. + One does not add numbering manually at the markdown files, but tries to get the numbers injected by the rendering framework or CSS. +* Enables renaming of ADRs (before publication) easily +* Allows copy'n'paste of ADRs from other repositories without having to worry about the numbers. diff --git a/structurizr-import/src/test/resources/decisions/madr/0003-provide-own-madr-tools.md b/structurizr-import/src/test/resources/decisions/madr/0003-provide-own-madr-tools.md new file mode 100644 index 000000000..00750fd68 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0003-provide-own-madr-tools.md @@ -0,0 +1,33 @@ +--- +parent: Decisions +nav_order: 3 +status: on hold +--- +# Write Own MADR Tooling + +## Context and Problem Statement + +Developers seem to like tooling to create ADRs. +That tooling should support MADR. +The tooling should be easy to install. + +## Considered Options + +* Include in [adr-tools](https://github.com/npryce/adr-tools), 924 stars as of 2018-06-14 +* Include in [adr-j](https://github.com/adoble/adr-j), 2 stars as of 2018-06-14 +* Include in [adr](https://github.com/phodal/adr), 72 stars as of 2018-06-14 +* Write own MADR tooling +* No tool support + +## Decision Outcome + +Chosen option: "Write own MADR tooling", because + +* adding MADR support to `adr-tools` [was rejected](https://github.com/npryce/adr-tools/pull/43) +* other tooling seem to a) modify MADR or b) do not keep up with changes on MADR. + +We accept that this comes with maintenance cost. + +## More Information + +An overview on current tooling of MADR is available at <https://adr.github.io/madr/tooling.html>. diff --git a/structurizr-import/src/test/resources/decisions/madr/0004-write-own-toc-tool.md b/structurizr-import/src/test/resources/decisions/madr/0004-write-own-toc-tool.md new file mode 100644 index 000000000..038204407 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0004-write-own-toc-tool.md @@ -0,0 +1,29 @@ +--- +parent: Decisions +nav_order: 4 +--- +# Write Own TOC Tool + +## Context and Problem Statement + +ADRs have to be indexed somehow. E.g., for offering a website showing all ADRs. + +## Considered Options + +* Write own tool `adr-log` +* Use `adr-tools`' TOC functionality + +## Decision Outcome + +Chosen option: "Write own tool `adr-log`", because + +* we want to have the format `ADR-0001 - Title` in the TOC. +* `adr-tools` offers `title` only. + +We accept that changing `adr-tools` would also be possible. +It is prepared to included header and footer: <https://github.com/npryce/adr-tools/blob/master/tests/generate-contents-with-header-and-footer.sh>. + +### Consequences + +* Good, because `adr-log` is installable using `npm install -g adr-log`, which is easier than installing `adr-tools`. +* Bad, because another tool has to be maintained diff --git a/structurizr-import/src/test/resources/decisions/madr/0005-use-dashes-in-filenames.md b/structurizr-import/src/test/resources/decisions/madr/0005-use-dashes-in-filenames.md new file mode 100644 index 000000000..4c94869e8 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0005-use-dashes-in-filenames.md @@ -0,0 +1,26 @@ +--- +parent: Decisions +nav_order: 5 +--- +# Use Dashes in Filenames + +## Context and Problem Statement + +What is the pattern of the filename where an ADR is stored? + +## Considered Options + +* `NNNN-title-with-dashes.md` - format used by [adr-tools](https://github.com/npryce/adr-tools) +* `YYYY-MM-DD Title` - see <https://github.com/joelparkerhenderson/architecture_decision_record#adr-file-name-conventions> + +## Decision Outcome + +Chosen option: "`NNNN-title-with-dashes.md`", because + +* `NNNN` provides a unique number, which can be used for referencing in the forms + * `ADR-0001` in plain text and + * by `@ADR(1)` Java code (enabled by [e-adr](https://adr.github.io/e-adr/)) +* The creation time of an ADR is of historical interest only, if it gets updated somehow. + The arguments are similar than the ones by [Does Git have keyword expansion?](https://git.wiki.kernel.org/index.php/GitFaq#Does_Git_have_keyword_expansion.3F) +* Having no spaces in filenames eases working in the command line +* This is exactly the format offered by [adr-tools](https://github.com/npryce/adr-tools) diff --git a/structurizr-import/src/test/resources/decisions/madr/0006-use-names-as-identifier.md b/structurizr-import/src/test/resources/decisions/madr/0006-use-names-as-identifier.md new file mode 100644 index 000000000..1734742b8 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0006-use-names-as-identifier.md @@ -0,0 +1,24 @@ +--- +parent: Decisions +nav_order: 6 +--- +# Use Names as Identifier + +## Context and Problem Statement + +An option is listed at "Considered Options" and repeated at "Pros and Cons of the Options". Finally, the chosen option is stated at "Decision Outcome". + +## Decision Drivers + +* Easy to read +* Easy to write +* Avoid copy and paste errors + +## Considered Options + +* Repeat all option names if they occur +* Assign an identifier to an option, e.g., `[A] Use gradle as build tool` + +## Decision Outcome + +Chosen option: "Repeat all option names if they occur", because 1) there is no markdown standard for identifiers, 2) the document is harder to read if there are multiple options which must be remembered. diff --git a/structurizr-import/src/test/resources/decisions/madr/0007-do-not-emphasize-line-headings.md b/structurizr-import/src/test/resources/decisions/madr/0007-do-not-emphasize-line-headings.md new file mode 100644 index 000000000..c12abeab3 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0007-do-not-emphasize-line-headings.md @@ -0,0 +1,23 @@ +--- +parent: Decisions +nav_order: 7 +--- +# Do Not Emphasize Line Headings + +## Context and Problem Statement + +MADR contains lines such as `Chosen option: "[option 1]"`. Should "Chosen option" be emphasized? + +## Decision Drivers + +* MADR should be easy to read +* MADR should be easy to write + +## Considered Options + +* Do not emphasize line headings +* Emphasize line headings + +## Decision Outcome + +Chosen option: "Do not emphasize line headings", because 1) these headings always are put at the beginning of a line and followed by a colon. Thus, they are already easy to identified as line heading. 2) Readers not familiar with Markdown might be confused by stars in the text. diff --git a/structurizr-import/src/test/resources/decisions/madr/0008-add-status-field.md b/structurizr-import/src/test/resources/decisions/madr/0008-add-status-field.md new file mode 100644 index 000000000..bae2bb479 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0008-add-status-field.md @@ -0,0 +1,100 @@ +--- +parent: Decisions +nav_order: 8 +--- +# Add Status Field + +## Context and Problem Statement + +Technical Story: <https://github.com/adr/madr/issues/2> + +ADRs have a status. Should this be tracked? And if it should, how should we track it? + +## Considered Options + +* Use YAML front matter +* Use badge +* Use text line +* Use separate heading +* Use table +* Do not add status + +## Decision Outcome + +Chosen option: "Use YAML front matter", because comes out best (see below). + +## Pros and Cons of the Options + +### Use YAML front matter + +Example: + +```markdown +--- +parent: Decisions +nav_order: 3 +status: on hold +--- +# Write own MADR tooling +``` + +* Good, because YAML front matter is supported by most Markdown parsers + +### Use badge + +#### Examples + +* ![Example "Use Angular" with "status: accepted"](0008-example-badge.png) +* [![Example "status: superseded"](https://img.shields.io/badge/status-superseeded_by_ADR_0001-orange.svg?style=flat-square)](https://github.com/adr/madr/blob/main/docs/decisions/0001-use-CC0-as-license.md) + +--- + +* Good, because plain markdown +* Good, because looks good +* Bad, because hard to read in markdown source +* Bad, because relies on the online service <https://shields.io> or [local badges have to be generated](https://github.com/badges/shields#using-the-badge-library) +* Bad, because at local usages, many badges have to be generated (superseeded-by-ADR-0006, for each ADR number) +* Bad, because not easy to write + +### Use text line + +Example: `Status: Accepted` + +* Good, because plain markdown +* Good, because easy to read +* Good, because easy to write +* Good, because looks OK in both markdown-source (MD) and in rendered versions (HTML, PDF) +* Good, because no dependencies on external tools +* Good, because single line indicates the current state +* Bad, because "Status" line needs to be maintained +* Bad, because uses space at the beginning. When users read MADR, they should directly dive into the context and problem and not into the status + +### Use separate heading + +Example: ![example for separate heading](0008-example-separate-heading.png) + +* Good, because plain markdown +* Good, because easy to write +* Bad, because it uses much space: At least three lines: heading, status, separating empty line + +### Use table + +Example: ![example for table](0008-example-table.png) + +* Good, because history can be included +* Good, because multiple entries can be made +* Good, because already implemented in `adr-tools` fork +* Bad, because not covered by the [CommonMark specification 0.28 (2017-08-01)](http://spec.commonmark.org/0.28/) +* Bad, because hard to read +* Bad, because outdated entries cannot be easily identified +* Bad, because needs more markdown training + +### Do not add status + +* Good, because MADR is kept lean +* Bad, because users demand state field +* Bad, because not in line with other ADR templates + +## More Information + +See [ADR-0013](0013-use-yaml-front-matter-for-meta-data.md) for more reasoning on using YAML front matter. diff --git a/structurizr-import/src/test/resources/decisions/madr/0008-example-badge.png b/structurizr-import/src/test/resources/decisions/madr/0008-example-badge.png new file mode 100644 index 000000000..20ced2835 Binary files /dev/null and b/structurizr-import/src/test/resources/decisions/madr/0008-example-badge.png differ diff --git a/structurizr-import/src/test/resources/decisions/madr/0008-example-separate-heading.png b/structurizr-import/src/test/resources/decisions/madr/0008-example-separate-heading.png new file mode 100644 index 000000000..8a898033e Binary files /dev/null and b/structurizr-import/src/test/resources/decisions/madr/0008-example-separate-heading.png differ diff --git a/structurizr-import/src/test/resources/decisions/madr/0008-example-table.png b/structurizr-import/src/test/resources/decisions/madr/0008-example-table.png new file mode 100644 index 000000000..768c2d145 Binary files /dev/null and b/structurizr-import/src/test/resources/decisions/madr/0008-example-table.png differ diff --git a/structurizr-import/src/test/resources/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md b/structurizr-import/src/test/resources/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md new file mode 100644 index 000000000..f08efe328 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0009-support-links-between-adrs-inside-an-adrs.md @@ -0,0 +1,79 @@ +--- +parent: Decisions +nav_order: 9 +--- +# Support Links To Other ADRs Inside an ADR + +## Context and Problem Statement + +A decision might point to another decision. +For instance, if a decision is a follow-up to another decision. +This should be supported by MADR, too. + +Technical Story: <https://github.com/adr/madr/issues/9> + +## Considered Options + +* Include in section "More Information" +* Use tables +* Use heading together with a bullet list directly after status +* Use heading together with a bullet list directly after "Decision Outcome" +* Use heading together with a bullet list at the end +* Do not add links + +## Decision Outcome + +Chosen option: "Include in section 'More Information'", because comes out best (see below). + +## Pros and Cons of the Options + +### Include in section "More Information" + +Example: + +```markdown +## More Information + +[ADR-0008](0008-add-status-field.md) reasons on adding meta data (such as status). +``` + +* Good, because provides freedom to the user +* Bad, because parsing gets harder + +### Use tables + +* Good, because easy to write +* Good, because history is shown (enabled by concept) +* Good, because [current `adr-tools`' support](https://github.com/npryce/adr-tools/pull/43) uses tables to describe links. +* Bad, because not supported by the CommonMark spec +* Bad, because unclear whether a link was super seeded by another one +* Bad, because valid links not clear at first sight (there might be outdated links shown) + +### Use heading together with a bullet list directly after status + +Example: +![grafik](https://user-images.githubusercontent.com/1366654/36787434-6a63e318-1c8a-11e8-8824-4dd7b3d0f2c6.png) + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Bad, because not consistent with the status label (refs <https://github.com/adr/madr/issues/2>) + +### Use heading together with a bullet list directly after "Decision Outcome" + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Good, because the options are first introduced and then the links +* Good, because consistent with position of "Decision Outcome" +* Bad, because reader might get distracted: He might expect explanation of the options instead of links to something else +* Bad, because not consistent with scientific papers, where related work and future work are coming after the discussion of the content. + +### Use heading together with a bullet list at the end + +* Good, because easy to write +* Good, because supported by the CommonMark spec +* Good, because the options and pros/cons are kept together with the option list. +* Good, because consistent with pattern format + +### Do not add links + +* Good, because template stays minimal diff --git a/structurizr-import/src/test/resources/decisions/madr/0010-support-categories.md b/structurizr-import/src/test/resources/decisions/madr/0010-support-categories.md new file mode 100644 index 000000000..a9160f5d9 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0010-support-categories.md @@ -0,0 +1,106 @@ +--- +parent: Decisions +nav_order: 10 +--- +# Support Categories + +## Context and Problem Statement + +ADRs are recorded. The number of ADRs grows and the context/topic/scope of ADRs might be different (e.g., frontend, backend) + +## Decision Drivers + +* Easy to find groups ADRs in hundreds of ADRs +* Easy to group +* Easy to create +* Good finding without external tooling +* Keep newcomers in mind (should be doable in <10 minutes) +* Keep template lean + +## Considered Options + +* Use labels +* Add `* Category: CATEGORY` directly under the heading (similar to <https://gist.github.com/FaKeller/2f9c63b6e1d436abb7358b68bf396f57>) +* Use YAML front matter +* Encode category in filename +* Use subfolders with local IDs +* Use subfolders with global IDs +* Don't do it. + +## Decision Outcome + +Chosen option: "Use subfolders with local IDs", because comes out best (see below). + +## Pros and Cons of the Options + +### Use labels + +Example: + +Use Angular ![category-frontend](https://img.shields.io/badge/category-frontend-blue.svg?style=flat-square) + +`![category-frontend](https://img.shields.io/badge/category-frontend-blue.svg?style=flat-square)` + +* Good, because full markdown +* Good, because linking to an overview page is possible (using markdown) +* Bad, because not straight-forward to parse +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Add `* Category: CATEGORY` directly under the heading + +* Good, because full markdown +* Good, because linking to an overview page is possible (using markdown) +* Good, because straight-forward to parse +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Use YAML front matter + +Example: + +```yaml +--- +category: frontend +--- +``` + +* Good, because nearly straight-forward to parse +* Good, because Jekyll supports it +* Bad, because YAML front matter is not part of the [CommonMarc Spec](http://spec.commonmark.org/) +* Bad, because no simple filtering using `ls` or Windows Explorer is possible + +### Encode category in filename + +Example: `0050--frontend--title-with-dashes.md` + +* Good, because programmatic filtering is possible +* Good, because `ls -la | grep --category--` works +* Bad, because plain file list in Windows explorer cannot be filtered +* Bad, because as bad as [TagSpaces](https://www.tagspaces.org/), which stores the tags in the filenames in brackets. E.g., `demo[demotag secondtag].md`. + +### Use subfolders with local IDs + +Optionally "to-be-categorized" folder. + +One level of subfolder, not nested + +#### Examples + +* `docs/decisions/smar/0000-secure-entities.md` +* `docs/decisions/smar/0001-flexible-properties-selection.md` + +#### Pros/cons + +* Good, because grouping is done by folders (which are natural for grouping) +* Good, because typos can easily be spotted +* Bad, because there is no unique number identifying an ADR +* Bad, because two indices have to be maintained (`adr-log` needs to be updated) +* Bad, because [e-adr](https://github.com/adr/e-adr) needs to be adapted to `@ADR("category", number)` (not that bad) +* Bad, because when category is unknown it is hard to find the right folder +* Bad, because using categories might be hampering newcomers + +### Use subfolders with global IDs + +#### Examples + +* `docs/decisions/smar/0005-secure-entities.md` +* `docs/decisions/smar/0047-flexible-properties-selection.md` diff --git a/structurizr-import/src/test/resources/decisions/madr/0011-use-asterisk-as-list-marker.md b/structurizr-import/src/test/resources/decisions/madr/0011-use-asterisk-as-list-marker.md new file mode 100644 index 000000000..60956c008 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0011-use-asterisk-as-list-marker.md @@ -0,0 +1,20 @@ +--- +parent: Decisions +nav_order: 11 +--- +# Use Asterisk as List Marker + +## Context and Problem Statement + +Lists in Markdown can be indicated by `*` (asterisk) or `-` (hyphen). + +## Considered Options + +* Use an asterisk +* Use a hyphen + +## Decision Outcome + +Chosen option: "Use an asterisk", because an asterisk does not have a meaning of "good" or "bad", whereas a hyphen `-` could be read as indicator of something negative (in contrast to `+`, which could be more be read as "good"). + +According to the [Markdown Style Guide](http://www.cirosantilli.com/markdown-style-guide/), an asterisk as list marker is more readable (see [readability profile](http://www.cirosantilli.com/markdown-style-guide/#readability-profile)). diff --git a/structurizr-import/src/test/resources/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md b/structurizr-import/src/test/resources/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md new file mode 100644 index 000000000..c73921b26 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0012-use-curly-braces-to-denote-placeholder.md @@ -0,0 +1,46 @@ +--- +parent: Decisions +nav_order: 12 +--- +# Use Curly Braces to Denote Placeholders + +## Context and Problem Statement + +When crafting an ADR placeholders need to be replaced by real values. +How to mark the placeholders? + +## Considered Options + +* Use curly braces +* Use square brackets +* Use less-than and greater-than + +## Decision Outcome + +Chosen option: "Use curly braces", because comes out best (see below). + +## Pros and Cons of the Options + +### Use curly braces + +Example: `{option 1}`. + +* Good, because [consistent to mustache templates](https://krasimirtsonev.com/blog/article/markdown-smart-placeholders). +* Good, because no confusion with markdown notation for links + +### Use square brackets + +Example: `[option 1]`. + +* Good, because used in MADR 1.x and MADR 2.x +* Bad, because confusion with markdown notation for links +* Bad, because some users did not remove the brackets. Example: `Date: [2021-03-12]` or `Good, because [user no longer activatess shortcut accidently when entering task]`. + +### Use less-than and greater-than + +Example: `<option 1>` + +Idea taken from <https://github.com/schubmat/DecisionCapture/blob/master/templates/captureTemplate_full.md> + +* Good, because kept in Markdown as is +* Bad, because could be mixed up with an HTML element diff --git a/structurizr-import/src/test/resources/decisions/madr/0013-example.png b/structurizr-import/src/test/resources/decisions/madr/0013-example.png new file mode 100644 index 000000000..9facf8226 Binary files /dev/null and b/structurizr-import/src/test/resources/decisions/madr/0013-example.png differ diff --git a/structurizr-import/src/test/resources/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md b/structurizr-import/src/test/resources/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md new file mode 100644 index 000000000..2788effc9 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0013-use-yaml-front-matter-for-meta-data.md @@ -0,0 +1,65 @@ +--- +parent: Decisions +nav_order: 13 +--- +# Use YAML front matter for metadata + +## Context and Problem Statement + +MADR offers the fields "Status", "Deciders", and "Date". +These are a kind of metadata fields. +Should this data be included in the ADR directly, or should it be separated somehow? + +## Decision Drivers + +* Easy to read +* Easy to write + +## Considered Options + +* Use YAML front matter +* Use plain Markdown everywhere + +## Decision Outcome + +Chosen option: "Use YAML front matter", because comes out best (see below). + +## Pros and Cons of the Options + +## Use YAML front matter + +Example: + +```markdown +--- +status: accepted +deciders: +date: +--- + +## Context and problem statement + +We want to record architectural decisions made in this project. +Which format and structure should these records follow? +``` + +Rendered output: + +![adr-013 rendered output](0013-example.png) + +* Good, because it shortens the body (essence of the ADR) +* Good, because tools can handle it more easily +* Good, because indicates the lower importance of the data +* Bad, because pretends to be more accurate than it can be (e.g., possible status values) +* Bad, because rendering not standardized +* Bad, because not all Markdown parsers can parse it + +## Use plain Markdown everywhere + +* Good, because all parsers can handle it +* Bad, because special markdown parsing tooling is needed +* Bad, because metadata is handled the same way as the content + +## More Information + +[ADR-0008](0008-add-status-field.md) reasons on adding metadata (such as status). diff --git a/structurizr-import/src/test/resources/decisions/madr/0014-allow-neutral-arguments.md b/structurizr-import/src/test/resources/decisions/madr/0014-allow-neutral-arguments.md new file mode 100644 index 000000000..bfd221988 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0014-allow-neutral-arguments.md @@ -0,0 +1,62 @@ +--- +parent: Decisions +nav_order: 14 +--- +# Allow "neutral" arguments + +## Context and Problem Statement + +Sometimes, one wants to write down an argument, which is neither pro nor con. + +## Considered Options + +* Neutral, because … +* OK, because … +* Good/bad, because … +* Interesting, because … +* Indifferent, because … + +## Decision Outcome + +Chosen option: "Neutral, because …", because + +* it fits best. +* it is consistent to patterns: +/0/- + +## Pros and Cons of the Options + +### Neutral, because … + +In the following, a full pro/con list is given: + +Example: + +* The proposed solution is good, because it resolves the force 1. +* The proposed solution is good, because it addresses decision driver 2. +* The proposed solution is good, because it mitigates the technical risk / dept with respect to decision driver 4. +* The proposed solution is good, because it removes architectural smell … +* The proposed solution is good, because it addresses stakeholder concern … +* The proposed solution is good, because it has a positive effect on the performance (quality property). +* The proposed solution is neutral, because it is indifferent to decision driver 2. +* The proposed solution is bad, because it does not address decision driver 3. +* The proposed solution is bad, because it has a negative effect on the maintainability (quality property). + +Shorter example for pros and cons: + +* The proposed solution is good with respect to resolving the force 1. +* The considered option is a good solution, because it resolves force1 and the non-resolving of force 1 is OK, … + +### OK, because … + +Real world example: <https://github.com/island-is/island.is/blob/main/handbook/technical-overview/adr/0005-error-tracking-and-monitoring.md> + +```markdown +### Bugsnag + +- Good, because it offers a Slack integration for faster feedback. +- Good, because it offers a Github integration to link to possible commits and PRs. +- Good, because it offers bot front-side/server-side/serverless error tracking. +- OK, because it was ranked the **\#5** as the best Javascript (client-side) error logging service in a community survey. +- Bad, because it's expensive. (**\$199/mo** for **450k events** and **15 collaborators**) +- Bad, because it's pricing includes a fixed set of collaborators. +``` diff --git a/structurizr-import/src/test/resources/decisions/madr/0015-include-consulting-informed-of-raci.md b/structurizr-import/src/test/resources/decisions/madr/0015-include-consulting-informed-of-raci.md new file mode 100644 index 000000000..b028fbb7c --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0015-include-consulting-informed-of-raci.md @@ -0,0 +1,44 @@ +--- +parent: Decisions +nav_order: 15 +--- +# Include "Consulted" and "Informed" of RACI + +## Context and Problem Statement + +We noticed an intersection between MADR and [RACI](https://en.wikipedia.org/wiki/Responsibility_assignment_matrix), and felt the need to add a "consulted" and "informed" field in addition to "deciders". +Would it be beneficial to "upstream" these fields to MADR? + +MADR Issue: [#62](https://github.com/adr/madr/issues/62). + +## Decision Drivers + +* MADR should contain fields important to the ADR decision process +* MADR template should be easy to understand +* MADR should be lightweight + +## Considered Options + +* Include "Consulted" and "Informed" of RACI +* Include all fields of RACI +* Do not include anything of RACI + +## Decision Outcome + +Chosen option: "Include 'Consulted' and 'Informed' of RACI", because comes out best (see below). + +## Pros and Cons of the Options + +### Include "Consulted" and "Informed" of RACI + +* Good, because these two roles of RACI are well understood. +* Good, because we make these fields optional, thus it keeps MADR still lightweight. +* Bad, because it adds two additional fields + +### Include all fields of RACI + +This would add "Responsible", "Accountable", "Consulted", and "Informed" + +* Good, because complete RACI would be included +* Bad, because get confused about who is "accountable" and who is "responsible". +* Bad, because if decisions are mostly taken by consensus in small committees, then there might not be an "accountable" person. diff --git a/structurizr-import/src/test/resources/decisions/madr/0016-outcome-before-detailed-pros-cons.md b/structurizr-import/src/test/resources/decisions/madr/0016-outcome-before-detailed-pros-cons.md new file mode 100644 index 000000000..9caa315e8 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0016-outcome-before-detailed-pros-cons.md @@ -0,0 +1,74 @@ +--- +parent: Decisions +nav_order: 16 +--- +# Outcome before Detailed Pros and Cons + +## Context and Problem Statement + +MADR aims to list pros and cons of each option. +Where should that list be placed? + +## Decision Drivers + +* Most important information should be above the fold. +* MADR should be easy to write. +* MADR should be easy to read. + +## Considered Options + +* Section "Pros and Cons of the Options" after "Decision Outcome" +* Section "Pros and Cons of the Options" before "Decision Outcome" + +## Decision Outcome + +Chosen option: "Section 'Pros and Cons of the Options' after 'Decision Outcome'", because this keeps the available options and the decision outcome close together. +One gets the decision outcome above the fold and preserve a nice logical flow. +Hence, one can see the "Pros and Cons" section as a sort of "appendix" to the considered options section. +It is almost like the "Considered Options" section implies the following sentence: "For a more detailed analysis of these options, refer to pros and cons". + +## Pros and Cons of the Options + +### Section "Pros and Cons of the Options" after "Decision Outcome" + +Illustration: + +```markdown +## Considered Options + +... + +## Decision Outcome + +... + +## Pros and Cons of the Options + +... +``` + +* Good, because this keeps the available options and the decision outcome close together. +* Bad, because readers might be confused, because the logical flow broken: First comes the result, then the detailed arguments. + +### Section "Pros and Cons of the Options" before "Decision Outcome" + +Illustration: + +```markdown +## Considered Options + +... + +## Pros and Cons of the Options + +... + +## Decision Outcome + +... +``` + +* Good, because the logical flow is kept +* Bad, because the decision outcome is not close to the options +* Bad, because "small" MADRs with the list of options and the decision outcome (and not containing the section "Pros and Cons of the Options") +* Bad, because "small" MADRs with the list of options and the decision outcome (and not containing the section "Pros and Cons of the Options") diff --git a/structurizr-import/src/test/resources/decisions/madr/0017-use-same-format-for-outcomes-and-options.md b/structurizr-import/src/test/resources/decisions/madr/0017-use-same-format-for-outcomes-and-options.md new file mode 100644 index 000000000..bbf876996 --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0017-use-same-format-for-outcomes-and-options.md @@ -0,0 +1,26 @@ +--- +parent: Decisions +nav_order: 17 +--- +# Use Same Format for Outcomes and Options + +## Context and Problem Statement + +"Outcome" has "Positive Consequences" and "Negative Consequences" sections. "Options" just have a single list with "Good" and "Bad" prefixes. + +Ticket: [issue#75](https://github.com/adr/madr/issues/75) + +## Decision Drivers + +* Consistent design of MADR +* Allow easy copy and paste + +## Considered Options + +* Section "Consequences" listing positive and negative consequences as "Good, because" and "Bad, because" +* Section "Consequences" listing positive and negative consequences as "Positive, because" and "Negative, because" +* No sections "Consequences", "Positive Consequences", and "Negative Consequences" + +## Decision Outcome + +Chosen option: 'Section "Consequences" listing positive and negative consequences as "Good, because" and "Bad, because"', because resolves all forces. diff --git a/structurizr-import/src/test/resources/decisions/madr/0018-use-confirmation-as-heading.md b/structurizr-import/src/test/resources/decisions/madr/0018-use-confirmation-as-heading.md new file mode 100644 index 000000000..230f4f54a --- /dev/null +++ b/structurizr-import/src/test/resources/decisions/madr/0018-use-confirmation-as-heading.md @@ -0,0 +1,30 @@ +--- +parent: Decisions +nav_order: 18 +--- +<!-- we need to disable MD025, because we use the different heading "ADR Template" in the homepage (see above) than it is foreseen in the template --> +<!-- markdownlint-disable-next-line MD025 --> +# Use "Confirmation" as Heading + +## Context and Problem Statement + +In MADR, we want to include some sort of check that the decision was implemented. +How to name the heading for the explanation? + +## Decision Drivers + +* Consistent with terms used in IT +* Common word + +## Considered Options + +* "Confirmation" +* "Validation" +* "Verification" + +## Decision Outcome + +Chosen option: "Confirmation", because "validation" is out of scope of the template. +There is a process leading to a "valid" ADR. +The other term "Verification" is often bound to a formal tool or formal procedure. +We wanted to enable also less formal checks. diff --git a/structurizr-import/src/test/resources/diagrams/kroki/diagram.dot b/structurizr-import/src/test/resources/diagrams/kroki/diagram.dot new file mode 100644 index 000000000..3f2b18926 --- /dev/null +++ b/structurizr-import/src/test/resources/diagrams/kroki/diagram.dot @@ -0,0 +1 @@ +digraph G {Hello->World} diff --git a/structurizr-import/src/test/resources/diagrams/mermaid/class.mmd b/structurizr-import/src/test/resources/diagrams/mermaid/class.mmd new file mode 100644 index 000000000..eee0f009f --- /dev/null +++ b/structurizr-import/src/test/resources/diagrams/mermaid/class.mmd @@ -0,0 +1,21 @@ +classDiagram + Animal <|-- Duck + Animal <|-- Fish + Animal <|-- Zebra + Animal : +int age + Animal : +String gender + Animal: +isMammal() + Animal: +mate() + class Duck{ + +String beakColor + +swim() + +quack() + } + class Fish{ + -int sizeInFeet + -canEat() + } + class Zebra{ + +bool is_wild + +run() + } diff --git a/structurizr-import/src/test/resources/diagrams/mermaid/flowchart.mmd b/structurizr-import/src/test/resources/diagrams/mermaid/flowchart.mmd new file mode 100644 index 000000000..a5bd75ae5 --- /dev/null +++ b/structurizr-import/src/test/resources/diagrams/mermaid/flowchart.mmd @@ -0,0 +1,6 @@ +flowchart TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car] \ No newline at end of file diff --git a/structurizr-import/src/test/resources/diagrams/plantuml/with-title.puml b/structurizr-import/src/test/resources/diagrams/plantuml/with-title.puml new file mode 100644 index 000000000..caf9f13a5 --- /dev/null +++ b/structurizr-import/src/test/resources/diagrams/plantuml/with-title.puml @@ -0,0 +1,4 @@ +@startuml +title Sequence diagram example +Bob -> Alice : hello +@enduml \ No newline at end of file diff --git a/structurizr-import/src/test/resources/diagrams/plantuml/without-title.puml b/structurizr-import/src/test/resources/diagrams/plantuml/without-title.puml new file mode 100644 index 000000000..1da6ac585 --- /dev/null +++ b/structurizr-import/src/test/resources/diagrams/plantuml/without-title.puml @@ -0,0 +1,3 @@ +@startuml +Bob -> Alice : hello +@enduml \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/01-section-1.md b/structurizr-import/src/test/resources/docs/docs/01-section-1.md new file mode 100644 index 000000000..4721380c0 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/01-section-1.md @@ -0,0 +1 @@ +## Section 1 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/02-section-2.markdown b/structurizr-import/src/test/resources/docs/docs/02-section-2.markdown new file mode 100644 index 000000000..2fc0e3f87 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/02-section-2.markdown @@ -0,0 +1 @@ +## Section 2 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/03-section-3.text b/structurizr-import/src/test/resources/docs/docs/03-section-3.text new file mode 100644 index 000000000..e847bfa94 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/03-section-3.text @@ -0,0 +1 @@ +## Section 3 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/04-section-4.adoc b/structurizr-import/src/test/resources/docs/docs/04-section-4.adoc new file mode 100644 index 000000000..062d5dd9a --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/04-section-4.adoc @@ -0,0 +1 @@ +== Section 4 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/05-section-5.asciidoc b/structurizr-import/src/test/resources/docs/docs/05-section-5.asciidoc new file mode 100644 index 000000000..1a501bdbf --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/05-section-5.asciidoc @@ -0,0 +1 @@ +== Section 5 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/06-section-6.asc b/structurizr-import/src/test/resources/docs/docs/06-section-6.asc new file mode 100644 index 000000000..8728c5661 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/06-section-6.asc @@ -0,0 +1 @@ +== Section 6 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/07-subdirectory/01-section-1.md b/structurizr-import/src/test/resources/docs/docs/07-subdirectory/01-section-1.md new file mode 100644 index 000000000..af328a67d --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/07-subdirectory/01-section-1.md @@ -0,0 +1 @@ +## Section 7 \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/file_without_extension b/structurizr-import/src/test/resources/docs/docs/file_without_extension new file mode 100644 index 000000000..d7df09c19 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/docs/file_without_extension @@ -0,0 +1 @@ +This file should be ignored. \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/docs/images/image.gif b/structurizr-import/src/test/resources/docs/docs/images/image.gif new file mode 100644 index 000000000..c06542ada Binary files /dev/null and b/structurizr-import/src/test/resources/docs/docs/images/image.gif differ diff --git a/structurizr-import/src/test/resources/docs/docs/images/image.jpeg b/structurizr-import/src/test/resources/docs/docs/images/image.jpeg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/docs/images/image.jpeg differ diff --git a/structurizr-import/src/test/resources/docs/docs/images/image.jpg b/structurizr-import/src/test/resources/docs/docs/images/image.jpg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/docs/images/image.jpg differ diff --git a/structurizr-import/src/test/resources/docs/docs/images/image.png b/structurizr-import/src/test/resources/docs/docs/images/image.png new file mode 100644 index 000000000..644aef77b Binary files /dev/null and b/structurizr-import/src/test/resources/docs/docs/images/image.png differ diff --git a/structurizr-import/src/test/resources/docs/images/image.gif b/structurizr-import/src/test/resources/docs/images/image.gif new file mode 100644 index 000000000..c06542ada Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/image.gif differ diff --git a/structurizr-import/src/test/resources/docs/images/image.jpeg b/structurizr-import/src/test/resources/docs/images/image.jpeg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/image.jpeg differ diff --git a/structurizr-import/src/test/resources/docs/images/image.jpg b/structurizr-import/src/test/resources/docs/images/image.jpg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/image.jpg differ diff --git a/structurizr-import/src/test/resources/docs/images/image.png b/structurizr-import/src/test/resources/docs/images/image.png new file mode 100644 index 000000000..644aef77b Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/image.png differ diff --git a/structurizr-import/src/test/resources/docs/images/image.svg b/structurizr-import/src/test/resources/docs/images/image.svg new file mode 100644 index 000000000..1d526413e --- /dev/null +++ b/structurizr-import/src/test/resources/docs/images/image.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"> + <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> +</svg> \ No newline at end of file diff --git a/structurizr-import/src/test/resources/docs/images/images/image.gif b/structurizr-import/src/test/resources/docs/images/images/image.gif new file mode 100644 index 000000000..c06542ada Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/images/image.gif differ diff --git a/structurizr-import/src/test/resources/docs/images/images/image.jpeg b/structurizr-import/src/test/resources/docs/images/images/image.jpeg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/images/image.jpeg differ diff --git a/structurizr-import/src/test/resources/docs/images/images/image.jpg b/structurizr-import/src/test/resources/docs/images/images/image.jpg new file mode 100644 index 000000000..0b9e9c39d Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/images/image.jpg differ diff --git a/structurizr-import/src/test/resources/docs/images/images/image.png b/structurizr-import/src/test/resources/docs/images/images/image.png new file mode 100644 index 000000000..644aef77b Binary files /dev/null and b/structurizr-import/src/test/resources/docs/images/images/image.png differ diff --git a/structurizr-import/src/test/resources/docs/images/noimages/readme.md b/structurizr-import/src/test/resources/docs/images/noimages/readme.md new file mode 100644 index 000000000..6e5b3dd54 --- /dev/null +++ b/structurizr-import/src/test/resources/docs/images/noimages/readme.md @@ -0,0 +1 @@ +These are not the images you are looking for... diff --git a/structurizr-inspection/README.md b/structurizr-inspection/README.md new file mode 100644 index 000000000..ed2285468 --- /dev/null +++ b/structurizr-inspection/README.md @@ -0,0 +1,7 @@ +# structurizr-inspection + +[![Maven Central](https://img.shields.io/maven-central/v/com.structurizr/structurizr-inspection.svg?label=Maven%20Central)](https://search.maven.org/artifact/com.structurizr/structurizr-inspection) + +This library provides utilities to inspect a Structurizr workspace for warnings. + +- [Documentation](https://docs.structurizr.com/workspaces) \ No newline at end of file diff --git a/structurizr-inspection/build.gradle b/structurizr-inspection/build.gradle new file mode 100644 index 000000000..72a7ec6ba --- /dev/null +++ b/structurizr-inspection/build.gradle @@ -0,0 +1,7 @@ +dependencies { + + api project(':structurizr-core') + + testImplementation project(':structurizr-dsl') + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/DefaultInspector.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/DefaultInspector.java new file mode 100644 index 000000000..e691825ab --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/DefaultInspector.java @@ -0,0 +1,163 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; +import com.structurizr.inspection.documentation.EmbeddedViewMissingInspection; +import com.structurizr.inspection.documentation.EmbeddedViewWithGeneratedKeyInspection; +import com.structurizr.inspection.model.*; +import com.structurizr.inspection.view.*; +import com.structurizr.inspection.workspace.WorkspaceScopeInspection; +import com.structurizr.inspection.workspace.WorkspaceToolingInspection; +import com.structurizr.model.*; +import com.structurizr.view.*; + +import java.util.List; + +public class DefaultInspector extends Inspector { + + private static final String INSPECTION_SUMMARY_NUMBER_OF_ERROR = "structurizr.inspection.error"; + private static final String INSPECTION_SUMMARY_NUMBER_OF_WARNING = "structurizr.inspection.warning"; + private static final String INSPECTION_SUMMARY_NUMBER_OF_INFO = "structurizr.inspection.info"; + private static final String INSPECTION_SUMMARY_NUMBER_OF_IGNORE = "structurizr.inspection.ignore"; + + public DefaultInspector(Workspace workspace) { + super(workspace); + + if (!"false".equalsIgnoreCase(workspace.getProperties().get("structurizr.inspection"))) { + runWorkspaceInspections(); + runModelInspections(); + runViewInspections(); + + List<Violation> violations = getViolations(); + workspace.addProperty(INSPECTION_SUMMARY_NUMBER_OF_ERROR, "" + violations.stream().filter(r -> r.getSeverity() == Severity.ERROR).count()); + workspace.addProperty(INSPECTION_SUMMARY_NUMBER_OF_WARNING, "" + violations.stream().filter(r -> r.getSeverity() == Severity.WARNING).count()); + workspace.addProperty(INSPECTION_SUMMARY_NUMBER_OF_INFO, "" + violations.stream().filter(r -> r.getSeverity() == Severity.INFO).count()); + workspace.addProperty(INSPECTION_SUMMARY_NUMBER_OF_IGNORE, "" + violations.stream().filter(r -> r.getSeverity() == Severity.IGNORE).count()); + } + } + + private void runWorkspaceInspections() { + add(new WorkspaceToolingInspection(this).run()); + add(new WorkspaceScopeInspection(this).run()); + add(new EmbeddedViewMissingInspection(this).run(getWorkspace())); + add(new EmbeddedViewWithGeneratedKeyInspection(this).run(getWorkspace())); + } + + private void runModelInspections() { + add(new EmptyModelInspection(this).run()); + add(new MultipleSoftwareSystemsDetailedInspection(this).run()); + ElementNotIncludedInAnyViewsInspection elementNotIncludedInAnyViewsCheck = new ElementNotIncludedInAnyViewsInspection(this); + DisconnectedElementInspection disconnectedElementCheck = new DisconnectedElementInspection(this); + for (Element element : getWorkspace().getModel().getElements()) { + if (element instanceof Person) { + add(new PersonDescriptionInspection(this).run(element)); + } + + if (element instanceof SoftwareSystem) { + add(new SoftwareSystemDescriptionInspection(this).run(element)); + add(new SoftwareSystemDocumentationInspection(this).run(element)); + add(new SoftwareSystemDecisionsInspection(this).run(element)); + add(new EmbeddedViewMissingInspection(this).run((SoftwareSystem)element)); + add(new EmbeddedViewWithGeneratedKeyInspection(this).run((SoftwareSystem)element)); + } + + if (element instanceof Container) { + add(new ContainerDescriptionInspection(this).run(element)); + add(new ContainerTechnologyInspection(this).run(element)); + add(new EmbeddedViewMissingInspection(this).run((Container)element)); + add(new EmbeddedViewWithGeneratedKeyInspection(this).run((Container)element)); + } + + if (element instanceof Component) { + add(new ComponentDescriptionInspection(this).run(element)); + add(new ComponentTechnologyInspection(this).run(element)); + add(new EmbeddedViewMissingInspection(this).run((Component)element)); + add(new EmbeddedViewWithGeneratedKeyInspection(this).run((Component)element)); + } + + if (element instanceof DeploymentNode) { + add(new DeploymentNodeDescriptionInspection(this).run(element)); + add(new DeploymentNodeTechnologyInspection(this).run(element)); + add(new EmptyDeploymentNodeInspection(this).run(element)); + } + + if (element instanceof InfrastructureNode) { + add(new InfrastructureNodeDescriptionInspection(this).run(element)); + add(new InfrastructureNodeTechnologyInspection(this).run(element)); + } + + add(disconnectedElementCheck.run(element)); + add(elementNotIncludedInAnyViewsCheck.run(element)); + + for (Relationship relationship : element.getRelationships()) { + add(new RelationshipDescriptionInspection(this).run(relationship)); + add(new RelationshipTechnologyInspection(this).run(relationship)); + } + } + } + + private void runViewInspections() { + add(new EmptyViewsInspection(this).run()); + + for (CustomView view : getWorkspace().getViews().getCustomViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + for (SystemLandscapeView view : getWorkspace().getViews().getSystemLandscapeViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + add(new SystemContextViewsForMultipleSoftwareSystemsInspection(this).run()); + for (SystemContextView view : getWorkspace().getViews().getSystemContextViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + add(new ContainerViewsForMultipleSoftwareSystemsInspection(this).run()); + for (ContainerView view : getWorkspace().getViews().getContainerViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + for (ComponentView view : getWorkspace().getViews().getComponentViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + for (DynamicView view : getWorkspace().getViews().getDynamicViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + for (DeploymentView view : getWorkspace().getViews().getDeploymentViews()) { + add(new GeneratedKeyInspection(this).run(view)); + add(new EmptyViewInspection(this).run(view)); + add(new ManualLayoutInspection(this).run(view)); + } + + for (FilteredView view : getWorkspace().getViews().getFilteredViews()) { + add(new GeneratedKeyInspection(this).run(view)); + } + + for (ImageView view : getWorkspace().getViews().getImageViews()) { + add(new GeneratedKeyInspection(this).run(view)); + } + + for (ElementStyle elementStyle : getWorkspace().getViews().getConfiguration().getStyles().getElements()) { + add(new ElementStyleMetadataInspection(this).run(elementStyle)); + } + } + + @Override + public SeverityStrategy getSeverityStrategy() { + return new PropertyBasedSeverityStrategy(); + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/FixedSeverityStrategy.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/FixedSeverityStrategy.java new file mode 100644 index 000000000..81e9901b4 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/FixedSeverityStrategy.java @@ -0,0 +1,54 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.Model; +import com.structurizr.model.Relationship; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.View; +import com.structurizr.view.ViewSet; + +public class FixedSeverityStrategy implements SeverityStrategy { + + private final Severity severity; + + public FixedSeverityStrategy(Severity severity) { + this.severity = severity; + } + + @Override + public Severity getSeverity(Inspection inspection, Workspace workspace) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, ViewSet viewSet) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, View view) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, ElementStyle elementStyle) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, Model model) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, Element element) { + return severity; + } + + @Override + public Severity getSeverity(Inspection inspection, Relationship relationship) { + return severity; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspection.java new file mode 100644 index 000000000..4b4c66409 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspection.java @@ -0,0 +1,31 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; + +public abstract class Inspection { + + private final Inspector inspector; + + protected Inspection(Inspector inspector) { + this.inspector = inspector; + } + + protected abstract String getType(); + + public Inspector getInspector() { + return inspector; + } + + protected Workspace getWorkspace() { + return inspector.getWorkspace(); + } + + protected Violation noViolation() { + return null; + } + + protected Violation violation(String description) { + return new Violation(this, description); + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspector.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspector.java new file mode 100644 index 000000000..5df5fea46 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/Inspector.java @@ -0,0 +1,41 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; + +import java.util.ArrayList; +import java.util.List; + +public abstract class Inspector { + + private final Workspace workspace; + + private final List<Violation> violations = new ArrayList<>(); + private int numberOfInspections = 0; + + protected Inspector(Workspace workspace) { + this.workspace = workspace; + } + + public Workspace getWorkspace() { + return workspace; + } + + public List<Violation> getViolations() { + return new ArrayList<>(violations); + } + + public int getNumberOfInspections() { + return numberOfInspections; + } + + protected void add(Violation violation) { + numberOfInspections++; + + if (violation != null) { + violations.add(violation); + } + } + + public abstract SeverityStrategy getSeverityStrategy(); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/PropertyBasedSeverityStrategy.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/PropertyBasedSeverityStrategy.java new file mode 100644 index 000000000..0483cba69 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/PropertyBasedSeverityStrategy.java @@ -0,0 +1,150 @@ +package com.structurizr.inspection; + +import com.structurizr.PropertyHolder; +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.Model; +import com.structurizr.model.Relationship; +import com.structurizr.util.StringUtils; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.View; +import com.structurizr.view.ViewSet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class PropertyBasedSeverityStrategy implements SeverityStrategy { + + private static final String STRUCTURIZR_INSPECTION_PREFIX = "structurizr.inspection."; + + public PropertyBasedSeverityStrategy() { + } + + @Override + public Severity getSeverity(Inspection inspection, Workspace workspace) { + Severity severity = getSeverityFromProperties(inspection.getType(), workspace); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, ViewSet viewSet) { + Severity severity = getSeverityFromProperties(inspection.getType(), inspection.getWorkspace(), viewSet.getConfiguration()); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, View view) { + Severity severity = getSeverityFromProperties(inspection.getType(), inspection.getWorkspace(), inspection.getWorkspace().getViews().getConfiguration(), view); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, ElementStyle elementStyle) { + Severity severity = getSeverityFromProperties(inspection.getType(), inspection.getWorkspace(), inspection.getWorkspace().getViews().getConfiguration(), elementStyle); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, Model model) { + Severity severity = getSeverityFromProperties(inspection.getType(), inspection.getWorkspace(), model); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, Element element) { + Element parentElement = element.getParent(); + Element grandParentElement = null; + if (parentElement != null) { + grandParentElement = parentElement.getParent(); + } + + Severity severity = getSeverityFromProperties(inspection.getType(), inspection.getWorkspace(), inspection.getWorkspace().getModel(), grandParentElement, parentElement, element); + return severity != null ? severity : Severity.ERROR; + } + + @Override + public Severity getSeverity(Inspection inspection, Relationship relationship) { + Element source = relationship.getSource(); + Relationship linkedRelationship = null; + if (!StringUtils.isNullOrEmpty(relationship.getLinkedRelationshipId())) { + linkedRelationship = inspection.getWorkspace().getModel().getRelationship(relationship.getLinkedRelationshipId()); + } + + String allRelationshipsType = inspection.getType(); + String specificRelationshipType = inspection.getType(); + + // convert model.relationship.description to model.relationship[sourceType->destinationType].description + String sourceType = relationship.getSource().getClass().getSimpleName().toLowerCase(); + String destinationType = relationship.getDestination().getClass().getSimpleName().toLowerCase(); + specificRelationshipType = allRelationshipsType.replaceFirst( + "\\.relationship\\.", + String.format(".relationship[%s->%s].", sourceType, destinationType) + ); + + Severity severity = getSeverityFromProperties(specificRelationshipType, inspection.getWorkspace(), inspection.getWorkspace().getModel(), source.getParent(), source, linkedRelationship, relationship); + if (severity == null) { + severity = getSeverityFromProperties(allRelationshipsType, inspection.getWorkspace(), inspection.getWorkspace().getModel(), source.getParent(), source, linkedRelationship, relationship); + } + + return severity != null ? severity : Severity.ERROR; + } + + protected Severity getSeverityFromProperties(String type, PropertyHolder... propertyHolders) { + List<String> types = generatePropertyNames(type); + List<PropertyHolder> reversedPropertyHolders = Arrays.asList(propertyHolders); + Collections.reverse(reversedPropertyHolders); + + for (PropertyHolder propertyHolder : reversedPropertyHolders) { + if (propertyHolder != null) { + for (String t : types) { + if (propertyHolder.getProperties().containsKey(t)) { + return Severity.valueOf(propertyHolder.getProperties().get(t).toUpperCase()); + } + } + } + } + + return null; + } + + protected List<String> generatePropertyNames(String type) { + // example input: + // model.component.description + // + // example output: + // structurizr.inspection.model.component.description + // structurizr.inspection.model.component.* + // structurizr.inspection.model.* + // structurizr.inspection.* + + // example input: + // model.relationship[component->component].technology + // + // example output: + // structurizr.inspection.model.relationship[component->component].technology + // structurizr.inspection.model.relationship[component->component].* + + type = type.toLowerCase(); + List<String> types = new ArrayList<>(); + + String[] parts = type.split("\\."); + String buf = STRUCTURIZR_INSPECTION_PREFIX; + types.add(buf + "*"); + for (int i = 0; i < parts.length-1; i++) { + buf = buf + parts[i] + "."; + types.add(buf + "*"); + } + + types.add(STRUCTURIZR_INSPECTION_PREFIX + type); + Collections.reverse(types); + + if (type.contains(".relationship[")) { + return types.subList(0, types.size()-2); + } + + return types; + } + +} diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/Severity.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/Severity.java new file mode 100644 index 000000000..3b86141b5 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/Severity.java @@ -0,0 +1,10 @@ +package com.structurizr.inspection; + +public enum Severity { + + ERROR, + WARNING, + INFO, + IGNORE + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/SeverityStrategy.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/SeverityStrategy.java new file mode 100644 index 000000000..4a037054c --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/SeverityStrategy.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; +import com.structurizr.model.Element; +import com.structurizr.model.Model; +import com.structurizr.model.Relationship; +import com.structurizr.view.ElementStyle; +import com.structurizr.view.View; +import com.structurizr.view.ViewSet; + +public interface SeverityStrategy { + + Severity getSeverity(Inspection inspection, Workspace workspace); + + Severity getSeverity(Inspection inspection, ViewSet viewSet); + + Severity getSeverity(Inspection inspection, View view); + + Severity getSeverity(Inspection inspection, ElementStyle elementStyle); + + Severity getSeverity(Inspection inspection, Model model); + + Severity getSeverity(Inspection inspection, Element element); + + Severity getSeverity(Inspection inspection, Relationship relationship); + +} diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/Violation.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/Violation.java new file mode 100644 index 000000000..082577689 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/Violation.java @@ -0,0 +1,37 @@ +package com.structurizr.inspection; + +public final class Violation { + + private Inspection inspection; + private Severity severity; + private final String message; + + Violation(Inspection inspection, String message) { + this.inspection = inspection; + this.message = message; + } + + public String getType() { + return inspection.getType(); + } + + public Severity getSeverity() { + return severity; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return inspection.getType() + " | " + severity + " | " + message; + } + + public Violation withSeverity(Severity severity) { + this.severity = severity; + + return this; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java new file mode 100644 index 000000000..7b2eb945d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/AbstractDocumentableInspection.java @@ -0,0 +1,90 @@ +package com.structurizr.inspection.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.*; +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public abstract class AbstractDocumentableInspection extends Inspection { + + private static final Pattern MARKDOWN_EMBED = Pattern.compile("!\\[.*?]\\(embed:(.+?)\\)"); + private static final Pattern ASCIIDOC_EMBED = Pattern.compile("image::embed:(.+?)\\[]"); + + public AbstractDocumentableInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(Documentable documentable) { + Severity severity; + if (documentable instanceof Workspace) { + severity = getInspector().getSeverityStrategy().getSeverity(this, (Workspace)documentable); + } else { + Element element = (Element)documentable; + severity = getInspector().getSeverityStrategy().getSeverity(this, element); + } + Violation violation = inspect(documentable); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(Documentable documentable); + + protected Set<String> findEmbeddedViewKeys(Documentable documentable) { + Set<String> keys = new LinkedHashSet<>(); + + if (documentable.getDocumentation() != null) { + for (Section section : documentable.getDocumentation().getSections()) { + keys.addAll(findEmbeddedViewKeys(section)); + } + + for (Decision decision : documentable.getDocumentation().getDecisions()) { + keys.addAll(findEmbeddedViewKeys(decision)); + } + } + + return keys; + } + + private Set<String> findEmbeddedViewKeys(DocumentationContent content) { + Set<String> keys = new LinkedHashSet<>(); + + String[] lines = content.getContent().split("\n"); + for (String line : lines) { + if (content.getFormat() == Format.Markdown) { + // ![](embed:MyDiagramKey) + Matcher matcher = MARKDOWN_EMBED.matcher(line); + if (matcher.matches()) { + String key = matcher.group(1); + keys.add(key); + } + } else if (content.getFormat() == Format.AsciiDoc) { + // image::embed:MyDiagramKey[] + Matcher matcher = ASCIIDOC_EMBED.matcher(line); + if (matcher.matches()) { + String key = matcher.group(1); + keys.add(key); + } + } + } + + return keys; + } + + protected String terminologyFor(Element element) { + return getWorkspace().getViews().getConfiguration().getTerminology().findTerminology(element).toLowerCase(); + } + + protected String nameOf(Element element) { + String canonicalName = element.getCanonicalName(); + return canonicalName.substring(canonicalName.indexOf("://") + 3); + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspection.java new file mode 100644 index 000000000..de1259aed --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspection.java @@ -0,0 +1,52 @@ +package com.structurizr.inspection.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Documentable; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.view.View; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class EmbeddedViewMissingInspection extends AbstractDocumentableInspection { + + public EmbeddedViewMissingInspection(Inspector inspector) { + super(inspector); + } + + protected Violation inspect(Documentable documentable) { + Set<String> keys = findEmbeddedViewKeys(documentable); + Set<String> missingViews = new LinkedHashSet<>(); + + for (String key : keys) { + View view = getWorkspace().getViews().getViewWithKey(key); + if (view == null) { + missingViews.add(key); + } + } + + if (!missingViews.isEmpty()) { + if (documentable instanceof Workspace) { + return violation("The following views are embedded into documentation for the workspace but do not exist in the workspace: " + String.join(", ", missingViews)); + } else if (documentable instanceof Element) { + Element element = (Element)documentable; + return violation("The following views are embedded into documentation for the " + terminologyFor(element).toLowerCase() + " \"" + nameOf(element) + "\" but do not exist in the workspace: " + String.join(", ", missingViews)); + } + } + + return noViolation(); + } + + @Override + protected String getType() { + return "documentation.embeddedview"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspection.java new file mode 100644 index 000000000..363f8efe3 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspection.java @@ -0,0 +1,49 @@ +package com.structurizr.inspection.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Documentable; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.view.View; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class EmbeddedViewWithGeneratedKeyInspection extends AbstractDocumentableInspection { + + public EmbeddedViewWithGeneratedKeyInspection(Inspector inspector) { + super(inspector); + } + + protected Violation inspect(Documentable documentable) { + Set<String> keys = findEmbeddedViewKeys(documentable); + Set<String> viewsWithGeneratedKeys = new LinkedHashSet<>(); + + for (String key : keys) { + View view = getWorkspace().getViews().getViewWithKey(key); + if (view != null && view.isGeneratedKey()) { + viewsWithGeneratedKeys.add(key); + } + } + + if (!viewsWithGeneratedKeys.isEmpty()) { + if (documentable instanceof Workspace) { + return violation("The following views are embedded into documentation for the workspace via an automatically generated view key: " + String.join(", ", viewsWithGeneratedKeys)); + } else if (documentable instanceof Element) { + Element element = (Element)documentable; + return violation("The following views are embedded into documentation for the " + terminologyFor(element).toLowerCase() + " \"" + nameOf(element) + "\" via an automatically generated view key: " + String.join(", ", viewsWithGeneratedKeys)); + } + } + + return noViolation(); + } + + @Override + protected String getType() { + return "documentation.embeddedview"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractComponentInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractComponentInspection.java new file mode 100644 index 000000000..08edc0863 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractComponentInspection.java @@ -0,0 +1,21 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.model.Element; + +abstract class AbstractComponentInspection extends AbstractElementInspection { + + public AbstractComponentInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected final Violation inspect(Element element) { + return inspect((Component)element); + } + + protected abstract Violation inspect(Component component); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractContainerInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractContainerInspection.java new file mode 100644 index 000000000..7ec4e579a --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractContainerInspection.java @@ -0,0 +1,21 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.Element; + +abstract class AbstractContainerInspection extends AbstractElementInspection { + + public AbstractContainerInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected final Violation inspect(Element element) { + return inspect((Container)element); + } + + protected abstract Violation inspect(Container container); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractDeploymentNodeInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractDeploymentNodeInspection.java new file mode 100644 index 000000000..74fa9958c --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractDeploymentNodeInspection.java @@ -0,0 +1,21 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; + +abstract class AbstractDeploymentNodeInspection extends AbstractElementInspection { + + public AbstractDeploymentNodeInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Element element) { + return inspect((DeploymentNode)element); + } + + protected abstract Violation inspect(DeploymentNode deploymentNode); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractElementInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractElementInspection.java new file mode 100644 index 000000000..7a895d79d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractElementInspection.java @@ -0,0 +1,33 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; + +abstract class AbstractElementInspection extends Inspection { + + public AbstractElementInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(Element element) { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, element); + Violation violation = inspect(element); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected String terminologyFor(Element element) { + return getWorkspace().getViews().getConfiguration().getTerminology().findTerminology(element).toLowerCase(); + } + + protected String nameOf(Element element) { + String canonicalName = element.getCanonicalName(); + return canonicalName.substring(canonicalName.indexOf("://") + 3); + } + + protected abstract Violation inspect(Element element); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractInfrastructureNodeInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractInfrastructureNodeInspection.java new file mode 100644 index 000000000..51f08cd61 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractInfrastructureNodeInspection.java @@ -0,0 +1,21 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.model.InfrastructureNode; + +abstract class AbstractInfrastructureNodeInspection extends AbstractElementInspection { + + public AbstractInfrastructureNodeInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Element element) { + return inspect((InfrastructureNode)element); + } + + protected abstract Violation inspect(InfrastructureNode infrastructureNode); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractModelInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractModelInspection.java new file mode 100644 index 000000000..e5a28ea25 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractModelInspection.java @@ -0,0 +1,24 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; + +abstract class AbstractModelInspection extends Inspection { + + public AbstractModelInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run() { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, getWorkspace().getModel()); + Violation violation = inspect(getWorkspace()); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(Workspace workspace); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractRelationshipInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractRelationshipInspection.java new file mode 100644 index 000000000..d84da68f8 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractRelationshipInspection.java @@ -0,0 +1,34 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; + +public abstract class AbstractRelationshipInspection extends Inspection { + + public AbstractRelationshipInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(Relationship relationship) { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, relationship); + Violation violation = inspect(relationship); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected String terminologyFor(Element element) { + return getWorkspace().getViews().getConfiguration().getTerminology().findTerminology(element).toLowerCase(); + } + + protected String nameOf(Element element) { + String canonicalName = element.getCanonicalName(); + return canonicalName.substring(canonicalName.indexOf("://") + 3); + } + + protected abstract Violation inspect(Relationship relationship); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractSoftwareSystemInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractSoftwareSystemInspection.java new file mode 100644 index 000000000..188349479 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/AbstractSoftwareSystemInspection.java @@ -0,0 +1,21 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.model.SoftwareSystem; + +abstract class AbstractSoftwareSystemInspection extends AbstractElementInspection { + + public AbstractSoftwareSystemInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected final Violation inspect(Element element) { + return inspect((SoftwareSystem)element); + } + + protected abstract Violation inspect(SoftwareSystem softwareSystem); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentDescriptionInspection.java new file mode 100644 index 000000000..cb1eb32d6 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentDescriptionInspection.java @@ -0,0 +1,16 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class ComponentDescriptionInspection extends ElementDescriptionInspection { + + public ComponentDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected String getType() { + return "model.component.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentTechnologyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentTechnologyInspection.java new file mode 100644 index 000000000..04cd65312 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ComponentTechnologyInspection.java @@ -0,0 +1,28 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.util.StringUtils; + +public class ComponentTechnologyInspection extends AbstractComponentInspection { + + public ComponentTechnologyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Component component) { + if (StringUtils.isNullOrEmpty(component.getDescription())) { + return violation("The " + terminologyFor(component).toLowerCase() + " \"" + nameOf(component) + "\" is missing a technology."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.component.technology"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerDescriptionInspection.java new file mode 100644 index 000000000..0efcecc23 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerDescriptionInspection.java @@ -0,0 +1,16 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class ContainerDescriptionInspection extends ElementDescriptionInspection { + + public ContainerDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected String getType() { + return "model.container.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerTechnologyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerTechnologyInspection.java new file mode 100644 index 000000000..2c8efd2a2 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ContainerTechnologyInspection.java @@ -0,0 +1,28 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.util.StringUtils; + +public class ContainerTechnologyInspection extends AbstractContainerInspection { + + public ContainerTechnologyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Container container) { + if (StringUtils.isNullOrEmpty(container.getTechnology())) { + return violation("The " + terminologyFor(container).toLowerCase() + " \"" + nameOf(container) + "\" is missing a technology."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.container.technology"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspection.java new file mode 100644 index 000000000..c94c03567 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspection.java @@ -0,0 +1,16 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class DeploymentNodeDescriptionInspection extends ElementDescriptionInspection { + + public DeploymentNodeDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected String getType() { + return "model.deploymentnode.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspection.java new file mode 100644 index 000000000..8919c90df --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspection.java @@ -0,0 +1,28 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; +import com.structurizr.util.StringUtils; + +public class DeploymentNodeTechnologyInspection extends AbstractDeploymentNodeInspection { + + public DeploymentNodeTechnologyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(DeploymentNode deploymentNode) { + if (StringUtils.isNullOrEmpty(deploymentNode.getTechnology())) { + return violation("The " + terminologyFor(deploymentNode).toLowerCase() + " \"" + nameOf(deploymentNode) + "\" is missing a technology."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.deploymentnode.technology"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DisconnectedElementInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DisconnectedElementInspection.java new file mode 100644 index 000000000..328c2deb0 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/DisconnectedElementInspection.java @@ -0,0 +1,44 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; + +import java.util.HashSet; +import java.util.Set; + +public class DisconnectedElementInspection extends AbstractElementInspection { + + private final Set<String> elementsWithRelationships = new HashSet<>(); + + public DisconnectedElementInspection(Inspector inspector) { + super(inspector); + + for (Relationship relationship : getWorkspace().getModel().getRelationships()) { + elementsWithRelationships.add(relationship.getSourceId()); + elementsWithRelationships.add(relationship.getDestinationId()); + } + } + + @Override + protected Violation inspect(Element element) { + if (element instanceof DeploymentNode) { + // deployment nodes typically won't have relationships to/from them + return noViolation(); + } + + if (!elementsWithRelationships.contains(element.getId())) { + return violation("The " + terminologyFor(element).toLowerCase() + " \"" + nameOf(element) + "\" is disconnected - add a relationship to/from it, or consider removing it from the model."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.element.disconnected"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementDescriptionInspection.java new file mode 100644 index 000000000..b981ab01b --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementDescriptionInspection.java @@ -0,0 +1,23 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.util.StringUtils; + +abstract class ElementDescriptionInspection extends AbstractElementInspection { + + public ElementDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Element element) { + if (StringUtils.isNullOrEmpty(element.getDescription())) { + return violation("The " + terminologyFor(element).toLowerCase() + " \"" + nameOf(element) + "\" is missing a description."); + } + + return noViolation(); + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspection.java new file mode 100644 index 000000000..a53857c1c --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspection.java @@ -0,0 +1,44 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Element; +import com.structurizr.view.ElementView; +import com.structurizr.view.ModelView; +import com.structurizr.view.View; + +import java.util.HashSet; +import java.util.Set; + +public class ElementNotIncludedInAnyViewsInspection extends AbstractElementInspection { + + private final Set<String> elementsInViews = new HashSet<>(); + + public ElementNotIncludedInAnyViewsInspection(Inspector inspector) { + super(inspector); + + for (View view : getWorkspace().getViews().getViews()) { + if (view instanceof ModelView) { + ModelView modelView = (ModelView)view; + for (ElementView elementView : modelView.getElements()) { + elementsInViews.add(elementView.getId()); + } + } + } + } + + @Override + protected Violation inspect(Element element) { + if (!elementsInViews.contains(element.getId())) { + return violation("The " + terminologyFor(element) + " named \"" + element.getName() + "\" is not included on any views - add it to a view."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.element.noview"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyDeploymentNodeInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyDeploymentNodeInspection.java new file mode 100644 index 000000000..207d6e48e --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyDeploymentNodeInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; + +public class EmptyDeploymentNodeInspection extends AbstractDeploymentNodeInspection { + + public EmptyDeploymentNodeInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(DeploymentNode deploymentNode) { + if (!deploymentNode.hasChildren() && !deploymentNode.hasSoftwareSystemInstances() && !deploymentNode.hasContainerInstances() && !deploymentNode.hasInfrastructureNodes()) { + return violation("The " + terminologyFor(deploymentNode).toLowerCase() + " \"" + nameOf(deploymentNode) + "\" is empty."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.deploymentnode.empty"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyModelInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyModelInspection.java new file mode 100644 index 000000000..4031f44b9 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/EmptyModelInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; + +public class EmptyModelInspection extends AbstractModelInspection { + + public EmptyModelInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + if (workspace.getModel().isEmpty()) { + return violation("The model is empty."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.empty"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspection.java new file mode 100644 index 000000000..91ffca161 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspection.java @@ -0,0 +1,15 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class InfrastructureNodeDescriptionInspection extends ElementDescriptionInspection { + + public InfrastructureNodeDescriptionInspection(Inspector inspector) { + super(inspector); + } + + protected String getType() { + return "model.infrastructurenode.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspection.java new file mode 100644 index 000000000..1365d2c50 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspection.java @@ -0,0 +1,28 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.InfrastructureNode; +import com.structurizr.util.StringUtils; + +public class InfrastructureNodeTechnologyInspection extends AbstractInfrastructureNodeInspection { + + public InfrastructureNodeTechnologyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(InfrastructureNode infrastructureNode) { + if (StringUtils.isNullOrEmpty(infrastructureNode.getTechnology())) { + return violation("The " + terminologyFor(infrastructureNode).toLowerCase() + " \"" + nameOf(infrastructureNode) + "\" is missing a technology."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.infrastructurenode.technology"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspection.java new file mode 100644 index 000000000..a3220a769 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspection.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; + +public class MultipleSoftwareSystemsDetailedInspection extends AbstractModelInspection { + + public MultipleSoftwareSystemsDetailedInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + int softwareSystemsWithDetails = 0; + for (SoftwareSystem softwareSystem : workspace.getModel().getSoftwareSystems()) { + if (softwareSystem.hasContainers() || !softwareSystem.getDocumentation().isEmpty()) { + softwareSystemsWithDetails++; + } + } + + if (softwareSystemsWithDetails > 1) { + return violation("This workspace describes the internal details of " + softwareSystemsWithDetails + " software systems. It is recommended that a workspace contains the model, views, and documentation for a single software system only."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "workspace.scope"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/PersonDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/PersonDescriptionInspection.java new file mode 100644 index 000000000..ed9ec4753 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/PersonDescriptionInspection.java @@ -0,0 +1,16 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class PersonDescriptionInspection extends ElementDescriptionInspection { + + public PersonDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected String getType() { + return "model.person.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipDescriptionInspection.java new file mode 100644 index 000000000..9159e29d9 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipDescriptionInspection.java @@ -0,0 +1,33 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.util.StringUtils; + +public class RelationshipDescriptionInspection extends AbstractRelationshipInspection { + + public RelationshipDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Relationship relationship) { + Element source = relationship.getSource(); + Element destination = relationship.getDestination(); + + if (StringUtils.isNullOrEmpty(relationship.getDescription())) { + return violation("The relationship between the " + terminologyFor(source) + " \"" + nameOf(source) + "\" and the " + terminologyFor(destination) + " \"" + nameOf(destination) + "\" is missing a description."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.relationship.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipTechnologyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipTechnologyInspection.java new file mode 100644 index 000000000..48a0bf2f7 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/RelationshipTechnologyInspection.java @@ -0,0 +1,33 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import com.structurizr.model.Relationship; +import com.structurizr.util.StringUtils; + +public class RelationshipTechnologyInspection extends AbstractRelationshipInspection { + + public RelationshipTechnologyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Relationship relationship) { + Element source = relationship.getSource(); + Element destination = relationship.getDestination(); + + if (StringUtils.isNullOrEmpty(relationship.getTechnology())) { + return violation("The relationship between the " + terminologyFor(source) + " \"" + nameOf(source) + "\" and the " + terminologyFor(destination) + " \"" + nameOf(destination) + "\" is missing a technology."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.relationship.technology"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDecisionsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDecisionsInspection.java new file mode 100644 index 000000000..890c579f4 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDecisionsInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; + +public class SoftwareSystemDecisionsInspection extends AbstractSoftwareSystemInspection { + + public SoftwareSystemDecisionsInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(SoftwareSystem softwareSystem) { + if (softwareSystem.hasContainers() && softwareSystem.getDocumentation().getDecisions().isEmpty()) { + return violation("The " + terminologyFor(softwareSystem).toLowerCase() + " \"" + nameOf(softwareSystem) + "\" has containers, but is missing decisions."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.softwaresystem.decisions"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspection.java new file mode 100644 index 000000000..0ea96e233 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspection.java @@ -0,0 +1,16 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; + +public class SoftwareSystemDescriptionInspection extends ElementDescriptionInspection { + + public SoftwareSystemDescriptionInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected String getType() { + return "model.softwaresystem.description"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspection.java new file mode 100644 index 000000000..8496d310d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.model; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; + +public class SoftwareSystemDocumentationInspection extends AbstractSoftwareSystemInspection { + + public SoftwareSystemDocumentationInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(SoftwareSystem softwareSystem) { + if (softwareSystem.hasContainers() && softwareSystem.getDocumentation().getSections().isEmpty()) { + return violation("The " + terminologyFor(softwareSystem).toLowerCase() + " \"" + nameOf(softwareSystem) + "\" has containers, but is missing documentation."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "model.softwaresystem.documentation"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractModelViewInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractModelViewInspection.java new file mode 100644 index 000000000..157ccc0e5 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractModelViewInspection.java @@ -0,0 +1,24 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ModelView; + +abstract class AbstractModelViewInspection extends Inspection { + + public AbstractModelViewInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(ModelView view) { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, view); + Violation violation = inspect(view); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(ModelView view); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewInspection.java new file mode 100644 index 000000000..a4a44ad84 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewInspection.java @@ -0,0 +1,24 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.View; + +abstract class AbstractViewInspection extends Inspection { + + public AbstractViewInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(View view) { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, view); + Violation violation = inspect(view); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(View view); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewsInspection.java new file mode 100644 index 000000000..a2c64a831 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/AbstractViewsInspection.java @@ -0,0 +1,24 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; + +abstract class AbstractViewsInspection extends Inspection { + + public AbstractViewsInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run() { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, getWorkspace().getViews()); + Violation violation = inspect(getWorkspace()); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(Workspace workspace); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspection.java new file mode 100644 index 000000000..beb8d048d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspection.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ContainerView; + +import java.util.HashSet; +import java.util.Set; + +public class ContainerViewsForMultipleSoftwareSystemsInspection extends AbstractViewsInspection { + + public ContainerViewsForMultipleSoftwareSystemsInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + Set<String> softwareSystemsWithContainerViews = new HashSet<>(); + for (ContainerView view : workspace.getViews().getContainerViews()) { + softwareSystemsWithContainerViews.add(view.getSoftwareSystemId()); + } + if (softwareSystemsWithContainerViews.size() > 1) { + return violation("Container views exist for " + softwareSystemsWithContainerViews.size() + " software systems. It is recommended that a workspace includes container views for a single software system only."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "workspace.scope"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ElementStyleMetadataInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ElementStyleMetadataInspection.java new file mode 100644 index 000000000..027d94b16 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ElementStyleMetadataInspection.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ElementStyle; + +public class ElementStyleMetadataInspection extends Inspection { + + public ElementStyleMetadataInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run(ElementStyle elementStyle) { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, elementStyle); + Violation violation = inspect(elementStyle); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected Violation inspect(ElementStyle elementStyle) { + if (elementStyle.getMetadata() != null && !elementStyle.getMetadata()) { + return violation("The element style for tag \"" + elementStyle.getTag() + "\" has metadata hidden, which may introduce ambiguity on rendered diagrams."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "views.styles.element.metadata"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewInspection.java new file mode 100644 index 000000000..af9763c1d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ModelView; + +public class EmptyViewInspection extends AbstractModelViewInspection { + + public EmptyViewInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(ModelView view) { + if (view.getElements().isEmpty()) { + return violation("The view with key \"" + view.getKey() + "\" is empty."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "views.view.empty"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewsInspection.java new file mode 100644 index 000000000..9f5b1e61c --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/EmptyViewsInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; + +public class EmptyViewsInspection extends AbstractViewsInspection { + + public EmptyViewsInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + if (workspace.getViews().isEmpty()) { + return violation("This workspace has no views."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "views.empty"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/GeneratedKeyInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/GeneratedKeyInspection.java new file mode 100644 index 000000000..9ea19512d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/GeneratedKeyInspection.java @@ -0,0 +1,28 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ModelView; +import com.structurizr.view.View; + +public class GeneratedKeyInspection extends AbstractViewInspection { + + public GeneratedKeyInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(View view) { + if (view.isGeneratedKey()) { + return violation("The view with key \"" + view.getKey() + "\" has an automatically generated view key and this is not guaranteed to be stable over time."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "views.view.key"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ManualLayoutInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ManualLayoutInspection.java new file mode 100644 index 000000000..69bbe4c0d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/ManualLayoutInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.view; + +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ModelView; + +public class ManualLayoutInspection extends AbstractModelViewInspection { + + public ManualLayoutInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(ModelView view) { + if (view.isGeneratedKey() && view.getAutomaticLayout() == null) { + return violation("The view with key \"" + view.getKey() + "\" has an automatically generated view key and this may cause manual layout information to be lost in the future."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "views.view.layout"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspection.java new file mode 100644 index 000000000..6314f6931 --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspection.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.view.SystemContextView; + +import java.util.HashSet; +import java.util.Set; + +public class SystemContextViewsForMultipleSoftwareSystemsInspection extends AbstractViewsInspection { + + public SystemContextViewsForMultipleSoftwareSystemsInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + Set<String> softwareSystemsWithSystemContextViews = new HashSet<>(); + for (SystemContextView view : workspace.getViews().getSystemContextViews()) { + softwareSystemsWithSystemContextViews.add(view.getSoftwareSystemId()); + } + if (softwareSystemsWithSystemContextViews.size() > 1) { + return violation("System context views exist for " + softwareSystemsWithSystemContextViews.size() + " software systems. It is recommended that a workspace includes system context views for a single software system only."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "workspace.scope"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/AbstractWorkspaceInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/AbstractWorkspaceInspection.java new file mode 100644 index 000000000..a74a52dca --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/AbstractWorkspaceInspection.java @@ -0,0 +1,24 @@ +package com.structurizr.inspection.workspace; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspection; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; + +abstract class AbstractWorkspaceInspection extends Inspection { + + public AbstractWorkspaceInspection(Inspector inspector) { + super(inspector); + } + + public final Violation run() { + Severity severity = getInspector().getSeverityStrategy().getSeverity(this, getWorkspace()); + Violation violation = inspect(getWorkspace()); + + return violation == null ? null : violation.withSeverity(severity); + } + + protected abstract Violation inspect(Workspace workspace); + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceScopeInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceScopeInspection.java new file mode 100644 index 000000000..5619abafc --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceScopeInspection.java @@ -0,0 +1,27 @@ +package com.structurizr.inspection.workspace; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; + +public class WorkspaceScopeInspection extends AbstractWorkspaceInspection { + + public WorkspaceScopeInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + if (workspace.getConfiguration().getScope() == null) { + return violation("This workspace has no defined scope. It is recommended that the workspace scope is set to \"Landscape\" or \"SoftwareSystem\"."); + } + + return noViolation(); + } + + @Override + protected String getType() { + return "workspace.scope"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceToolingInspection.java b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceToolingInspection.java new file mode 100644 index 000000000..19b9c2e7d --- /dev/null +++ b/structurizr-inspection/src/main/java/com/structurizr/inspection/workspace/WorkspaceToolingInspection.java @@ -0,0 +1,33 @@ +package com.structurizr.inspection.workspace; + +import com.structurizr.Workspace; +import com.structurizr.inspection.Inspector; +import com.structurizr.inspection.Violation; +import com.structurizr.util.StringUtils; + +public class WorkspaceToolingInspection extends AbstractWorkspaceInspection { + + private static final String CLOUD_SERVICE_DSL_EDITOR = "structurizr-cloud/dsl-editor"; + private static final String ONPREMISES_DSL_EDITOR = "structurizr-onpremises/dsl-editor"; + + public WorkspaceToolingInspection(Inspector inspector) { + super(inspector); + } + + @Override + protected Violation inspect(Workspace workspace) { + if (!StringUtils.isNullOrEmpty(workspace.getLastModifiedAgent())) { + if (workspace.getLastModifiedAgent().startsWith(CLOUD_SERVICE_DSL_EDITOR) || workspace.getLastModifiedAgent().startsWith(ONPREMISES_DSL_EDITOR)) { + return violation("The browser-based DSL editor is the easiest way to get started without installing any tooling, but it does not provide access to the full feature set of the Structurizr DSL. It is recommended that you use the Structurizr DSL in conjunction with the Structurizr CLI's \"push\" command."); + } + } + + return noViolation(); + } + + @Override + protected String getType() { + return "workspace.tooling"; + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/DefaultInspectorTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/DefaultInspectorTests.java new file mode 100644 index 000000000..619f12d22 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/DefaultInspectorTests.java @@ -0,0 +1,103 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; +import com.structurizr.dsl.StructurizrDslParser; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class DefaultInspectorTests { + + @Test + void test_EmptyWorkspace() { + Workspace workspace = new Workspace("Name", "Description"); + DefaultInspector inspector = new DefaultInspector(workspace); + List<Violation> violations = inspector.getViolations(); + + assertEquals(9, inspector.getNumberOfInspections()); + assertEquals(3, violations.size()); + + Violation violation = violations.get(0); + assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("This workspace has no defined scope. It is recommended that the workspace scope is set to \"Landscape\" or \"SoftwareSystem\".", violation.getMessage()); + + violation = violations.get(1); + assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.empty", violation.getType()); + assertEquals("The model is empty.", violation.getMessage()); + + violation = violations.get(2); + assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.empty", violation.getType()); + assertEquals("This workspace has no views.", violation.getMessage()); + + assertEquals("3", workspace.getProperties().get("structurizr.inspection.error")); + assertEquals("0", workspace.getProperties().get("structurizr.inspection.warning")); + assertEquals("0", workspace.getProperties().get("structurizr.inspection.info")); + assertEquals("0", workspace.getProperties().get("structurizr.inspection.ignore")); + } + + @Test + void test_EmptyWorkspace_WhenInspectionsAreDisabled() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.addProperty("structurizr.inspection", "false"); + + DefaultInspector inspector = new DefaultInspector(workspace); + List<Violation> violations = inspector.getViolations(); + + assertEquals(0, inspector.getNumberOfInspections()); + assertEquals(0, violations.size()); + assertNull(workspace.getProperties().get("structurizr.inspection.error")); + assertNull(workspace.getProperties().get("structurizr.inspection.warning")); + assertNull(workspace.getProperties().get("structurizr.inspection.info")); + assertNull(workspace.getProperties().get("structurizr.inspection.ignore")); + } + + @Test + void test() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/workspace.dsl")); + Workspace workspace = parser.getWorkspace(); + + DefaultInspector inspector = new DefaultInspector(workspace); + Collection<Violation> violations = inspector.getViolations(); + + for (Violation violation : violations) { + System.out.println(violation); + } + + assertEquals(28, violations.size()); + assertEquals(0, violations.stream().filter(v -> v.getSeverity() == Severity.ERROR).count()); + assertEquals(27, violations.stream().filter(v -> v.getSeverity() == Severity.WARNING).count()); + assertEquals(1, violations.stream().filter(v -> v.getSeverity() == Severity.INFO).count()); + } + + @Test + void test_WithAllViolationsAsError() throws Exception { + StructurizrDslParser parser = new StructurizrDslParser(); + parser.parse(new File("src/test/resources/workspace.dsl")); + Workspace workspace = parser.getWorkspace(); + + DefaultInspector inspector = new DefaultInspector(workspace) { + @Override + public SeverityStrategy getSeverityStrategy() { + return new FixedSeverityStrategy(Severity.ERROR); + } + }; + Collection<Violation> violations = inspector.getViolations(); + + for (Violation violation : violations) { + System.out.println(violation); + } + + assertEquals(28, violations.size()); + assertEquals(28, violations.stream().filter(v -> v.getSeverity() == Severity.ERROR).count()); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/PropertyBasedSeverityStrategyTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/PropertyBasedSeverityStrategyTests.java new file mode 100644 index 000000000..e6bf91594 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/PropertyBasedSeverityStrategyTests.java @@ -0,0 +1,271 @@ +package com.structurizr.inspection; + +import com.structurizr.Workspace; +import com.structurizr.inspection.model.ComponentDescriptionInspection; +import com.structurizr.inspection.model.RelationshipDescriptionInspection; +import com.structurizr.inspection.model.RelationshipTechnologyInspection; +import com.structurizr.inspection.workspace.WorkspaceScopeInspection; +import com.structurizr.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PropertyBasedSeverityStrategyTests { + + private Workspace workspace; + private Inspector inspector; + private Inspection inspection; + private PropertyBasedSeverityStrategy severityStrategy; + + @BeforeEach + void setUp() { + workspace = new Workspace("Name", "Description"); + inspector = new Inspector(workspace) { + @Override + public SeverityStrategy getSeverityStrategy() { + return null; + } + }; + severityStrategy = new PropertyBasedSeverityStrategy(); + } + + @Test + void getSeverityForWorkspace() { + inspection = new WorkspaceScopeInspection(inspector); + + // default is error + assertEquals(Severity.ERROR, severityStrategy.getSeverity(inspection, workspace)); + } + + @Test + void getSeverityForWorkspace_WhenInspectionSpecifiedByName() { + inspection = new WorkspaceScopeInspection(inspector); + + // specify by name at workspace level + workspace.addProperty("structurizr.inspection." + inspection.getType(), "warning"); + assertEquals(Severity.WARNING, severityStrategy.getSeverity(inspection, workspace)); + } + + @Test + void getSeverityForWorkspace_WhenInspectionSpecifiedByWildcard() { + inspection = new WorkspaceScopeInspection(inspector); + + // specify by wildcard at workspace level + workspace.addProperty("structurizr.inspection.*", "warning"); + assertEquals(Severity.WARNING, severityStrategy.getSeverity(inspection, workspace)); + } + + @Test + void getSeverityForComponent() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // default is error + assertEquals(Severity.ERROR, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenInspectionSpecifiedByNameInComponent() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by name at component level + component.addProperty("structurizr.inspection." + inspection.getType(), "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenInspectionSpecifiedByWildcardInComponent() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by wildcard at component level + workspace.addProperty("structurizr.inspection.model.component.*", "ignore"); + component.addProperty("structurizr.inspection.model.component.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenInspectionSpecifiedByNameInContainer() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by name in parent container + container.addProperty("structurizr.inspection." + inspection.getType(), "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenInspectionSpecifiedByWildcardInContainer() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by wildcard in parent container + container.addProperty("structurizr.inspection.model.component.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByNameInSoftwareSystem() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by name in parent software system + softwareSystem.addProperty("structurizr.inspection." + inspection.getType(), "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByWildcardInSoftwareSystem() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by wildcard in parent software system + softwareSystem.addProperty("structurizr.inspection.model.component.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByNameInModel() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by name in model + workspace.getModel().addProperty("structurizr.inspection." + inspection.getType(), "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByWildcardInModel() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by wildcard in model + workspace.getModel().addProperty("structurizr.inspection.model.component.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByNameInWorkspace() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by name in workspace + workspace.addProperty("structurizr.inspection." + inspection.getType(), "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByComponentWildcardInWorkspace() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by wildcard in workspace + workspace.addProperty("structurizr.inspection.model.component.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void getSeverityForComponent_WhenSpecifiedByModelWildcardInWorkspace() { + inspection = new ComponentDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Component"); + + // specify by model wildcard in workspace + workspace.addProperty("structurizr.inspection.model.*", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, component)); + } + + @Test + void generateTypes_ForElement() { + PropertyBasedSeverityStrategy strategy = new PropertyBasedSeverityStrategy(); + List<String> types = strategy.generatePropertyNames("model.component.description"); + + assertEquals(4, types.size()); + assertEquals("structurizr.inspection.model.component.description", types.get(0)); + assertEquals("structurizr.inspection.model.component.*", types.get(1)); + assertEquals("structurizr.inspection.model.*", types.get(2)); + assertEquals("structurizr.inspection.*", types.get(3)); + } + + @Test + void generateTypes_ForRelationship() { + PropertyBasedSeverityStrategy strategy = new PropertyBasedSeverityStrategy(); + List<String> types = strategy.generatePropertyNames("model.relationship[component->component].technology"); + + assertEquals(2, types.size()); + assertEquals("structurizr.inspection.model.relationship[component->component].technology", types.get(0)); + assertEquals("structurizr.inspection.model.relationship[component->component].*", types.get(1)); + } + + @Test + void getSeverityForRelationship_BetweenComponents() { + inspection = new RelationshipDescriptionInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component1 = container.addComponent("Component 1"); + Component component2 = container.addComponent("Component 2"); + Relationship relationship = component1.uses(component2, ""); + + // default is error + assertEquals(Severity.ERROR, severityStrategy.getSeverity(inspection, relationship)); + } + + @Test + void getSeverityForRelationship_BetweenComponents_WhenSpecifiedByRelationshipTypeInWorkspace() { + inspection = new RelationshipTechnologyInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component1 = container.addComponent("Component 1"); + Component component2 = container.addComponent("Component 2"); + Relationship relationship = component1.uses(component2, ""); + + // specify by relationship type in workspace + workspace.addProperty("structurizr.inspection.model.relationship[component->component].technology", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, relationship)); + } + + @Test + void getSeverityForRelationship_WhenSpecifiedInLinkedRelationship() { + inspection = new RelationshipTechnologyInspection(inspector); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + workspace.getModel().setImpliedRelationshipsStrategy(new CreateImpliedRelationshipsUnlessAnyRelationshipExistsStrategy()); + Container container1 = softwareSystem.addContainer("Container 1"); + Component component1 = container1.addComponent("Component 1"); + Container container2 = softwareSystem.addContainer("Container 2"); + Component component2 = container2.addComponent("Component 2"); + Relationship relationship = component1.uses(component2, ""); + Relationship impliedRelationship = container1.getEfferentRelationshipWith(container2); + + // specify in original relationship + relationship.addProperty("structurizr.inspection.model.relationship.technology", "info"); + assertEquals(Severity.INFO, severityStrategy.getSeverity(inspection, impliedRelationship)); + } + +} \ No newline at end of file diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java new file mode 100644 index 000000000..9cf023b41 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewMissingInspectionTests.java @@ -0,0 +1,91 @@ +package com.structurizr.inspection.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.inspection.model.SoftwareSystemDocumentationInspection; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class EmbeddedViewMissingInspectionTests { + + @Test + public void run_WithMissingView() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Section section = new Section(); + section.setFormat(Format.Markdown); + section.setContent(""" + ## Context + + ![](embed:SystemContext) + """); + softwareSystem.getDocumentation().addSection(section); + + Decision decision = new Decision("1"); + decision.setTitle("Decision 1"); + decision.setStatus("Accepted"); + decision.setFormat(Format.AsciiDoc); + decision.setContent(""" + ## Containers + + image::embed:Containers[] + """); + softwareSystem.getDocumentation().addDecision(decision); + + Violation violation = new EmbeddedViewMissingInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("documentation.embeddedview", violation.getType()); + assertEquals("The following views are embedded into documentation for the software system \"Software System\" but do not exist in the workspace: SystemContext, Containers", violation.getMessage()); + + workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", "Description"); + + violation = new EmbeddedViewMissingInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("documentation.embeddedview", violation.getType()); + assertEquals("The following views are embedded into documentation for the software system \"Software System\" but do not exist in the workspace: Containers", violation.getMessage()); + } + + @Test + public void run_WithoutMissingView() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Section section = new Section(); + section.setFormat(Format.Markdown); + section.setContent(""" + ## Context + + ![](embed:SystemContext) + """); + softwareSystem.getDocumentation().addSection(section); + + Decision decision = new Decision("1"); + decision.setTitle("Decision 1"); + decision.setStatus("Accepted"); + decision.setFormat(Format.AsciiDoc); + decision.setContent(""" + ## Containers + + image::embed:Containers[] + """); + softwareSystem.getDocumentation().addDecision(decision); + + workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", "Description"); + workspace.getViews().createContainerView(softwareSystem, "Containers", "Description"); + + Violation violation = new EmbeddedViewMissingInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspectionTests.java new file mode 100644 index 000000000..d602d65f8 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/documentation/EmbeddedViewWithGeneratedKeyInspectionTests.java @@ -0,0 +1,80 @@ +package com.structurizr.inspection.documentation; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class EmbeddedViewWithGeneratedKeyInspectionTests { + + @Test + public void run_WithGeneratedKey() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Section section = new Section(); + section.setFormat(Format.Markdown); + section.setContent(""" + ## Context + + ![](embed:SystemContext-001) + """); + softwareSystem.getDocumentation().addSection(section); + + section = new Section(); + section.setFormat(Format.AsciiDoc); + section.setContent(""" + ## Containers + + image::embed:Container-001[] + """); + softwareSystem.getDocumentation().addSection(section); + + workspace.getViews().createSystemContextView(softwareSystem, "", "Description"); + workspace.getViews().createContainerView(softwareSystem, "", "Description"); + + Violation violation = new EmbeddedViewWithGeneratedKeyInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("documentation.embeddedview", violation.getType()); + assertEquals("The following views are embedded into documentation for the software system \"Software System\" via an automatically generated view key: SystemContext-001, Container-001", violation.getMessage()); + } + + @Test + public void run_WithoutGeneratedKey() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + + Section section = new Section(); + section.setFormat(Format.Markdown); + section.setContent(""" + ## Context + + ![](embed:SystemContext) + """); + softwareSystem.getDocumentation().addSection(section); + + section = new Section(); + section.setFormat(Format.AsciiDoc); + section.setContent(""" + ## Containers + + image::embed:Containers[] + """); + softwareSystem.getDocumentation().addSection(section); + + workspace.getViews().createSystemContextView(softwareSystem, "SystemContext", "Description"); + workspace.getViews().createContainerView(softwareSystem, "Containers", "Description"); + + Violation violation = new EmbeddedViewWithGeneratedKeyInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentDescriptionInspectionTests.java new file mode 100644 index 000000000..8417ac25b --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentDescriptionInspectionTests.java @@ -0,0 +1,43 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +ComponentDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Name"); + + Violation violation = new ComponentDescriptionInspection(new DefaultInspector(workspace)).run(component); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.component.description", violation.getType()); + assertEquals("The component \"Software System.Container.Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Name", "Description"); + + Violation violation = new ComponentDescriptionInspection(new DefaultInspector(workspace)).run(component); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentTechnologyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentTechnologyInspectionTests.java new file mode 100644 index 000000000..f81612bdd --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ComponentTechnologyInspectionTests.java @@ -0,0 +1,43 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +ComponentTechnologyInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Name"); + + Violation violation = new ComponentTechnologyInspection(new DefaultInspector(workspace)).run(component); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.component.technology", violation.getType()); + assertEquals("The component \"Software System.Container.Name\" is missing a technology.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + Component component = container.addComponent("Name", "Description", "Technology"); + + Violation violation = new ComponentTechnologyInspection(new DefaultInspector(workspace)).run(component); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerDescriptionInspectionTests.java new file mode 100644 index 000000000..423aca5fb --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerDescriptionInspectionTests.java @@ -0,0 +1,40 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +ContainerDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name"); + + Violation violation = new ContainerDescriptionInspection(new DefaultInspector(workspace)).run(container); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.container.description", violation.getType()); + assertEquals("The container \"Software System.Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name", "Description"); + + Violation violation = new ContainerDescriptionInspection(new DefaultInspector(workspace)).run(container); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerTechnologyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerTechnologyInspectionTests.java new file mode 100644 index 000000000..c2f7d681c --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ContainerTechnologyInspectionTests.java @@ -0,0 +1,40 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +ContainerTechnologyInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name"); + + Violation violation = new ContainerTechnologyInspection(new DefaultInspector(workspace)).run(container); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.container.technology", violation.getType()); + assertEquals("The container \"Software System.Name\" is missing a technology.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name", "Description", "Technology"); + + Violation violation = new ContainerTechnologyInspection(new DefaultInspector(workspace)).run(container); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspectionTests.java new file mode 100644 index 000000000..96adc01dd --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeDescriptionInspectionTests.java @@ -0,0 +1,37 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +DeploymentNodeDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Name"); + + Violation violation = new DeploymentNodeDescriptionInspection(new DefaultInspector(workspace)).run(deploymentNode); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.deploymentnode.description", violation.getType()); + assertEquals("The deployment node \"Default/Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Name", "Description", "Technology"); + + Violation violation = new DeploymentNodeDescriptionInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspectionTests.java new file mode 100644 index 000000000..dc7844b8e --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DeploymentNodeTechnologyInspectionTests.java @@ -0,0 +1,37 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.DeploymentNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +DeploymentNodeTechnologyInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Name", "Description", ""); + + Violation violation = new DeploymentNodeTechnologyInspection(new DefaultInspector(workspace)).run(deploymentNode); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.deploymentnode.technology", violation.getType()); + assertEquals("The deployment node \"Default/Name\" is missing a technology.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Name", "Description", "Technology"); + + Violation violation = new DeploymentNodeTechnologyInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DisconnectedElementInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DisconnectedElementInspectionTests.java new file mode 100644 index 000000000..c944d8e38 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/DisconnectedElementInspectionTests.java @@ -0,0 +1,42 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +DisconnectedElementInspectionTests { + + @Test + public void run_WithDisconnectedElement() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + + Violation violation = new DisconnectedElementInspection(new DefaultInspector(workspace)).run(a); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.element.disconnected", violation.getType()); + assertEquals("The software system \"A\" is disconnected - add a relationship to/from it, or consider removing it from the model.", violation.getMessage()); + } + + @Test + public void run_WithoutDisconnectedElement() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + a.uses(b, "Uses"); + + Violation violation = new DisconnectedElementInspection(new DefaultInspector(workspace)).run(a); + assertNull(violation); + + violation = new DisconnectedElementInspection(new DefaultInspector(workspace)).run(b); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspectionTests.java new file mode 100644 index 000000000..a6a9232f3 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/ElementNotIncludedInAnyViewsInspectionTests.java @@ -0,0 +1,38 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +ElementNotIncludedInAnyViewsInspectionTests { + + @Test + public void run_NotInViews() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + + Violation violation = new ElementNotIncludedInAnyViewsInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.element.noview", violation.getType()); + assertEquals("The software system named \"Name\" is not included on any views - add it to a view.", violation.getMessage()); + } + + @Test + public void run_InViews() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + workspace.getViews().createSystemLandscapeView("key", "Description").addAllElements(); + + Violation violation = new ElementNotIncludedInAnyViewsInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyDeploymentNodeCheckTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyDeploymentNodeCheckTests.java new file mode 100644 index 000000000..100b254be --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyDeploymentNodeCheckTests.java @@ -0,0 +1,73 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.DeploymentNode; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +EmptyDeploymentNodeCheckTests { + + @Test + public void run_Empty() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Name"); + + Violation violation = new EmptyDeploymentNodeInspection(new DefaultInspector(workspace)).run(deploymentNode); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.deploymentnode.empty", violation.getType()); + assertEquals("The deployment node \"Default/Name\" is empty.", violation.getMessage()); + } + + @Test + public void run_WithDeploymentNode() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node", "Description", "Technology"); + deploymentNode.addDeploymentNode("Deployment Node"); + + Violation violation = new EmptyDeploymentNodeInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + + @Test + public void run_WithInfrastructureNode() { + Workspace workspace = new Workspace("Name", "Description"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node", "Description", "Technology"); + deploymentNode.addInfrastructureNode("Infrastructure Node"); + + Violation violation = new EmptyDeploymentNodeInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + + @Test + public void run_WithSoftwareSystemInstance() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node", "Description", "Technology"); + deploymentNode.add(softwareSystem); + + Violation violation = new EmptyDeploymentNodeInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + + @Test + public void run_WithContainerInstance() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Container"); + DeploymentNode deploymentNode = workspace.getModel().addDeploymentNode("Deployment Node", "Description", "Technology"); + deploymentNode.add(container); + + Violation violation = new EmptyDeploymentNodeInspection(new DefaultInspector(workspace)).run(deploymentNode); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyModelInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyModelInspectionTests.java new file mode 100644 index 000000000..7d3c4b0e2 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/EmptyModelInspectionTests.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +EmptyModelInspectionTests { + + @Test + public void run_WhenThereAreNoElements() { + Workspace workspace = new Workspace("Name", "Description"); + + Violation violation = new EmptyModelInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.empty", violation.getType()); + assertEquals("The model is empty.", violation.getMessage()); + } + + @Test + public void run_WhenThereAreElements() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("Name"); + + Violation violation = new EmptyModelInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspectionTests.java new file mode 100644 index 000000000..99be5e896 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeDescriptionInspectionTests.java @@ -0,0 +1,39 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.InfrastructureNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +InfrastructureNodeDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + InfrastructureNode infrastructureNode = workspace.getModel().addDeploymentNode("Deployment Node") + .addInfrastructureNode("Name"); + + Violation violation = new InfrastructureNodeDescriptionInspection(new DefaultInspector(workspace)).run(infrastructureNode); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.infrastructurenode.description", violation.getType()); + assertEquals("The infrastructure node \"Default/Deployment Node/Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + InfrastructureNode infrastructureNode = workspace.getModel().addDeploymentNode("Deployment Node") + .addInfrastructureNode("Name", "Description", "Technology"); + + Violation violation = new InfrastructureNodeDescriptionInspection(new DefaultInspector(workspace)).run(infrastructureNode); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspectionTests.java new file mode 100644 index 000000000..852bdeac8 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/InfrastructureNodeTechnologyInspectionTests.java @@ -0,0 +1,39 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.InfrastructureNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +InfrastructureNodeTechnologyInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + InfrastructureNode infrastructureNode = workspace.getModel().addDeploymentNode("Deployment Node") + .addInfrastructureNode("Name"); + + Violation violation = new InfrastructureNodeTechnologyInspection(new DefaultInspector(workspace)).run(infrastructureNode); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.infrastructurenode.technology", violation.getType()); + assertEquals("The infrastructure node \"Default/Deployment Node/Name\" is missing a technology.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + InfrastructureNode infrastructureNode = workspace.getModel().addDeploymentNode("Deployment Node") + .addInfrastructureNode("Name", "Description", "Technology"); + + Violation violation = new InfrastructureNodeTechnologyInspection(new DefaultInspector(workspace)).run(infrastructureNode); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspectionTests.java new file mode 100644 index 000000000..6f3fe9023 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/MultipleSoftwareSystemsDetailedInspectionTests.java @@ -0,0 +1,81 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +MultipleSoftwareSystemsDetailedInspectionTests { + + @Test + public void run_MultipleSoftwareSystemsWithContainers() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("A").addContainer("Container"); + workspace.getModel().addSoftwareSystem("B").addContainer("Container"); + + Violation violation = new MultipleSoftwareSystemsDetailedInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("This workspace describes the internal details of 2 software systems. It is recommended that a workspace contains the model, views, and documentation for a single software system only.", violation.getMessage()); + } + + @Test + public void run_MultipleSoftwareSystemsWithDocumentation() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addSoftwareSystem("A").getDocumentation().addSection(new Section(Format.Markdown, "# Section 1")); + workspace.getModel().addSoftwareSystem("B").getDocumentation().addSection(new Section(Format.Markdown, "# Section 1")); + + Violation violation = new MultipleSoftwareSystemsDetailedInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("This workspace describes the internal details of 2 software systems. It is recommended that a workspace contains the model, views, and documentation for a single software system only.", violation.getMessage()); + } + + @Test + public void run_MultipleSoftwareSystemsWithDecisions() { + Workspace workspace = new Workspace("Name", "Description"); + Decision decision = new Decision("1"); + decision.setTitle("Decision 1"); + decision.setFormat(Format.Markdown); + decision.setContent("Content"); + decision.setStatus("Accepted"); + workspace.getModel().addSoftwareSystem("A").getDocumentation().addDecision(decision); + workspace.getModel().addSoftwareSystem("B").getDocumentation().addDecision(decision); + + Violation violation = new MultipleSoftwareSystemsDetailedInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("This workspace describes the internal details of 2 software systems. It is recommended that a workspace contains the model, views, and documentation for a single software system only.", violation.getMessage()); + } + + @Test + public void run_SingleSoftwareSystem() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + a.addContainer("Container"); + + a.getDocumentation().addSection(new Section(Format.Markdown, "# Section 1")); + + Decision decision = new Decision("1"); + decision.setTitle("Decision 1"); + decision.setFormat(Format.Markdown); + decision.setContent("Content"); + decision.setStatus("Accepted"); + + a.getDocumentation().addDecision(decision); + + Violation violation = new MultipleSoftwareSystemsDetailedInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/PersonDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/PersonDescriptionInspectionTests.java new file mode 100644 index 000000000..02fc6c914 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/PersonDescriptionInspectionTests.java @@ -0,0 +1,37 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Person; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +PersonDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + Person person = workspace.getModel().addPerson("Name"); + + Violation violation = new PersonDescriptionInspection(new DefaultInspector(workspace)).run(person); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.person.description", violation.getType()); + assertEquals("The person \"Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + Person person = workspace.getModel().addPerson("Name", "Description"); + + Violation violation = new PersonDescriptionInspection(new DefaultInspector(workspace)).run(person); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipDescriptionInspectionTests.java new file mode 100644 index 000000000..df3bae94e --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipDescriptionInspectionTests.java @@ -0,0 +1,44 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +RelationshipDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, ""); + + Violation violation = new RelationshipDescriptionInspection(new DefaultInspector(workspace)).run(relationship); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.relationship.description", violation.getType()); + assertEquals("The relationship between the software system \"A\" and the software system \"B\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Description"); + + Violation violation = new RelationshipDescriptionInspection(new DefaultInspector(workspace)).run(relationship); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipTechnologyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipTechnologyInspectionTests.java new file mode 100644 index 000000000..ab022e14c --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/RelationshipTechnologyInspectionTests.java @@ -0,0 +1,43 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +RelationshipTechnologyInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Description"); + + Violation violation = new RelationshipTechnologyInspection(new DefaultInspector(workspace)).run(relationship); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.relationship.technology", violation.getType()); + assertEquals("The relationship between the software system \"A\" and the software system \"B\" is missing a technology.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + Relationship relationship = a.uses(b, "Description", "Technology"); + + Violation violation = new RelationshipTechnologyInspection(new DefaultInspector(workspace)).run(relationship); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDecisionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDecisionInspectionTests.java new file mode 100644 index 000000000..7508f3646 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDecisionInspectionTests.java @@ -0,0 +1,49 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Decision; +import com.structurizr.documentation.Format; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +SoftwareSystemDecisionInspectionTests { + + @Test + public void run_WithoutDecision() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name"); + + Violation violation = new SoftwareSystemDecisionsInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.softwaresystem.decisions", violation.getType()); + assertEquals("The software system \"Software System\" has containers, but is missing decisions.", violation.getMessage()); + } + + @Test + public void run_WithDecision() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name", "Description"); + + Decision decision = new Decision("1"); + decision.setFormat(Format.Markdown); + decision.setTitle("Decision 1"); + decision.setContent("Content"); + decision.setStatus("Accepted"); + softwareSystem.getDocumentation().addDecision(decision); + + Violation violation = new SoftwareSystemDecisionsInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspectionTests.java new file mode 100644 index 000000000..2b9405415 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDescriptionInspectionTests.java @@ -0,0 +1,37 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +SoftwareSystemDescriptionInspectionTests { + + @Test + public void run_WithoutDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name"); + + Violation violation = new SoftwareSystemDescriptionInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.softwaresystem.description", violation.getType()); + assertEquals("The software system \"Name\" is missing a description.", violation.getMessage()); + } + + @Test + public void run_WithDescription() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Name", "Description"); + + Violation violation = new SoftwareSystemDescriptionInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspectionTests.java new file mode 100644 index 000000000..e6d443477 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/model/SoftwareSystemDocumentationInspectionTests.java @@ -0,0 +1,43 @@ +package com.structurizr.inspection.model; + +import com.structurizr.Workspace; +import com.structurizr.documentation.Format; +import com.structurizr.documentation.Section; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.Container; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +SoftwareSystemDocumentationInspectionTests { + + @Test + public void run_WithoutDocumentation() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name"); + + Violation violation = new SoftwareSystemDocumentationInspection(new DefaultInspector(workspace)).run(softwareSystem); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("model.softwaresystem.documentation", violation.getType()); + assertEquals("The software system \"Software System\" has containers, but is missing documentation.", violation.getMessage()); + } + + @Test + public void run_WithDocumentation() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem softwareSystem = workspace.getModel().addSoftwareSystem("Software System"); + Container container = softwareSystem.addContainer("Name", "Description"); + softwareSystem.getDocumentation().addSection(new Section(Format.Markdown, "# Section 1")); + + Violation violation = new SoftwareSystemDocumentationInspection(new DefaultInspector(workspace)).run(softwareSystem); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspectionTests.java new file mode 100644 index 000000000..3c387d9df --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ContainerViewsForMultipleSoftwareSystemsInspectionTests.java @@ -0,0 +1,41 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ContainerViewsForMultipleSoftwareSystemsInspectionTests { + + @Test + public void run_MultipleSoftwareSystems() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createContainerView(a, "Containers-A", "Description"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + workspace.getViews().createContainerView(b, "Containers-B", "Description"); + + Violation violation = new ContainerViewsForMultipleSoftwareSystemsInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("Container views exist for 2 software systems. It is recommended that a workspace includes container views for a single software system only.", violation.getMessage()); + } + + @Test + public void run_SingleSoftwareSystem() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createContainerView(a, "Containers-A", "Description"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + + Violation violation = new ContainerViewsForMultipleSoftwareSystemsInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ElementStyleMetadataInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ElementStyleMetadataInspectionTests.java new file mode 100644 index 000000000..8b55a903c --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ElementStyleMetadataInspectionTests.java @@ -0,0 +1,47 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.ElementStyle; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class + +ElementStyleMetadataInspectionTests { + + @Test + public void run_WithMetadataFalse() { + Workspace workspace = new Workspace("Name", "Description"); + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().addElementStyle("Tag").metadata(false); + + Violation violation = new ElementStyleMetadataInspection(new DefaultInspector(workspace)).run(elementStyle); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.styles.element.metadata", violation.getType()); + assertEquals("The element style for tag \"Tag\" has metadata hidden, which may introduce ambiguity on rendered diagrams.", violation.getMessage()); + } + + @Test + public void run_WithMetadataTrue() { + Workspace workspace = new Workspace("Name", "Description"); + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().addElementStyle("Tag").metadata(true); + + Violation violation = new ElementStyleMetadataInspection(new DefaultInspector(workspace)).run(elementStyle); + assertNull(violation); + } + + @Test + public void run_WithMetadataUnset() { + Workspace workspace = new Workspace("Name", "Description"); + ElementStyle elementStyle = workspace.getViews().getConfiguration().getStyles().addElementStyle("Tag"); + + Violation violation = new ElementStyleMetadataInspection(new DefaultInspector(workspace)).run(elementStyle); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewInspectionTests.java new file mode 100644 index 000000000..8a46cae9d --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewInspectionTests.java @@ -0,0 +1,39 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +EmptyViewInspectionTests { + + @Test + public void run_EmptyView() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + + Violation violation = new EmptyViewInspection(new DefaultInspector(workspace)).run(view); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.view.empty", violation.getType()); + assertEquals("The view with key \"key\" is empty.", violation.getMessage()); + } + + @Test + public void run_NonEmptyView() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getModel().addPerson("User"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.addAllElements(); + + Violation violation = new EmptyViewInspection(new DefaultInspector(workspace)).run(view); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewsInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewsInspectionTests.java new file mode 100644 index 000000000..93e06d247 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/EmptyViewsInspectionTests.java @@ -0,0 +1,36 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class + +EmptyViewsInspectionTests { + + @Test + public void run_WhenThereAreNoViews() { + Workspace workspace = new Workspace("Name", "Description"); + + Violation violation = new EmptyViewsInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.empty", violation.getType()); + assertEquals("This workspace has no views.", violation.getMessage()); + } + + @Test + public void run_WhenThereAreViews() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getViews().createSystemLandscapeView("key", "Description"); + + Violation violation = new EmptyViewsInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/GeneratedKeyInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/GeneratedKeyInspectionTests.java new file mode 100644 index 000000000..82833a1f9 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/GeneratedKeyInspectionTests.java @@ -0,0 +1,36 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class GeneratedKeyInspectionTests { + + @Test + public void run_GeneratedKey() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("", "Description"); + + Violation violation = new GeneratedKeyInspection(new DefaultInspector(workspace)).run(view); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.view.key", violation.getType()); + assertEquals("The view with key \"SystemLandscape-001\" has an automatically generated view key and this is not guaranteed to be stable over time.", violation.getMessage()); + } + + @Test + public void run_NonGeneratedKey() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + + Violation violation = new GeneratedKeyInspection(new DefaultInspector(workspace)).run(view); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ManualLayoutInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ManualLayoutInspectionTests.java new file mode 100644 index 000000000..95e6cacb4 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/ManualLayoutInspectionTests.java @@ -0,0 +1,56 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.view.SystemLandscapeView; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ManualLayoutInspectionTests { + + @Test + public void run_GeneratedKeyAndManualLayout() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("", "Description"); + + Violation violation = new ManualLayoutInspection(new DefaultInspector(workspace)).run(view); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("views.view.layout", violation.getType()); + assertEquals("The view with key \"SystemLandscape-001\" has an automatically generated view key and this may cause manual layout information to be lost in the future.", violation.getMessage()); + } + + @Test + public void run_GeneratedKeyAndAutomaticLayout() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("", "Description"); + view.enableAutomaticLayout(); + + Violation violation = new ManualLayoutInspection(new DefaultInspector(workspace)).run(view); + assertNull(violation); + } + + @Test + public void run_NonGeneratedKeyAndManualLayout() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + + Violation violation = new ManualLayoutInspection(new DefaultInspector(workspace)).run(view); + assertNull(violation); + } + + @Test + public void run_NonGeneratedKeyAndAutomaticLayout() { + Workspace workspace = new Workspace("Name", "Description"); + SystemLandscapeView view = workspace.getViews().createSystemLandscapeView("key", "Description"); + view.enableAutomaticLayout(); + + Violation violation = new ManualLayoutInspection(new DefaultInspector(workspace)).run(view); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspectionTests.java new file mode 100644 index 000000000..b65a5f914 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/view/SystemContextViewsForMultipleSoftwareSystemsInspectionTests.java @@ -0,0 +1,42 @@ +package com.structurizr.inspection.view; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import com.structurizr.model.SoftwareSystem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class +SystemContextViewsForMultipleSoftwareSystemsInspectionTests { + + @Test + public void run_MultipleSoftwareSystems() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createSystemContextView(a, "SystemContext-A", "Description"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + workspace.getViews().createSystemContextView(b, "SystemContext-B", "Description"); + + Violation violation = new SystemContextViewsForMultipleSoftwareSystemsInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("System context views exist for 2 software systems. It is recommended that a workspace includes system context views for a single software system only.", violation.getMessage()); + } + + @Test + public void run_SingleSoftwareSystem() { + Workspace workspace = new Workspace("Name", "Description"); + SoftwareSystem a = workspace.getModel().addSoftwareSystem("A"); + workspace.getViews().createSystemContextView(a, "SystemContext-A", "Description"); + SoftwareSystem b = workspace.getModel().addSoftwareSystem("B"); + + Violation violation = new SystemContextViewsForMultipleSoftwareSystemsInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceScopeInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceScopeInspectionTests.java new file mode 100644 index 000000000..294666aef --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceScopeInspectionTests.java @@ -0,0 +1,35 @@ +package com.structurizr.inspection.workspace; + +import com.structurizr.Workspace; +import com.structurizr.configuration.WorkspaceScope; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class WorkspaceScopeInspectionTests { + + @Test + public void run_WithUnscopedWorkspace() { + Workspace workspace = new Workspace("Name", "Description"); + + Violation violation = new WorkspaceScopeInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.scope", violation.getType()); + assertEquals("This workspace has no defined scope. It is recommended that the workspace scope is set to \"Landscape\" or \"SoftwareSystem\".", violation.getMessage()); + } + + @Test + public void run_WithScopedWorkspace() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.getConfiguration().setScope(WorkspaceScope.SoftwareSystem); + + Violation violation = new WorkspaceScopeInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceToolingInspectionTests.java b/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceToolingInspectionTests.java new file mode 100644 index 000000000..425c68932 --- /dev/null +++ b/structurizr-inspection/src/test/java/com/structurizr/inspection/workspace/WorkspaceToolingInspectionTests.java @@ -0,0 +1,46 @@ +package com.structurizr.inspection.workspace; + +import com.structurizr.Workspace; +import com.structurizr.inspection.DefaultInspector; +import com.structurizr.inspection.Severity; +import com.structurizr.inspection.Violation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class WorkspaceToolingInspectionTests { + + @Test + public void run_WithLastModifiedAgentAsCloudServiceDslEditor() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.setLastModifiedAgent("structurizr-cloud/dsl-editor/1234567890"); + + Violation violation = new WorkspaceToolingInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.tooling", violation.getType()); + assertEquals("The browser-based DSL editor is the easiest way to get started without installing any tooling, but it does not provide access to the full feature set of the Structurizr DSL. It is recommended that you use the Structurizr DSL in conjunction with the Structurizr CLI's \"push\" command.", violation.getMessage()); + } + + @Test + public void run_WithLastModifiedAgentAsOnpremisesInstallationDslEditor() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.setLastModifiedAgent("structurizr-onpremises/dsl-editor/1234567890"); + + Violation violation = new WorkspaceToolingInspection(new DefaultInspector(workspace)).run(); + Assertions.assertEquals(Severity.ERROR, violation.getSeverity()); + assertEquals("workspace.tooling", violation.getType()); + assertEquals("The browser-based DSL editor is the easiest way to get started without installing any tooling, but it does not provide access to the full feature set of the Structurizr DSL. It is recommended that you use the Structurizr DSL in conjunction with the Structurizr CLI's \"push\" command.", violation.getMessage()); + } + + @Test + public void run_WithLastModifiedAgentAsStructurizrCli() { + Workspace workspace = new Workspace("Name", "Description"); + workspace.setLastModifiedAgent("structurizr-cli/2.0.0"); + + Violation violation = new WorkspaceToolingInspection(new DefaultInspector(workspace)).run(); + assertNull(violation); + } + +} diff --git a/structurizr-inspection/src/test/resources/workspace.dsl b/structurizr-inspection/src/test/resources/workspace.dsl new file mode 100644 index 000000000..8110ea302 --- /dev/null +++ b/structurizr-inspection/src/test/resources/workspace.dsl @@ -0,0 +1,30 @@ +workspace { + + properties { + structurizr.inspection.* INFO + structurizr.inspection.model.* WARNING + } + + model { + user = person "User" + softwareSystem1 = softwareSystem "Software System 1" { + container1 = container "Container 1" { + component1 = component "Component 1" + } + } + + user -> component1 + + deploymentEnvironment "dev" { + deploymentNode "Server 1" { + infrastructureNode "Load Balancer" + containerInstance container1 + } + } + } + + configuration { + scope softwaresystem + } + +} \ No newline at end of file