├── .deepsource.toml ├── .github ├── dependabot.yml └── workflows │ ├── dependency-review.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DSL.md ├── LICENSE ├── Makefile ├── README.md ├── assets └── model-banner.png ├── cmd ├── mdl │ ├── main.go │ ├── serve.go │ ├── watch.go │ └── webapp │ │ ├── .babelrc.js │ │ ├── README.md │ │ ├── data │ │ ├── layout.json │ │ └── model.json │ │ ├── dist │ │ ├── 1815e00441357e01619e.ttf │ │ ├── 2463b90d9a316e4e5294.woff2 │ │ ├── 2582b0e4bcf85eceead0.ttf │ │ ├── 89999bdf5d835c012025.woff2 │ │ ├── 914997e1bdfc990d0897.ttf │ │ ├── c210719e60948b211a12.woff2 │ │ ├── da94ef451f4969af06e6.ttf │ │ ├── ea8f94e1d22e0d35ccd4.woff2 │ │ ├── index.html │ │ ├── main.js │ │ ├── main.js.map │ │ ├── runtime.js │ │ └── runtime.js.map │ │ ├── package.json │ │ ├── src │ │ ├── Root.tsx │ │ ├── components │ │ │ └── Toolbar.tsx │ │ ├── graph-view │ │ │ ├── defs.ts │ │ │ ├── graph-react.tsx │ │ │ ├── graph.ts │ │ │ ├── intersect.ts │ │ │ ├── layout.ts │ │ │ ├── shapes.ts │ │ │ ├── svg-create.ts │ │ │ ├── svg-text.ts │ │ │ └── undo.ts │ │ ├── hooks.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── parseModel.ts │ │ ├── shortcuts.tsx │ │ ├── static │ │ │ ├── index.html │ │ │ └── index.tsx │ │ ├── style.css │ │ ├── utils.ts │ │ ├── utils │ │ │ └── platform.ts │ │ └── websocket.ts │ │ ├── tsconfig.json │ │ ├── webpack.config.base.js │ │ ├── webpack.config.js │ │ ├── webpack.config.static.js │ │ └── yarn.lock └── stz │ └── main.go ├── codegen └── json.go ├── dsl ├── deployment.go ├── design.go ├── doc.go ├── elements.go ├── person.go ├── relationship.go ├── styles.go └── views.go ├── editor.png ├── examples ├── basic │ ├── README.md │ ├── gen │ │ └── SystemContext.svg │ ├── main.go │ └── model │ │ ├── model.go │ │ └── model.json ├── big_bank_plc │ ├── README.md │ ├── model.json │ ├── model.layout.json │ └── model │ │ └── model.go ├── json │ ├── README.md │ ├── main.go │ └── model │ │ └── model.go ├── nested │ ├── README.md │ ├── model │ │ ├── model.go │ │ ├── subsystem1 │ │ │ └── model.go │ │ └── subsystem2 │ │ │ └── model.go │ └── styles │ │ └── styles.go ├── relationship_style │ ├── gen │ │ └── SystemContext.svg │ └── model │ │ └── model.go ├── shapes │ ├── gen │ │ ├── SystemContext.svg │ │ └── layout.json │ └── model │ │ └── model.go ├── staticcheck.conf └── usage │ ├── README.md │ ├── gen │ └── view.svg │ └── model │ └── model.go ├── expr ├── component.go ├── component_test.go ├── container.go ├── container_test.go ├── deployment.go ├── design.go ├── doc.go ├── element.go ├── filtered_views.go ├── idify_test.go ├── model.go ├── model_test.go ├── person.go ├── person_test.go ├── registry.go ├── relationship.go ├── relationship_test.go ├── render.go ├── render_test.go ├── system.go ├── system_test.go ├── testing.go ├── view_props.go ├── views.go └── views_test.go ├── go.mod ├── go.sum ├── mdl ├── deployment.go ├── doc.go ├── elements.go ├── eval.go ├── model.go ├── relationship.go └── views.go ├── pkg └── version.go ├── plugin └── generate.go └── stz ├── client.go ├── client_test.go ├── configuration.go ├── doc.go ├── eval.go ├── layout.go ├── views.go └── workspace.go /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | [[analyzers]] 3 | name = "test-coverage" 4 | enabled = true 5 | [[analyzers]] 6 | name = "go" 7 | [analyzers.meta] 8 | import_root = "goa.design/model" 9 | [[analyzers]] 10 | name = "secrets" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/cmd/mdl/webapp" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v4 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Static Checks and Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.23' 23 | 24 | - name: Build UI 25 | run: | 26 | cd cmd/mdl/webapp 27 | npm install 28 | npm run build 29 | 30 | - name: golangci-lint 31 | uses: golangci/golangci-lint-action@v8 32 | with: 33 | version: latest 34 | args: --timeout=5m 35 | 36 | test: 37 | name: Test 38 | strategy: 39 | fail-fast: true 40 | matrix: 41 | go: ['1.24'] 42 | os: ['ubuntu-latest'] 43 | runs-on: ${{ matrix.os }} 44 | 45 | steps: 46 | - name: Check out code 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up Go ${{ matrix.go }} 50 | uses: actions/setup-go@v5 51 | with: 52 | go-version: ${{ matrix.go }} 53 | 54 | - name: Install dependencies 55 | run: go mod download 56 | 57 | - name: Build UI 58 | run: | 59 | cd cmd/mdl/webapp 60 | npm install 61 | npm run build 62 | 63 | - name: Build 64 | run: | 65 | cd cmd/mdl && go build 66 | cd ../stz && go build 67 | 68 | - name: Run tests 69 | run: go test ./... -coverprofile=cover.out 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/mdl/mdl 2 | cmd/stz/stz 3 | cmd/mdl/webapp/node_modules 4 | cmd/mdl/webapp/dist-static 5 | cmd/mdl/webapp/package-lock.json 6 | 7 | /gen 8 | /gen-* 9 | .idea 10 | .vscode 11 | 12 | 13 | cover.out 14 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | timeout: 5m 5 | 6 | linters: 7 | enable: 8 | - errcheck 9 | - govet 10 | - ineffassign 11 | - staticcheck 12 | - unused 13 | - misspell 14 | exclusions: 15 | rules: 16 | - linters: 17 | - staticcheck 18 | text: "ST1001" -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at info@goa.design. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Model 2 | 3 | Thank you for your interest in contributing to the Model project! We 4 | appreciate contributions via submitting Github issues and/or pull requests. 5 | 6 | Below are some guidelines to follow when contributing to this project: 7 | 8 | * Before opening an issue in Github, check 9 | [open issues](https://github.com/goadesign/model/issues) and 10 | [pull requests](https://github.com/goadesign/model/pulls) for 11 | existing issues and fixes. 12 | * If your issue has not been addressed, 13 | [open a Github issue](https://github.com/goadesign/model/issues/new) 14 | and follow the checklist presented in the issue description section. A simple 15 | model DSL that reproduces your issue helps immensely. 16 | * If you know how to fix your bug, we highly encourage PR contributions. See 17 | [How Can I Get Started section](#how-can-i-get-started?) on how to submit a PR. 18 | * For feature requests and submitting major changes, 19 | [open an issue](https://github.com/goadesign/model/issues/new) 20 | or hop onto the Goa slack channel (see https://goa.design to join) to discuss 21 | the feature first. 22 | * Keep conversations friendly! Constructive criticism goes a long way. 23 | * Have fun contributing! 24 | 25 | ## How Can I Get Started? 26 | 27 | 1) Read the README carefully as well as details on the [C4 model](https://c4model.com) 28 | for more information on the C4 model. 29 | 2) To get your hands dirty, fork the model repo and issue PRs from the fork. 30 | **PRO Tip:** Add a [git remote](https://git-scm.com/docs/git-remote.html) to 31 | your forked repo in the source code (in $GOPATH/src/goa.design/model when 32 | installed using `go get`) to avoid messing with import paths while testing 33 | your fix. 34 | 3) [Open issues](https://github.com/goadesign/model/issues) labeled as `good first 35 | issue` are ideal to understand the source code and make minor contributions. 36 | Issues labeled `help wanted` are bugs/features that are not currently being 37 | worked on and contributing to them are most welcome. 38 | 4) Link the issue that the PR intends to solve in the PR description. If an issue 39 | does not exist, adding a description in the PR that describes the issue and the 40 | fix is recommended. 41 | 5) Ensure the CI build passes when you issue a PR to Goa. 42 | 7) Join our slack channel(the Goa slack channgel, see https://goa.design to join) and 43 | participate in the conversations. 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Raphael Simon, Victor Dramba and Model Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #! /usr/bin/make 2 | # 3 | # Makefile for Model 4 | # 5 | # Targets: 6 | # - "depend" retrieves the Go packages needed to run the linter and tests 7 | # - "lint" runs the linter and checks the code format using goimports 8 | # - "test" runs the tests 9 | # - "release" creates a new release commit, tags the commit and pushes the tag to GitHub. 10 | # 11 | # Meta targets: 12 | # - "all" is the default target, it runs "lint" and "test" 13 | # - "ci" runs "depend" and "all" 14 | # 15 | MAJOR=1 16 | MINOR=11 17 | BUILD=2 18 | 19 | GO_FILES=$(shell find . -type f -name '*.go') 20 | 21 | # React app source files and dependencies 22 | WEBAPP_DIR=cmd/mdl/webapp 23 | WEBAPP_SRC_FILES=$(shell find $(WEBAPP_DIR)/src -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.css' -o -name '*.html' \) 2>/dev/null || true) 24 | WEBAPP_CONFIG_FILES=$(WEBAPP_DIR)/package.json $(WEBAPP_DIR)/tsconfig.json $(WEBAPP_DIR)/webpack.config.js $(WEBAPP_DIR)/webpack.config.base.js $(WEBAPP_DIR)/.babelrc.js 25 | WEBAPP_BUILD_OUTPUT=$(WEBAPP_DIR)/dist/main.js 26 | 27 | # Only list test and build dependencies 28 | # Standard dependencies are installed via go get 29 | DEPEND=\ 30 | golang.org/x/tools/cmd/goimports@latest \ 31 | github.com/golangci/golangci-lint/cmd/golangci-lint@latest \ 32 | github.com/mjibson/esc@latest 33 | 34 | all: lint test build 35 | 36 | ci: depend all 37 | 38 | depend: 39 | @go mod download 40 | @for package in $(DEPEND); do go install $$package; done 41 | 42 | lint: 43 | ifneq ($(GOOS),windows) 44 | @if [ "`goimports -l $(GO_FILES) | tee /dev/stderr`" ]; then \ 45 | echo "^ - Repo contains improperly formatted go files" && echo && exit 1; \ 46 | fi 47 | @output=$$(golangci-lint run ./... | grep -v "^0 issues\\.$$"); \ 48 | if [ -n "$$output" ]; then \ 49 | echo "$$output" && echo "^ - golangci-lint errors!" && echo && exit 1; \ 50 | fi 51 | endif 52 | 53 | test: 54 | go test ./... --coverprofile=cover.out 55 | 56 | # Ensure package-lock.json exists or is updated if package.json changes 57 | $(WEBAPP_DIR)/package-lock.json: $(WEBAPP_DIR)/package.json 58 | @echo "Generating/updating package-lock.json..." 59 | @cd $(WEBAPP_DIR) && npm install --package-lock-only 60 | 61 | # Install npm dependencies if package.json or package-lock.json changed 62 | $(WEBAPP_DIR)/node_modules/.install-timestamp: $(WEBAPP_DIR)/package.json $(WEBAPP_DIR)/package-lock.json 63 | @echo "Installing npm dependencies..." 64 | @cd $(WEBAPP_DIR) && npm install 65 | @touch $(WEBAPP_DIR)/node_modules/.install-timestamp 66 | 67 | # Build React app only if source files or config changed 68 | # package-lock.json is not a direct dependency here because its generation is handled by the rule above, 69 | # and npm install (triggered by .install-timestamp) will use it. 70 | $(WEBAPP_BUILD_OUTPUT): $(WEBAPP_SRC_FILES) $(WEBAPP_CONFIG_FILES) $(WEBAPP_DIR)/node_modules/.install-timestamp 71 | @echo "Building React app..." 72 | @cd $(WEBAPP_DIR) && npm run build 73 | 74 | # Phony target that depends on the actual build output 75 | build-ui: $(WEBAPP_BUILD_OUTPUT) 76 | 77 | # Force rebuild of UI (useful for development) 78 | build-ui-force: 79 | @echo "Force building React app..." 80 | @cd $(WEBAPP_DIR) && npm install 81 | @cd $(WEBAPP_DIR) && npm run build 82 | 83 | build: build-ui 84 | @cd cmd/mdl && go install 85 | @cd cmd/stz && go install 86 | 87 | serve: build 88 | @cmd/mdl/mdl serve 89 | 90 | # Clean build artifacts 91 | clean-ui: 92 | @echo "Cleaning React build artifacts..." 93 | @rm -rf $(WEBAPP_DIR)/dist/* 94 | @rm -f $(WEBAPP_DIR)/node_modules/.install-timestamp 95 | 96 | clean: clean-ui 97 | 98 | release: build 99 | # First make sure all is clean 100 | @git diff-index --quiet HEAD 101 | @go mod tidy 102 | 103 | # Bump version number 104 | @sed 's/Major = .*/Major = $(MAJOR)/' pkg/version.go > _tmp && mv _tmp pkg/version.go 105 | @sed 's/Minor = .*/Minor = $(MINOR)/' pkg/version.go > _tmp && mv _tmp pkg/version.go 106 | @sed 's/Build = .*/Build = $(BUILD)/' pkg/version.go > _tmp && mv _tmp pkg/version.go 107 | @sed 's/badge\/Version-.*/badge\/Version-v$(MAJOR).$(MINOR).$(BUILD)-blue.svg)/' README.md > _tmp && mv _tmp README.md 108 | @sed 's/model[ @]v.*\/\(.*\)tab=doc/model@v$(MAJOR).$(MINOR).$(BUILD)\/\1tab=doc/' README.md > _tmp && mv _tmp README.md 109 | @sed 's/mdl v[0-9]*\.[0-9]*\.[0-9]*, editor started\./mdl v$(MAJOR).$(MINOR).$(BUILD), editor started./' README.md > _tmp && mv _tmp README.md 110 | @sed 's/model@v.*\/\(.*\)tab=doc/model@v$(MAJOR).$(MINOR).$(BUILD)\/\1tab=doc/' DSL.md > _tmp && mv _tmp DSL.md 111 | 112 | # Commit and push 113 | @git add . 114 | @git commit -m "Release v$(MAJOR).$(MINOR).$(BUILD)" 115 | @git tag v$(MAJOR).$(MINOR).$(BUILD) 116 | @git push origin main 117 | @git push origin v$(MAJOR).$(MINOR).$(BUILD) 118 | 119 | .PHONY: all ci depend lint test build-ui build-ui-force build serve release clean-ui clean 120 | -------------------------------------------------------------------------------- /assets/model-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/assets/model-banner.png -------------------------------------------------------------------------------- /cmd/mdl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | goacodegen "goa.design/goa/v3/codegen" 12 | 13 | "goa.design/model/codegen" 14 | "goa.design/model/mdl" 15 | model "goa.design/model/pkg" 16 | ) 17 | 18 | type config struct { 19 | debug bool 20 | help bool 21 | out string 22 | dir string 23 | port int 24 | devmode bool 25 | devdist string 26 | } 27 | 28 | func main() { 29 | cfg := parseArgs() 30 | 31 | if cfg.help { 32 | printUsage() 33 | os.Exit(0) 34 | } 35 | 36 | cmd, pkg := parseCommand() 37 | 38 | var err error 39 | switch cmd { 40 | case "gen": 41 | err = generateJSON(pkg, cfg) 42 | case "serve": 43 | err = startServer(pkg, cfg) 44 | case "version": 45 | fmt.Printf("%s %s\n", os.Args[0], model.Version()) 46 | case "", "help": 47 | printUsage() 48 | default: 49 | fail(`unknown command %q, use "--help" for usage`, cmd) 50 | } 51 | 52 | if err != nil { 53 | fail(err.Error()) 54 | } 55 | } 56 | 57 | func parseArgs() config { 58 | cfg := config{ 59 | out: "design.json", 60 | dir: goacodegen.Gendir, 61 | port: 8080, 62 | devmode: os.Getenv("DEVMODE") == "1", 63 | devdist: os.Getenv("DEVDIST"), 64 | } 65 | 66 | flag.BoolVar(&cfg.debug, "debug", false, "print debug output") 67 | flag.BoolVar(&cfg.help, "help", false, "print this information") 68 | flag.BoolVar(&cfg.help, "h", false, "print this information") 69 | flag.StringVar(&cfg.out, "out", cfg.out, "set path to generated JSON representation") 70 | flag.StringVar(&cfg.dir, "dir", cfg.dir, "set output directory used by editor to save SVG files") 71 | flag.IntVar(&cfg.port, "port", cfg.port, "set local HTTP port used to serve diagram editor") 72 | 73 | // Parse only the flags, not the command and package 74 | args := os.Args[1:] 75 | flagStart := findFlagStart(args) 76 | if flagStart > 0 { 77 | if err := flag.CommandLine.Parse(args[flagStart:]); err != nil { 78 | fail("failed to parse flags: %s", err.Error()) 79 | } 80 | } 81 | 82 | return cfg 83 | } 84 | 85 | func parseCommand() (string, string) { 86 | args := os.Args[1:] 87 | var cmd, pkg string 88 | 89 | for i, arg := range args { 90 | if strings.HasPrefix(arg, "-") { 91 | break 92 | } 93 | switch i { 94 | case 0: 95 | cmd = arg 96 | case 1: 97 | pkg = arg 98 | default: 99 | printUsage() 100 | os.Exit(1) 101 | } 102 | } 103 | 104 | return cmd, pkg 105 | } 106 | 107 | func findFlagStart(args []string) int { 108 | for i, arg := range args { 109 | if strings.HasPrefix(arg, "-") { 110 | return i 111 | } 112 | } 113 | return len(args) 114 | } 115 | 116 | func generateJSON(pkg string, cfg config) error { 117 | if pkg == "" { 118 | return fmt.Errorf(`missing PACKAGE argument, use "--help" for usage`) 119 | } 120 | 121 | b, err := codegen.JSON(pkg, cfg.debug) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | return os.WriteFile(cfg.out, b, 0600) 127 | } 128 | 129 | func startServer(pkg string, cfg config) error { 130 | if pkg == "" { 131 | return fmt.Errorf(`missing PACKAGE argument, use "--help" for usage`) 132 | } 133 | 134 | absDir, err := filepath.Abs(cfg.dir) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | if err := os.MkdirAll(absDir, 0700); err != nil { 140 | return err 141 | } 142 | 143 | if cfg.devmode && cfg.devdist == "" { 144 | cfg.devdist = "./cmd/mdl/webapp/dist" 145 | } 146 | 147 | return serve(absDir, pkg, cfg.port, cfg.devdist, cfg.debug) 148 | } 149 | 150 | func serve(out, pkg string, port int, devdist string, debug bool) error { 151 | // Load initial design 152 | design, err := loadDesign(pkg, debug) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | server := NewServer(design) 158 | 159 | // Watch for changes and update server 160 | if err := watch(pkg, func() { 161 | if newDesign, err := loadDesign(pkg, debug); err != nil { 162 | fmt.Println("error parsing DSL:\n" + err.Error()) 163 | } else { 164 | server.SetDesign(newDesign) 165 | } 166 | }); err != nil { 167 | return err 168 | } 169 | 170 | return server.Serve(out, devdist, port) 171 | } 172 | 173 | func loadDesign(pkg string, debug bool) (*mdl.Design, error) { 174 | b, err := codegen.JSON(pkg, debug) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | var design mdl.Design 180 | if err := json.Unmarshal(b, &design); err != nil { 181 | return nil, fmt.Errorf("failed to load design: %s", err.Error()) 182 | } 183 | 184 | return &design, nil 185 | } 186 | 187 | func printUsage() { 188 | fmt.Fprintln(os.Stderr, "Usage:") 189 | fmt.Fprintf(os.Stderr, " %s serve PACKAGE [FLAGS]\n", os.Args[0]) 190 | fmt.Fprintf(os.Stderr, " Start a HTTP server that serves a graphical editor for the design described in PACKAGE.\n") 191 | fmt.Fprintf(os.Stderr, " %s gen PACKAGE [FLAGS]\n", os.Args[0]) 192 | fmt.Fprintf(os.Stderr, " Generate a JSON representation of the design described in PACKAGE.\n") 193 | fmt.Fprintf(os.Stderr, "\nPACKAGE must be the import path to a Go package containing Model DSL.\n\n") 194 | fmt.Fprintf(os.Stderr, "FLAGS:\n") 195 | flag.PrintDefaults() 196 | } 197 | 198 | func fail(format string, args ...any) { 199 | fmt.Fprintf(os.Stderr, format+"\n", args...) 200 | os.Exit(1) 201 | } 202 | -------------------------------------------------------------------------------- /cmd/mdl/watch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "github.com/jaschaephraim/lrserver" 12 | "golang.org/x/tools/go/packages" 13 | 14 | "goa.design/model/codegen" 15 | ) 16 | 17 | // watch implements functionality to listen to changes in the model files 18 | // when notifications are received from the filesystem, the model is rebuild 19 | // and the editor page is refreshed via live reload server `lrserver` 20 | func watch(pkg string, reload func()) error { 21 | // Watch model design and regenerate on change 22 | watcher, err := fsnotify.NewWatcher() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedFiles}, pkg+"//...") 28 | if err != nil { 29 | return err 30 | } 31 | if len(pkgs) == 0 { 32 | fmt.Println("Nothing to watch") 33 | return nil 34 | } 35 | fmt.Println("Watching:", filepath.Dir(pkgs[0].GoFiles[0])) 36 | for _, p := range pkgs { // we need to watch the subpackages too 37 | if err = watcher.Add(filepath.Dir(p.GoFiles[0])); err != nil { 38 | return err 39 | } 40 | } 41 | 42 | // Create live reload server and hookup to watcher 43 | lr := lrserver.New(lrserver.DefaultName, lrserver.DefaultPort) 44 | lr.SetStatusLog(nil) 45 | lr.SetErrorLog(nil) 46 | go func() { 47 | if err := lr.ListenAndServe(); err != nil { 48 | fail(err.Error()) 49 | } 50 | }() 51 | go func() { 52 | for { 53 | select { 54 | case ev := <-watcher.Events: 55 | if strings.HasPrefix(filepath.Base(ev.Name), codegen.TmpDirPrefix) { 56 | continue 57 | } 58 | 59 | // debounce, because some editors do several file operations when you save 60 | // we wait for the stream of events to become silent for `interval` 61 | interval := 100 * time.Millisecond 62 | timer := time.NewTimer(interval) 63 | outer: 64 | for { 65 | select { 66 | case ev = <-watcher.Events: 67 | timer.Reset(interval) 68 | case <-timer.C: 69 | break outer 70 | } 71 | } 72 | 73 | fmt.Println(ev.String()) 74 | reload() 75 | lr.Reload(ev.Name) 76 | 77 | case err := <-watcher.Errors: 78 | fmt.Fprintln(os.Stderr, "Error watching files:", err) 79 | } 80 | } 81 | }() 82 | 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | // This caches the Babel config by environment. 3 | api.cache.using(() => process.env.NODE_ENV); 4 | 5 | return { 6 | presets: [ 7 | '@babel/preset-env', 8 | '@babel/preset-typescript', 9 | '@babel/preset-react' 10 | ], 11 | plugins: [ 12 | // Applies the react-refresh Babel plugin on development modes only 13 | api.env('development') && 'react-refresh/babel' 14 | ].filter(Boolean) 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/README.md: -------------------------------------------------------------------------------- 1 | # Model Diagram Layout (MDL) Webapp 2 | 3 | This webapp provides an interactive interface for viewing and editing model diagrams with automatic layout capabilities. 4 | 5 | ## Features 6 | 7 | ### Interactive Diagram Editing 8 | 9 | The webapp provides intuitive mouse interactions for navigating and editing diagrams: 10 | 11 | #### Mouse Interactions 12 | 13 | - **Pan View**: Drag on empty space to pan around the diagram 14 | - **Zoom**: Use scroll wheel to zoom in/out at cursor position 15 | - **Select Elements**: Click on nodes or edges to select them 16 | - **Multi-Selection**: Shift+click to add/remove elements from selection 17 | - **Box Selection**: Shift+drag on empty space to select multiple elements 18 | - **Move Elements**: Drag selected elements to reposition them 19 | - **Undo/Redo**: All changes are tracked for easy reversal 20 | 21 | #### Toolbar Features 22 | 23 | - **Alignment Tools**: Align selected elements horizontally or vertically 24 | - **Distribution Tools**: Evenly distribute selected elements with equal spacing 25 | - **Auto Layout**: Apply automatic layout algorithms to organize the entire diagram 26 | - **Connection Routing**: Change how connections are drawn (orthogonal, straight, curved) 27 | - **Zoom Controls**: Zoom in/out, fit to screen, or reset to 100% 28 | - **Save**: Save the current layout and positions 29 | 30 | ### Auto Layout Engine 31 | 32 | The webapp now uses **ELK.js** (Eclipse Layout Kernel) for automatic diagram layout, providing significant improvements over the previous Dagre.js implementation: 33 | 34 | #### Available Layout Algorithms 35 | 36 | 1. **Layered (Hierarchical)** - Default algorithm, ideal for directed graphs with clear hierarchy 37 | - Optimized for node-link diagrams with inherent direction 38 | - Excellent edge routing with minimal crossings 39 | - Supports grouping and nested structures 40 | 41 | 2. **Stress (Force-directed)** - Physics-based layout for general graphs 42 | - Good for understanding overall graph structure 43 | - Minimizes edge lengths and overlaps 44 | 45 | 3. **Tree** - Specialized for tree structures 46 | - Optimized spacing for hierarchical data 47 | - Clean, readable layouts for tree-like diagrams 48 | 49 | 4. **Force** - Alternative force-directed algorithm 50 | - Different physics simulation approach 51 | - Good for dense graphs 52 | 53 | 5. **Radial** - Circular layout with central focus 54 | - Places important nodes at the center 55 | - Good for showing relationships radiating from key elements 56 | 57 | 6. **Disco** - Disconnected components layout 58 | - Handles graphs with multiple disconnected parts 59 | - Optimizes space usage for complex diagrams 60 | 61 | #### Layout Features 62 | 63 | - **Improved Space Optimization**: Better node spacing and edge routing 64 | - **Orthogonal Edge Routing**: Cleaner, more readable edge paths 65 | - **Group Support**: Proper handling of nested diagram elements 66 | - **Configurable Options**: Adjustable spacing, direction, and algorithm-specific parameters 67 | - **Fallback Handling**: Graceful degradation if layout fails 68 | 69 | #### Usage 70 | 71 | 1. Select your preferred layout algorithm from the dropdown menu 72 | 2. Click "Auto Layout" to apply the selected algorithm 73 | 3. The system will automatically optimize node positions and edge routing 74 | 4. Use "Fit" to zoom and center the resulting layout 75 | 76 | ### Keyboard Shortcuts 77 | 78 | #### Navigation & Zoom 79 | - **Scroll Wheel**: Zoom in/out 80 | - **Ctrl + =**: Zoom in 81 | - **Ctrl + -**: Zoom out 82 | - **Ctrl + 9**: Fit diagram to screen 83 | - **Ctrl + 0**: Reset zoom to 100% 84 | 85 | #### Selection & Editing 86 | - **Ctrl + A**: Select all elements 87 | - **Esc**: Deselect all elements 88 | - **Delete/Backspace**: Remove selected edge vertices 89 | - **Arrow Keys**: Move selected elements (1px) 90 | - **Shift + Arrow Keys**: Move selected elements (10px) 91 | 92 | #### File Operations 93 | - **Ctrl + S**: Save current layout 94 | - **Ctrl + Z**: Undo last change 95 | - **Ctrl + Shift + Z** or **Ctrl + Y**: Redo last undone change 96 | 97 | #### Help 98 | - **Shift + ?** or **Shift + F1**: Show/hide keyboard shortcuts help 99 | 100 | #### Advanced Editing 101 | - **Alt + Click**: Add vertex to relationship edge (Option + Click on Mac) 102 | - **Alt + Shift + Click**: Add label anchor to relationship edge (Option + Shift + Click on Mac) 103 | 104 | ## Technical Implementation 105 | 106 | - **ELK.js v0.10.0**: Modern layout algorithms with TypeScript support 107 | - **Async Layout**: Non-blocking layout calculation with progress indication 108 | - **Error Handling**: Robust fallback mechanisms for layout failures 109 | - **Performance**: Optimized for both small and large diagrams 110 | 111 | ## Development 112 | 113 | ```bash 114 | npm install 115 | npm start # Development server 116 | npm run build # Production build 117 | ``` 118 | 119 | ## Migration from Dagre.js 120 | 121 | The migration from Dagre.js to ELK.js provides: 122 | - **Better algorithms**: More sophisticated layout techniques 123 | - **Improved edge routing**: Cleaner, more readable connections 124 | - **Enhanced grouping**: Better support for nested elements 125 | - **Modern codebase**: Active development and TypeScript support 126 | - **Multiple algorithms**: Choose the best layout for your diagram type 127 | 128 | ## Editor webapp 129 | 130 | The mdl editor is a web application that runs in the browser. 131 | It is embedded in the go executable, so it can be served 132 | as static files. `mdl` runs an HTTP server that serves the 133 | editor as static files. `mdl` also serves the model and layout data 134 | dynamically, for the editor to load. The editor then renders the 135 | selected view as an SVG, allowing the user to edit positions of elements 136 | and shapes of relationships. 137 | 138 | ### Development setup 139 | 140 | To develop the mdl and the editor, start the TypeScript compiler in watch mode and run mdl go program in devmode. 141 | 142 | `mdl` can be instructed to serve the editor files from disk instead 143 | of the embedded copies, to allow for easy development. 144 | ``` 145 | DEVMODE=1 go run ./cmd/mdl ... mdl params 146 | ``` 147 | 148 | Compile and run the TypeScript application in watch mode 149 | ``` 150 | yarn install 151 | yarn watch 152 | ``` 153 | 154 | `yarn watch` will watch for changes in the webapp files and recompile. 155 | Simply refresh the browser to see the changes. 156 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/data/layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "Components": { 3 | "11kj53l": { 4 | "x": 1596.5491079817027, 5 | "y": 950.9669353128886 6 | }, 7 | "15o55d9": { 8 | "x": 265.7023341743573, 9 | "y": 954.2572092999188 10 | }, 11 | "1hfi68p": { 12 | "x": 261.04362489149975, 13 | "y": 1283.9524457340424 14 | }, 15 | "1njagt4": { 16 | "x": 1594.8461550212464, 17 | "y": 1288.1969674005459 18 | }, 19 | "1olxg1c": { 20 | "x": 1590.784198208602, 21 | "y": 612.4401417515865 22 | }, 23 | "1qm4oey": { 24 | "x": 1118.1451334807587, 25 | "y": 192.5904352570644 26 | }, 27 | "ay5wb5": { 28 | "x": 945.3671456159877, 29 | "y": 1270.2460724859745 30 | }, 31 | "do2bvv": { 32 | "x": 945.2224675774833, 33 | "y": 945.2955331015629 34 | }, 35 | "hd9fd5": { 36 | "x": 807.5159305644356, 37 | "y": 191.0893871761279 38 | }, 39 | "jdi4s4": { 40 | "x": 948.2691929908412, 41 | "y": 622.5406891574396 42 | }, 43 | "sks1g4": { 44 | "x": 267.2210049931528, 45 | "y": 624.1553832371161 46 | } 47 | }, 48 | "Containers": { 49 | "1a44kjx": { 50 | "x": 949.1109620730406, 51 | "y": 154.49170594273608 52 | }, 53 | "1hfi68p": { 54 | "x": 229.42723802613912, 55 | "y": 1254.0520673207977 56 | }, 57 | "1njagt4": { 58 | "x": 2074.0651098667267, 59 | "y": 1271.5822134595332 60 | }, 61 | "1qm4oey": { 62 | "x": 233.46725973794676, 63 | "y": 737.6778529958701 64 | }, 65 | "1rhkirw": { 66 | "x": 905.8717684401352, 67 | "y": 733.2362818997086 68 | }, 69 | "ay5wb5": { 70 | "x": 2056.8158776060086, 71 | "y": 740.3815976828853 72 | }, 73 | "hd9fd5": { 74 | "x": 1476.392982873835, 75 | "y": 731.464973950552 76 | }, 77 | "uu8ny2": { 78 | "x": 1480.2172434568556, 79 | "y": 1275.0158471255827 80 | } 81 | }, 82 | "DevelopmentDeployment": { 83 | "144pryr": { 84 | "x": 1184.5998314965739, 85 | "y": 583.9016731967849 86 | }, 87 | "16l1tmw": { 88 | "x": 1186.073293706343, 89 | "y": 268.0378803848122 90 | }, 91 | "1echf4l": { 92 | "x": 196.2275520324854, 93 | "y": 177.64737476313292 94 | }, 95 | "1io29d4": { 96 | "x": 1803.9328924813683, 97 | "y": 293.34428998482065 98 | } 99 | }, 100 | "LiveDeployment": { 101 | "1drz7sz": { 102 | "x": 1726.4641182835253, 103 | "y": 702.413319347758 104 | }, 105 | "1iy36nc": { 106 | "x": 1038.4544306466573, 107 | "y": 291.07280602889637 108 | }, 109 | "1kb17li": { 110 | "x": 355.13150861644084, 111 | "y": 245.52383803088605 112 | }, 113 | "5fensq": { 114 | "x": 1731.3511023750038, 115 | "y": 255.46814681727824 116 | }, 117 | "sf7s62": { 118 | "x": 359.0947314188105, 119 | "y": 782.9693251929668 120 | }, 121 | "xg5rs9": { 122 | "x": 1039.54496256825, 123 | "y": 803.2569324150502 124 | } 125 | }, 126 | "SignIn": { 127 | "15o55d9": { 128 | "x": 1271, 129 | "y": 308 130 | }, 131 | "1hfi68p": { 132 | "x": 1829.428184393335, 133 | "y": 304.1773665935631 134 | }, 135 | "1qm4oey": { 136 | "x": 284.97551626459705, 137 | "y": 312.08868329678154 138 | }, 139 | "sks1g4": { 140 | "x": 788.6207830774708, 141 | "y": 313.1528828581602 142 | } 143 | }, 144 | "SystemLandscape": { 145 | "1a44kjx": { 146 | "x": 313.8988809038172, 147 | "y": 181.74720225954295 148 | }, 149 | "1mmpe00": { 150 | "x": 1673.2322127139207, 151 | "y": 170.74720345163578 152 | }, 153 | "1njagt4": { 154 | "x": 1675.5655427358852, 155 | "y": 1041.413841640529 156 | }, 157 | "23ylt4": { 158 | "x": 2314.898841366071, 159 | "y": 1046.080523738175 160 | }, 161 | "ay5wb5": { 162 | "x": 317.8988852748243, 163 | "y": 1050.0805179763931 164 | }, 165 | "gsdtx7": { 166 | "x": 1062.5655453187528, 167 | "y": 1046.7471806031895 168 | }, 169 | "o14fz3": { 170 | "x": 1051.1485244315527, 171 | "y": 429.6853416129542 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/1815e00441357e01619e.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/1815e00441357e01619e.ttf -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/2463b90d9a316e4e5294.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/2463b90d9a316e4e5294.woff2 -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/2582b0e4bcf85eceead0.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/2582b0e4bcf85eceead0.ttf -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/89999bdf5d835c012025.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/89999bdf5d835c012025.woff2 -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/914997e1bdfc990d0897.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/914997e1bdfc990d0897.ttf -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/c210719e60948b211a12.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/c210719e60948b211a12.woff2 -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/da94ef451f4969af06e6.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/da94ef451f4969af06e6.ttf -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/ea8f94e1d22e0d35ccd4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/cmd/mdl/webapp/dist/ea8f94e1d22e0d35ccd4.woff2 -------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/index.html: -------------------------------------------------------------------------------- 1 | Model - Architecture Diagrams as Code
-------------------------------------------------------------------------------- /cmd/mdl/webapp/dist/runtime.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e,r={},t={};function o(e){var n=t[e];if(void 0!==n)return n.exports;var i=t[e]={id:e,exports:{}};return r[e](i,i.exports,o),i.exports}o.m=r,e=[],o.O=(r,t,n,i)=>{if(!t){var a=1/0;for(p=0;p=i)&&Object.keys(o.O).every((e=>o.O[e](t[f])))?t.splice(f--,1):(l=!1,i0&&e[p-1][2]>i;p--)e[p]=e[p-1];e[p]=[t,n,i]},o.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return o.d(r,{a:r}),r},o.d=(e,r)=>{for(var t in r)o.o(r,t)&&!o.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},o.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),o.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.p="/",(()=>{o.b=document.baseURI||self.location.href;var e={121:0};o.O.j=r=>0===e[r];var r=(r,t)=>{var n,i,[a,l,f]=t,u=0;if(a.some((r=>0!==e[r]))){for(n in l)o.o(l,n)&&(o.m[n]=l[n]);if(f)var p=f(o)}for(r&&r(t);u = ({ model, layout }) => ( 18 | 19 | 20 | } /> 21 | 22 | 23 | ); 24 | 25 | export const refreshGraph = () => { 26 | const currentID = getCurrentViewID(); 27 | clearGraphCache(currentID); 28 | }; 29 | 30 | const ModelPane: FC<{ model: any; layouts: any }> = ({ model, layouts }) => { 31 | const [searchParams, setSearchParams] = useSearchParams(); 32 | const currentID = decodeURI(searchParams.get('id') || ''); 33 | 34 | // UI State 35 | const [helpVisible, setHelpVisible] = useState(false); 36 | const [dragMode, setDragMode] = useState<'pan' | 'select'>('pan'); 37 | 38 | // Get or create graph for current view 39 | const graph = useGraph(model, layouts, currentID); 40 | 41 | // Custom hooks for functionality 42 | const { layouting, handleAutoLayout } = useAutoLayout(graph || ({} as GraphData)); 43 | const { saving, handleSave } = useSave(graph || ({} as GraphData), currentID); 44 | 45 | if (!graph) { 46 | return ; 47 | } 48 | 49 | const handleToggleHelp = useCallback(() => { 50 | setHelpVisible(!helpVisible); 51 | }, [helpVisible]); 52 | 53 | // Update document title when view changes 54 | useEffect(() => { 55 | if (graph && graph.name) { 56 | document.title = `${graph.name} - Model`; 57 | } 58 | }, [graph]); 59 | 60 | // Setup keyboard shortcuts 61 | useKeyboardShortcuts(handleToggleHelp, handleSave, graph, dragMode, setDragMode, handleAutoLayout); 62 | 63 | const handleViewChange = useCallback((id: string) => { 64 | setSearchParams({ id: encodeURIComponent(id) }); 65 | }, [setSearchParams]); 66 | 67 | const handleSelect = useCallback((id: string | null) => { 68 | if (id) { 69 | const element = graph.metadata.elements.find((m: any) => m.id === id); 70 | console.log(removeEmptyProps(element)); 71 | } 72 | }, [graph]); 73 | 74 | return ( 75 | <> 76 | 89 | 95 | {helpVisible && } 96 | 97 | ); 98 | }; 99 | 100 | const ViewRedirect: FC<{ model: any }> = ({ model }) => { 101 | const views = listViews(model); 102 | 103 | React.useEffect(() => { 104 | // Set default title when no view is selected 105 | document.title = 'Model - Architecture Diagrams as Code'; 106 | 107 | if (views.length > 0) { 108 | document.location.href = '?id=' + views[0].key; 109 | } 110 | }, [views]); 111 | 112 | if (views.length > 0) { 113 | return <>Redirecting to {views[0].title}; 114 | } 115 | return <>No views available; 116 | }; 117 | 118 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/defs.ts: -------------------------------------------------------------------------------- 1 | export const defs = ` 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ` -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/graph-react.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, useEffect, useRef, useState} from "react"; 2 | import {buildGraph, getZoomAuto, GraphData, Node, setZoom, addCursorInteraction, restoreViewState, saveViewState} from "./graph"; 3 | 4 | interface Props { 5 | data: GraphData; 6 | onSelect: (nodeName: string | null) => void; 7 | dragMode: 'pan' | 'select'; 8 | } 9 | 10 | export const Graph: FC = ({data, onSelect, dragMode}) => { 11 | const [graphState, setGraphState] = useState(null); 12 | const ref = useRef(null); 13 | 14 | // Single effect for building the graph and handling all setup/cleanup 15 | useEffect(() => { 16 | if (!ref.current) return; 17 | 18 | // Clear previous content 19 | ref.current.innerHTML = ''; 20 | 21 | // Build graph with current props 22 | const g = buildGraph(data, (n: Node | null) => onSelect(n ? n.id : null), dragMode); 23 | ref.current.append(g.svg); 24 | setGraphState(g); 25 | 26 | // Try to restore previous view state, otherwise use auto zoom 27 | if (!restoreViewState(data.id)) { 28 | setZoom(getZoomAuto()); 29 | } 30 | 31 | // Save view state before page unload 32 | const handleBeforeUnload = () => { 33 | if (data?.id) { 34 | saveViewState(data.id); 35 | } 36 | }; 37 | 38 | window.addEventListener('beforeunload', handleBeforeUnload); 39 | 40 | return () => { 41 | // Save view state before cleanup 42 | if (data?.id) { 43 | saveViewState(data.id); 44 | } 45 | 46 | window.removeEventListener('beforeunload', handleBeforeUnload); 47 | 48 | if (ref.current) { 49 | ref.current.innerHTML = ''; 50 | } 51 | }; 52 | }, [data, onSelect, dragMode]); 53 | 54 | // Effect for updating drag mode on existing graph 55 | useEffect(() => { 56 | if (graphState?.svg) { 57 | // Clean up existing cursor interaction 58 | const svg = graphState.svg; 59 | const existingCleanup = (svg as any).__cursorInteractionCleanup; 60 | if (existingCleanup) { 61 | existingCleanup(); 62 | } 63 | 64 | // Set up cursor interaction with current drag mode 65 | addCursorInteraction(svg, dragMode); 66 | } 67 | }, [dragMode, graphState]); 68 | 69 | return
; 70 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/intersect.ts: -------------------------------------------------------------------------------- 1 | interface Point { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | interface BBox extends Point { 7 | width: number; 8 | height: number; 9 | } 10 | 11 | 12 | export function insideBox(p: Point, b: BBox, centeredBox = true): boolean { 13 | return centeredBox ? 14 | (p.x > b.x - b.width / 2 && p.x < b.x + b.width / 2 && p.y > b.y - b.height / 2 && p.y < b.y + b.height / 2) : 15 | (p.x > b.x && p.x < b.x + b.width && p.y > b.y && p.y < b.y + b.height) 16 | } 17 | 18 | export function boxesOverlap(b1: BBox, b2: BBox): boolean { 19 | return b1.x < b2.x + b2.width && b1.y < b2.y + b2.height && b1.x + b1.width > b2.x && b1.y + b1.height > b2.y 20 | } 21 | 22 | export function uncenterBox(b: BBox): BBox { 23 | return {x: b.x - b.width / 2, y: b.y - b.height / 2, width: b.width, height: b.height} 24 | } 25 | 26 | export function scaleBox(b: BBox, sc: number): BBox { 27 | return {x: b.x * sc, y: b.y * sc, width: b.width * sc, height: b.height * sc} 28 | } 29 | 30 | // intersect 2 segments (p1->q1) with (p2, q2) 31 | // if the lines intersect, the result contains the x and y of the intersection (treating the lines as infinite) 32 | // and booleans for whether line segment 1 or line segment 2 contain the point 33 | function segmentIntersection(p1: Point, q1: Point, p2: Point, q2: Point) { 34 | let denominator, a, b, numerator1, numerator2, 35 | result: { x: number, y: number, onLine1: boolean, onLine2: boolean } = { 36 | x: null, 37 | y: null, 38 | onLine1: false, 39 | onLine2: false 40 | }; 41 | denominator = (q2.y - p2.y) * (q1.x - p1.x) - (q2.x - p2.x) * (q1.y - p1.y); 42 | if (denominator == 0) { 43 | return result; 44 | } 45 | a = p1.y - p2.y; 46 | b = p1.x - p2.x; 47 | numerator1 = ((q2.x - p2.x) * a) - ((q2.y - p2.y) * b); 48 | numerator2 = ((q1.x - p1.x) * a) - ((q1.y - p1.y) * b); 49 | a = numerator1 / denominator; 50 | b = numerator2 / denominator; 51 | 52 | // if we cast these lines infinitely in both directions, they intersect here: 53 | result.x = p1.x + (a * (q1.x - p1.x)); 54 | result.y = p1.y + (a * (q1.y - p1.y)); 55 | 56 | // if line1 is a segment and line2 is infinite, they intersect if: 57 | if (a > 0 && a < 1) { 58 | result.onLine1 = true; 59 | } 60 | // if line2 is a segment and line1 is infinite, they intersect if: 61 | if (b >= 0 && b <= 1) { 62 | result.onLine2 = true; 63 | } 64 | // if line1 and line2 are segments, they intersect if both of the above are true 65 | return result; 66 | } 67 | 68 | // intersects a segment (p1->p2) with a box 69 | export function intersectRectFull(p1: Point, p2: Point, box: BBox): Point[] { 70 | const w = box.width / 2 71 | const h = box.height / 2 72 | const segs: { p: Point; q: Point }[] = [ 73 | {p: {x: box.x - w, y: box.y - h}, q: {x: box.x - w, y: box.y + h}}, 74 | {p: {x: box.x - w, y: box.y - h}, q: {x: box.x + w, y: box.y - h}}, 75 | {p: {x: box.x + w, y: box.y - h}, q: {x: box.x + w, y: box.y + h}}, 76 | {p: {x: box.x - w, y: box.y + h}, q: {x: box.x + w, y: box.y + h}}, 77 | ] 78 | return segs.map(s => segmentIntersection(p1, p2, s.p, s.q)).filter(ret => ret.onLine1 && ret.onLine2) 79 | } 80 | 81 | // intersects a line that goes from p to the center of the box 82 | export function intersectRect(box: BBox, p: Point): Point { 83 | if (insideBox(p, box)) return {x: box.x, y: box.y} 84 | return intersectRectFull(box, p, box)[0] || {x: box.x, y: box.y} 85 | } 86 | 87 | export function intersectEllipse(ellCenter: Point, rx: number, ry: number, nodeCenter: Point, point: Point) { 88 | 89 | //translate all to center ellipse 90 | const p1 = {x: point.x - ellCenter.x, y: point.y - ellCenter.y} 91 | const p2 = {x: nodeCenter.x - ellCenter.x, y: nodeCenter.y - ellCenter.y} 92 | 93 | if (p2.x == p1.x) { //hack to avoid singularity 94 | p1.x += .0000001 95 | } 96 | 97 | const s = (p2.y - p1.y) / (p2.x - p1.x); 98 | const si = p2.y - (s * p2.x); 99 | const a = (ry * ry) + (rx * rx * s * s); 100 | const b = 2 * rx * rx * si * s; 101 | const c = rx * rx * si * si - rx * rx * ry * ry; 102 | 103 | const radicand_sqrt = Math.sqrt((b * b) - (4 * a * c)); 104 | const x = p1.x > p2.x ? 105 | (-b + radicand_sqrt) / (2 * a) : 106 | (-b - radicand_sqrt) / (2 * a) 107 | const pos = { 108 | x: x, 109 | y: s * x + si 110 | } 111 | //translate back 112 | pos.x += ellCenter.x; 113 | pos.y += ellCenter.y 114 | 115 | return pos; 116 | } 117 | 118 | export interface Segment { 119 | p: Point; 120 | q: Point; 121 | } 122 | 123 | // given a polyline as a list of segments, interrupt it over the box so no line is inside the box 124 | export function intersectPolylineBox(segments: Segment[], box: BBox) { 125 | for (let i = 0; i < segments.length; i++) { 126 | const s = segments[i] 127 | if (insideBox(s.p, box)) { 128 | if (insideBox(s.q, box)) { // segment both ends inside box 129 | segments.splice(i, 1) 130 | i -= 1 131 | } else { // segment start inside box 132 | s.p = intersectRectFull(s.p, s.q, box)[0] 133 | } 134 | } else { 135 | if (insideBox(s.q, box)) { // segment end inside box 136 | s.q = intersectRectFull(s.p, s.q, box)[0] 137 | } else { // both ends outside 138 | const ret = intersectRectFull(s.p, s.q, box) 139 | if (ret.length == 2) { // intersects the box, splice segment 140 | // order the intersection points, closest first 141 | const dst1 = Math.abs(ret[0].x - s.p.x) + Math.abs(ret[0].y - s.p.y) 142 | const dst2 = Math.abs(ret[1].x - s.p.x) + Math.abs(ret[1].y - s.p.y) 143 | if (dst1 > dst2) ret.reverse() 144 | // split the segment in 2 145 | const s2 = {p: ret[1], q: s.q} 146 | s.q = ret[0] 147 | segments.splice(i + 1, 0, s2) 148 | i += 1 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | export function project(p: Point, a: Point, b: Point): Point { 156 | let atob = {x: b.x - a.x, y: b.y - a.y}; 157 | let atop = {x: p.x - a.x, y: p.y - a.y}; 158 | let len = atob.x * atob.x + atob.y * atob.y; 159 | let dot = atop.x * atob.x + atop.y * atob.y; 160 | let t = Math.min(1, Math.max(0, dot / len)); 161 | return { 162 | x: a.x + atob.x * t, 163 | y: a.y + atob.y * t 164 | }; 165 | } 166 | 167 | export function cabDistance(p1: Point, p2: Point): number { 168 | return Math.abs(p2.x - p1.x) + Math.abs(p2.y - p1.y) 169 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/svg-create.ts: -------------------------------------------------------------------------------- 1 | import {svgTextWrap} from "./svg-text"; 2 | 3 | export const create = { 4 | element(type: string, attrs: Record = {}, className?: string) { 5 | const el = document.createElementNS('http://www.w3.org/2000/svg', type); 6 | Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v))); 7 | if (className) el.classList.add(className); 8 | return el; 9 | }, 10 | 11 | use(id: string, attrs: Record = {}) { 12 | const el = this.element('use', attrs); 13 | el.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + id); 14 | return el; 15 | }, 16 | 17 | path(path: string, attrs: Record = {}, className?: string) { 18 | const p = this.element("path", {...attrs, d: path}, className); 19 | return p; 20 | }, 21 | 22 | text(text: string, attrs: Record = {}) { 23 | const t = this.element('text', attrs) as SVGTextElement; 24 | if (text) t.textContent = text; 25 | return t; 26 | }, 27 | 28 | textArea(text: string, width: number, fontSize: number, bold: boolean, x = 0, y = 0, anchor = '') { 29 | const attrs: Record = { 30 | 'font-size': `${fontSize}px`, 31 | 'font-weight': bold ? 'bold' : 'normal' 32 | }; 33 | const {lines, maxW} = svgTextWrap(text, width, attrs); 34 | const txt = this.text('', {x: 0, y, 'text-anchor': anchor || undefined}); 35 | 36 | lines.forEach((line, i) => { 37 | const span = this.element('tspan', {x, dy: `${fontSize + 2}px`, ...attrs}); 38 | span.textContent = line; 39 | txt.append(span); 40 | }); 41 | 42 | return {txt, dy: (lines.length + 1) * (fontSize + 2), maxW}; 43 | }, 44 | 45 | rect(width: number, height: number, x = 0, y = 0, r = 0, className?: string) { 46 | return this.element('rect', {x, y, rx: r, ry: r, width, height}, className) as SVGRectElement; 47 | }, 48 | 49 | icon(icon: string, x = 0, y = 0) { 50 | return this.use(icon, {x, y}); 51 | }, 52 | 53 | expand(x: number, y: number, expanded: boolean) { 54 | const g = this.element('g', {transform: `translate(${x},${y})`}, 'expand') as SVGGElement; 55 | g.append( 56 | this.rect(19, 19, 0, 0, 1), 57 | this.text(expanded ? '-' : '+', {x: 10, y: 14, 'text-anchor': 'middle'}) 58 | ); 59 | return g; 60 | } 61 | }; 62 | 63 | export function setPosition(g: SVGGElement, x: number, y: number) { 64 | g.setAttribute('transform', `translate(${x},${y})`); 65 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/svg-text.ts: -------------------------------------------------------------------------------- 1 | const textMeasure = () => { 2 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 3 | document.body.appendChild(svg); 4 | 5 | return { 6 | measure: (text: string, attrs: { [key: string]: string }) => { 7 | const node = document.createElementNS('http://www.w3.org/2000/svg', 'text') 8 | node.setAttribute('x', '0'); 9 | node.setAttribute('y', '0'); 10 | for (let attr in attrs) { 11 | node.setAttribute(attr, attrs[attr]); 12 | } 13 | node.appendChild(document.createTextNode(text)); 14 | 15 | svg.appendChild(node); 16 | const {width, height} = node.getBBox(); 17 | svg.removeChild(node); 18 | return {width, height}; 19 | }, 20 | clean: () => { 21 | document.body.removeChild(svg); 22 | } 23 | } 24 | } 25 | 26 | // Helper function to break long words that exceed width 27 | const breakLongWord = (word: string, maxWidth: number, attrs: { [key: string]: string }, mt: any): string[] => { 28 | const parts: string[] = []; 29 | let currentPart = ''; 30 | 31 | for (let i = 0; i < word.length; i++) { 32 | const testPart = currentPart + word[i]; 33 | const size = mt.measure(testPart, attrs); 34 | 35 | if (size.width > maxWidth && currentPart.length > 0) { 36 | parts.push(currentPart); 37 | currentPart = word[i]; 38 | } else { 39 | currentPart = testPart; 40 | } 41 | } 42 | 43 | if (currentPart.length > 0) { 44 | parts.push(currentPart); 45 | } 46 | 47 | return parts; 48 | } 49 | 50 | // split a text in lines wrapped at a certain width 51 | export const svgTextWrap = (text: string, width: number, attrs: { [key: string]: string }) => { 52 | const mt = textMeasure() 53 | let maxW = 0; 54 | 55 | const ret = text.trim().split('\n').map(text => { //split paragraphs 56 | //do one paragraph 57 | const words = text.trim().split(/\s+/); 58 | let lines: string[] = []; 59 | let currentLine: string[] = []; 60 | 61 | words.forEach(word => { 62 | // First check if the single word exceeds the width 63 | const wordSize = mt.measure(word, attrs); 64 | if (wordSize.width > width) { 65 | // If we have content in current line, finish it first 66 | if (currentLine.length > 0) { 67 | lines.push(currentLine.join(' ')); 68 | currentLine = []; 69 | } 70 | // Break the long word into smaller parts 71 | const brokenParts = breakLongWord(word, width, attrs, mt); 72 | // Add all but the last part as complete lines 73 | for (let i = 0; i < brokenParts.length - 1; i++) { 74 | lines.push(brokenParts[i]); 75 | maxW = Math.max(maxW, mt.measure(brokenParts[i], attrs).width); 76 | } 77 | // Start new line with the last part 78 | if (brokenParts.length > 0) { 79 | currentLine = [brokenParts[brokenParts.length - 1]]; 80 | } 81 | } else { 82 | // Normal word processing 83 | const newLine = [...currentLine, word]; 84 | const size = mt.measure(newLine.join(' '), attrs); 85 | if (size.width > width && currentLine.length > 0) { 86 | lines.push(currentLine.join(' ')); 87 | currentLine = [word]; 88 | } else { 89 | maxW = Math.max(maxW, size.width) 90 | currentLine = newLine; 91 | } 92 | } 93 | }); 94 | 95 | if (currentLine.length > 0) { 96 | lines.push(currentLine.join(' ')); 97 | } 98 | return lines; 99 | }).reduce((a, v) => a.concat(v), []) //flatten 100 | 101 | mt.clean() 102 | return {lines: ret, maxW}; 103 | }; 104 | 105 | 106 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/graph-view/undo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Undo functionality 3 | * at every change in the document, Undo can save a new version 4 | * so the user can "undo" and "redo" changes by reverting to an 5 | * older version of the document 6 | */ 7 | 8 | export class Undo { 9 | private readonly versions: Doc[] = []; 10 | private pos: number = 0; 11 | private lastSavedPos: number = 0; 12 | private readonly exportDoc: () => Doc; 13 | private readonly importDoc: (d: Doc) => void; 14 | change: () => void; 15 | private tmpPreviousState: Doc | null = null; 16 | 17 | constructor(id: string, exportDoc: () => Doc, importDoc: (d: Doc) => void) { 18 | this.exportDoc = exportDoc; 19 | this.importDoc = importDoc; 20 | this.change = debounce(() => this.saveNow(), 300); 21 | } 22 | 23 | // Store the state previous to the changes collected in the debounce period 24 | beforeChange() { 25 | if (!this.tmpPreviousState) { 26 | this.tmpPreviousState = this.deepClone(this.exportDoc()); 27 | } 28 | } 29 | 30 | length() { 31 | return this.versions.length; 32 | } 33 | 34 | currentState() { 35 | return this.deepClone(this.versions[this.pos - 1]); 36 | } 37 | 38 | private saveNow() { 39 | if (!this.tmpPreviousState) { 40 | throw Error("undo.change() was called without previously calling undo.beforeChange()!"); 41 | } 42 | 43 | this.versions[this.pos] = this.deepClone(this.exportDoc()); 44 | this.versions[this.pos - 1] = this.tmpPreviousState; 45 | this.tmpPreviousState = null; 46 | this.pos += 1; 47 | 48 | // Remove anything that might be on top of this version 49 | this.versions.splice(this.pos); 50 | } 51 | 52 | private deepClone(doc: Doc): Doc { 53 | // Use modern structuredClone if available, fallback to JSON 54 | if (typeof structuredClone !== 'undefined') { 55 | return structuredClone(doc); 56 | } 57 | return JSON.parse(JSON.stringify(doc)); 58 | } 59 | 60 | undo() { 61 | if (this.pos < 2) return; 62 | this.pos -= 1; 63 | const doc = this.versions[this.pos - 1]; 64 | this.importDoc(this.deepClone(doc)); 65 | } 66 | 67 | redo() { 68 | if (this.pos > this.versions.length - 1) return; 69 | const doc = this.versions[this.pos]; 70 | this.importDoc(this.deepClone(doc)); 71 | this.pos += 1; 72 | } 73 | 74 | changed() { 75 | return this.pos !== this.lastSavedPos; 76 | } 77 | 78 | setSaved() { 79 | this.lastSavedPos = this.pos; 80 | } 81 | } 82 | 83 | function debounce(func: () => void, wait: number) { 84 | let timeout: ReturnType; 85 | return function () { 86 | const context = this; 87 | const later = function () { 88 | timeout = null; 89 | func.apply(context); 90 | }; 91 | clearTimeout(timeout); 92 | timeout = setTimeout(later, wait); 93 | }; 94 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | import { GraphData } from './graph-view/graph'; 3 | import { parseView } from './parseModel'; 4 | import { LayoutOptions } from './graph-view/layout'; 5 | import { 6 | findShortcut, 7 | HELP, 8 | SAVE, 9 | TOGGLE_DRAG_MODE, 10 | ALIGN_HORIZONTAL, 11 | ALIGN_VERTICAL, 12 | DISTRIBUTE_HORIZONTAL, 13 | DISTRIBUTE_VERTICAL, 14 | AUTO_LAYOUT, 15 | RESET_POSITION, 16 | TOGGLE_GRID, 17 | TOGGLE_SNAP_TO_GRID, 18 | SNAP_ALL_TO_GRID, 19 | MOVE_LEFT, 20 | MOVE_RIGHT, 21 | MOVE_UP, 22 | MOVE_DOWN, 23 | MOVE_LEFT_FINE, 24 | MOVE_RIGHT_FINE, 25 | MOVE_UP_FINE, 26 | MOVE_DOWN_FINE 27 | } from './shortcuts'; 28 | 29 | // Global state for graphs to preserve edits 30 | const graphs: { [key: string]: GraphData } = {}; 31 | 32 | // Custom hook for graph management 33 | export const useGraph = (model: any, layouts: any, currentID: string): GraphData | null => { 34 | if (graphs[currentID]) { 35 | return graphs[currentID]; 36 | } 37 | 38 | const graph = parseView(model, layouts, currentID); 39 | if (graph) { 40 | graphs[currentID] = graph; 41 | } 42 | 43 | return graph; 44 | }; 45 | 46 | // Custom hook for auto layout functionality 47 | export const useAutoLayout = (graph: GraphData) => { 48 | const [layouting, setLayouting] = useState(false); 49 | 50 | const handleAutoLayout = useCallback(async () => { 51 | setLayouting(true); 52 | try { 53 | const options: LayoutOptions = { 54 | direction: 'DOWN', 55 | compactLayout: true 56 | }; 57 | await graph.autoLayout(options); 58 | } catch (error) { 59 | console.error('Layout failed:', error); 60 | alert('Layout failed. See console for details.'); 61 | } finally { 62 | setLayouting(false); 63 | } 64 | }, [graph]); 65 | 66 | return { layouting, handleAutoLayout }; 67 | }; 68 | 69 | // Custom hook for save functionality 70 | export const useSave = (graph: GraphData, currentID: string) => { 71 | const [saving, setSaving] = useState(false); 72 | 73 | const handleSave = useCallback(async () => { 74 | setSaving(true); 75 | 76 | try { 77 | const response = await fetch('data/save?id=' + encodeURIComponent(currentID), { 78 | method: 'post', 79 | body: graph.exportSVG() 80 | }); 81 | 82 | if (response.status !== 202) { 83 | alert('Error saving\nSee terminal output.'); 84 | } else { 85 | graph.setSaved(); 86 | } 87 | } catch (error) { 88 | console.error('Save failed:', error); 89 | alert('Save failed. See console for details.'); 90 | } finally { 91 | setSaving(false); 92 | } 93 | }, [graph, currentID]); 94 | 95 | return { saving, handleSave }; 96 | }; 97 | 98 | // Custom hook for keyboard shortcuts 99 | export const useKeyboardShortcuts = ( 100 | toggleHelp: () => void, 101 | saveLayout: () => void, 102 | graph?: GraphData, 103 | dragMode?: 'pan' | 'select', 104 | setDragMode?: (mode: 'pan' | 'select') => void, 105 | onAutoLayout?: () => void 106 | ) => { 107 | useEffect(() => { 108 | const handleKeyDown = (e: KeyboardEvent) => { 109 | const shortcut = findShortcut(e); 110 | 111 | // Prevent browser default for all recognized shortcuts 112 | if (shortcut) { 113 | e.preventDefault(); 114 | } 115 | 116 | if (shortcut === HELP) { 117 | toggleHelp(); 118 | } else if (shortcut === SAVE) { 119 | saveLayout(); 120 | } else if (shortcut === TOGGLE_DRAG_MODE && setDragMode && dragMode) { 121 | setDragMode(dragMode === 'pan' ? 'select' : 'pan'); 122 | } else if (graph) { 123 | // Graph-dependent shortcuts 124 | if (shortcut === ALIGN_HORIZONTAL) { 125 | graph.alignSelectionH(); 126 | } else if (shortcut === ALIGN_VERTICAL) { 127 | graph.alignSelectionV(); 128 | } else if (shortcut === DISTRIBUTE_HORIZONTAL) { 129 | graph.distributeSelectionH(); 130 | } else if (shortcut === DISTRIBUTE_VERTICAL) { 131 | graph.distributeSelectionV(); 132 | } else if (shortcut === AUTO_LAYOUT && onAutoLayout) { 133 | onAutoLayout(); 134 | } else if (shortcut === RESET_POSITION) { 135 | graph.alignTopLeft(); 136 | graph.resetPanTransform(); 137 | } else if (shortcut === TOGGLE_GRID) { 138 | graph.toggleGrid(); 139 | } else if (shortcut === TOGGLE_SNAP_TO_GRID) { 140 | graph.toggleSnapToGrid(); 141 | } else if (shortcut === SNAP_ALL_TO_GRID) { 142 | graph.snapAllToGrid(); 143 | } else if (shortcut === MOVE_LEFT) { 144 | graph.moveSelected(-graph.getGridSize(), 0); 145 | } else if (shortcut === MOVE_LEFT_FINE) { 146 | graph.moveSelected(-1, 0, true); // Disable snap for fine movement 147 | } else if (shortcut === MOVE_RIGHT) { 148 | graph.moveSelected(graph.getGridSize(), 0); 149 | } else if (shortcut === MOVE_RIGHT_FINE) { 150 | graph.moveSelected(1, 0, true); // Disable snap for fine movement 151 | } else if (shortcut === MOVE_UP) { 152 | graph.moveSelected(0, -graph.getGridSize()); 153 | } else if (shortcut === MOVE_UP_FINE) { 154 | graph.moveSelected(0, -1, true); // Disable snap for fine movement 155 | } else if (shortcut === MOVE_DOWN) { 156 | graph.moveSelected(0, graph.getGridSize()); 157 | } else if (shortcut === MOVE_DOWN_FINE) { 158 | graph.moveSelected(0, 1, true); // Disable snap for fine movement 159 | } 160 | } 161 | }; 162 | 163 | window.addEventListener('keydown', handleKeyDown); 164 | return () => window.removeEventListener('keydown', handleKeyDown); 165 | }, [toggleHelp, saveLayout, graph, dragMode, setDragMode, onAutoLayout]); 166 | }; 167 | 168 | // Utility function to clear graph cache 169 | export const clearGraphCache = (currentID?: string) => { 170 | if (currentID) { 171 | delete graphs[currentID]; 172 | } else { 173 | Object.keys(graphs).forEach(key => delete graphs[key]); 174 | } 175 | }; -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Model - Architecture Diagrams as Code 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import React, { Suspense, lazy, useEffect, useState } from 'react'; 3 | import { refreshGraph } from "./Root"; 4 | import './style.css'; 5 | import '@fortawesome/fontawesome-free/css/all.css'; 6 | import { RefreshConnector } from "./websocket"; 7 | 8 | const Root = lazy(() => import('./Root').then(module => ({ default: module.Root }))); 9 | 10 | interface ModelData { 11 | model: any; 12 | layout: any; 13 | } 14 | 15 | interface AppState { 16 | data: ModelData | null; 17 | error: string | null; 18 | loading: boolean; 19 | } 20 | 21 | const App: React.FC = () => { 22 | const [state, setState] = useState({ 23 | data: null, 24 | error: null, 25 | loading: true 26 | }); 27 | 28 | const loadData = async () => { 29 | setState(prev => ({ ...prev, loading: true, error: null })); 30 | 31 | try { 32 | const [modelResponse, layoutResponse] = await Promise.all([ 33 | fetch('data/model.json'), 34 | fetch('data/layout.json') 35 | ]); 36 | 37 | if (!modelResponse.ok) { 38 | throw new Error(`Failed to fetch model: ${modelResponse.statusText}`); 39 | } 40 | 41 | if (!layoutResponse.ok) { 42 | throw new Error(`Failed to fetch layout: ${layoutResponse.statusText}`); 43 | } 44 | 45 | const [model, layout] = await Promise.all([ 46 | modelResponse.json(), 47 | layoutResponse.json() 48 | ]); 49 | 50 | setState({ 51 | data: { model, layout }, 52 | error: null, 53 | loading: false 54 | }); 55 | } catch (error) { 56 | console.error('Failed to load data:', error); 57 | setState({ 58 | data: null, 59 | error: error instanceof Error ? error.message : 'Unknown error occurred', 60 | loading: false 61 | }); 62 | } 63 | }; 64 | 65 | const handleFileChange = (path: string) => { 66 | if (path.endsWith('.svg')) { 67 | return; // Ignore SVG changes to avoid infinite loops 68 | } 69 | 70 | console.log('File changed:', path); 71 | refreshGraph(); 72 | loadData(); 73 | }; 74 | 75 | useEffect(() => { 76 | // Setup refresh connector 77 | const refreshConnector = new RefreshConnector(handleFileChange); 78 | refreshConnector.connect(); 79 | 80 | // Initial data load 81 | loadData(); 82 | 83 | // Cleanup function 84 | return () => { 85 | // RefreshConnector cleanup would go here if it had a disconnect method 86 | }; 87 | }, []); 88 | 89 | if (state.loading) { 90 | return ; 91 | } 92 | 93 | if (state.error) { 94 | return ; 95 | } 96 | 97 | if (!state.data) { 98 | return ; 99 | } 100 | 101 | return ( 102 | }> 103 | 104 | 105 | ); 106 | }; 107 | 108 | const LoadingScreen: React.FC = () => ( 109 |
116 |
Loading...
117 |
118 | ); 119 | 120 | const ErrorScreen: React.FC<{ error: string; onRetry: () => void }> = ({ error, onRetry }) => ( 121 |
132 |

Error loading application

133 |

{error}

134 | 148 |
149 | ); 150 | 151 | // Initialize the application 152 | const container = document.getElementById('root'); 153 | if (!container) { 154 | throw new Error('Root container not found'); 155 | } 156 | 157 | const root = createRoot(container); 158 | root.render(); -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Model - Architecture Diagrams as Code 6 | 7 | 17 | 18 | 19 | 20 | 21 | 108 | 109 | 114 |
115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/static/index.tsx: -------------------------------------------------------------------------------- 1 | import {parseView} from "../parseModel"; 2 | import {buildGraphView} from "../graph-view/graph"; 3 | 4 | document.querySelectorAll('.c4-diagram').forEach(async el => { 5 | const dataEl = document.getElementById(el.getAttribute('data-model')) 6 | const model = JSON.parse(dataEl.textContent) 7 | 8 | const graph = parseView(model as any, {}, el.getAttribute('data-view-key')) 9 | const svg = buildGraphView(graph) 10 | el.append(svg) 11 | 12 | try { 13 | await graph.autoLayout() 14 | } catch (error) { 15 | console.error('Auto layout failed for static diagram:', error) 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/utils.ts: -------------------------------------------------------------------------------- 1 | // Helper functions for the application 2 | 3 | export function removeEmptyProps(obj: any) { 4 | return JSON.parse(JSON.stringify(obj)); 5 | } 6 | 7 | export function camelToWords(camel: string) { 8 | const split = camel.replace(/([A-Z])/g, " $1"); 9 | return split.charAt(0).toUpperCase() + split.slice(1); 10 | } 11 | 12 | export function getCurrentViewID() { 13 | const params = new URLSearchParams(document.location.search); 14 | return params.get('id') || ''; 15 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/utils/platform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Robust platform detection utilities 3 | */ 4 | 5 | /** 6 | * Detects if the user is on a Mac platform using multiple detection methods 7 | * for maximum compatibility across browsers and future-proofing. 8 | */ 9 | export const isMac = (): boolean => { 10 | if (typeof navigator === 'undefined') return false; 11 | 12 | // Method 1: Check userAgentData (modern browsers, most reliable) 13 | if ('userAgentData' in navigator && (navigator as any).userAgentData) { 14 | const platform = (navigator as any).userAgentData.platform; 15 | if (platform && platform.toLowerCase().includes('mac')) { 16 | return true; 17 | } 18 | } 19 | 20 | // Method 2: Check userAgent string (widely supported) 21 | const userAgent = navigator.userAgent.toLowerCase(); 22 | if (userAgent.includes('mac os') || userAgent.includes('macintosh')) { 23 | return true; 24 | } 25 | 26 | // Method 3: Check platform (fallback, deprecated but still widely supported) 27 | if (navigator.platform) { 28 | const platform = navigator.platform.toLowerCase(); 29 | if (platform.includes('mac') || platform.includes('darwin')) { 30 | return true; 31 | } 32 | } 33 | 34 | // Method 4: Check for Mac-specific features as additional validation 35 | try { 36 | const testEvent = new KeyboardEvent('keydown', { metaKey: true }); 37 | if (testEvent.metaKey !== undefined) { 38 | // Additional heuristic: Mac typically has different key layouts 39 | return /mac|darwin|os x/i.test(navigator.userAgent); 40 | } 41 | } catch (e) { 42 | // Ignore errors in older browsers 43 | } 44 | 45 | return false; 46 | }; 47 | 48 | /** 49 | * Gets the appropriate modifier key name for the current platform 50 | */ 51 | export const getModifierKeyName = (): string => { 52 | return isMac() ? 'Cmd' : 'Ctrl'; 53 | }; 54 | 55 | /** 56 | * Gets the appropriate modifier key property for keyboard events 57 | */ 58 | export const getModifierKeyProperty = (event: KeyboardEvent): boolean => { 59 | return isMac() ? event.metaKey : event.ctrlKey; 60 | }; 61 | 62 | /** 63 | * Gets the appropriate Alt key name for the current platform 64 | */ 65 | export const getAltKeyName = (): string => { 66 | return isMac() ? 'Option' : 'Alt'; 67 | }; -------------------------------------------------------------------------------- /cmd/mdl/webapp/src/websocket.ts: -------------------------------------------------------------------------------- 1 | interface LiveReloadOptions { 2 | minDelay: number; 3 | maxDelay: number; 4 | handshakeTimeout: number; 5 | } 6 | 7 | interface LiveReloadMessage { 8 | command: string; 9 | protocols?: string[]; 10 | ver?: string; 11 | path?: string; 12 | } 13 | 14 | class Timer { 15 | private readonly callback: () => void; 16 | private readonly handler: () => void; 17 | private running: boolean = false; 18 | private timeoutId: ReturnType | null = null; 19 | 20 | constructor(callback: () => void) { 21 | this.callback = callback; 22 | this.handler = () => { 23 | this.running = false; 24 | this.timeoutId = null; 25 | this.callback(); 26 | }; 27 | } 28 | 29 | start(timeout: number): void { 30 | if (this.running) { 31 | this.stop(); 32 | } 33 | this.timeoutId = setTimeout(this.handler, timeout); 34 | this.running = true; 35 | } 36 | 37 | stop(): void { 38 | if (this.running && this.timeoutId !== null) { 39 | clearTimeout(this.timeoutId); 40 | this.running = false; 41 | this.timeoutId = null; 42 | } 43 | } 44 | 45 | isRunning(): boolean { 46 | return this.running; 47 | } 48 | } 49 | 50 | /** 51 | * RefreshConnector implements the livereload protocol to listen for file changes via WebSocket 52 | */ 53 | export class RefreshConnector { 54 | private static readonly DEFAULT_OPTIONS: LiveReloadOptions = { 55 | minDelay: 1000, 56 | maxDelay: 60000, 57 | handshakeTimeout: 5000 58 | }; 59 | 60 | private static readonly LIVERELOAD_PROTOCOLS = [ 61 | 'http://livereload.com/protocols/official-9', 62 | 'http://livereload.com/protocols/2.x-remote-control' 63 | ]; 64 | 65 | private readonly uri: string; 66 | private readonly options: LiveReloadOptions; 67 | private readonly fileChangeHandler: (path: string) => void; 68 | 69 | private socket: WebSocket | null = null; 70 | private nextDelay: number; 71 | private connectionDesired: boolean = false; 72 | private disconnectionReason: string = ''; 73 | 74 | private readonly handshakeTimeout: Timer; 75 | private readonly reconnectTimer: Timer; 76 | 77 | constructor(fileChangeHandler: (file: string) => void, options?: Partial) { 78 | this.fileChangeHandler = fileChangeHandler; 79 | this.options = { ...RefreshConnector.DEFAULT_OPTIONS, ...options }; 80 | this.uri = 'ws://localhost:35729/livereload'; 81 | this.nextDelay = this.options.minDelay; 82 | 83 | this.handshakeTimeout = new Timer(() => this.handleHandshakeTimeout()); 84 | this.reconnectTimer = new Timer(() => this.attemptReconnection()); 85 | } 86 | 87 | connect(): void { 88 | this.connectionDesired = true; 89 | 90 | if (this.isSocketConnected()) { 91 | return; 92 | } 93 | 94 | this.prepareForConnection(); 95 | this.createWebSocket(); 96 | } 97 | 98 | disconnect(): void { 99 | this.connectionDesired = false; 100 | this.reconnectTimer.stop(); 101 | 102 | if (this.isSocketConnected()) { 103 | this.disconnectionReason = 'manual'; 104 | this.socket!.close(); 105 | } 106 | } 107 | 108 | private isSocketConnected(): boolean { 109 | return this.socket !== null && this.socket.readyState === WebSocket.OPEN; 110 | } 111 | 112 | private prepareForConnection(): void { 113 | this.reconnectTimer.stop(); 114 | this.disconnectionReason = 'cannot-connect'; 115 | } 116 | 117 | private createWebSocket(): void { 118 | this.socket = new WebSocket(this.uri); 119 | this.socket.onopen = () => this.handleOpen(); 120 | this.socket.onclose = () => this.handleClose(); 121 | this.socket.onmessage = (event) => this.handleMessage(event); 122 | this.socket.onerror = () => this.handleError(); 123 | } 124 | 125 | private handleOpen(): void { 126 | this.disconnectionReason = 'handshake-failed'; 127 | this.startHandshake(); 128 | } 129 | 130 | private handleClose(): void { 131 | console.log(`WebSocket disconnected: ${this.disconnectionReason}. Retry in ${this.nextDelay}ms`); 132 | this.scheduleReconnection(); 133 | } 134 | 135 | private handleMessage(event: MessageEvent): void { 136 | try { 137 | const message: LiveReloadMessage = JSON.parse(event.data); 138 | this.processMessage(message); 139 | } catch (error) { 140 | console.error('Failed to parse WebSocket message:', error); 141 | } 142 | } 143 | 144 | private handleError(): void { 145 | // Error handling is done in onclose 146 | } 147 | 148 | private processMessage(message: LiveReloadMessage): void { 149 | switch (message.command) { 150 | case 'hello': 151 | this.handleHelloMessage(); 152 | break; 153 | case 'reload': 154 | this.handleReloadMessage(message); 155 | break; 156 | default: 157 | console.log('Unknown WebSocket message received:', message); 158 | } 159 | } 160 | 161 | private handleHelloMessage(): void { 162 | this.handshakeTimeout.stop(); 163 | this.nextDelay = this.options.minDelay; 164 | } 165 | 166 | private handleReloadMessage(message: LiveReloadMessage): void { 167 | // The livereload server closes connection after sending reload 168 | // We must reconnect 169 | this.reconnectTimer.stop(); 170 | this.connect(); 171 | 172 | if (message.path) { 173 | this.fileChangeHandler(message.path); 174 | } 175 | } 176 | 177 | private startHandshake(): void { 178 | const helloMessage: LiveReloadMessage = { 179 | command: 'hello', 180 | protocols: RefreshConnector.LIVERELOAD_PROTOCOLS, 181 | ver: '3.3.1' 182 | }; 183 | 184 | this.sendCommand(helloMessage); 185 | this.handshakeTimeout.start(this.options.handshakeTimeout); 186 | } 187 | 188 | private handleHandshakeTimeout(): void { 189 | if (this.isSocketConnected()) { 190 | this.disconnectionReason = 'handshake-timeout'; 191 | this.socket!.close(); 192 | } 193 | } 194 | 195 | private attemptReconnection(): void { 196 | if (this.connectionDesired) { 197 | this.connect(); 198 | } 199 | } 200 | 201 | private scheduleReconnection(): void { 202 | if (!this.connectionDesired) { 203 | return; // Don't reconnect after manual disconnection 204 | } 205 | 206 | if (!this.reconnectTimer.isRunning()) { 207 | this.reconnectTimer.start(this.nextDelay); 208 | this.nextDelay = Math.min(this.options.maxDelay, this.nextDelay * 2); 209 | } 210 | } 211 | 212 | private sendCommand(command: LiveReloadMessage): void { 213 | if (this.isSocketConnected()) { 214 | this.socket!.send(JSON.stringify(command)); 215 | } 216 | } 217 | } -------------------------------------------------------------------------------- /cmd/mdl/webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "resolveJsonModule": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-env node */ 3 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | const isDevelopment = process.env.NODE_ENV !== 'production'; 7 | 8 | module.exports = { 9 | mode: isDevelopment ? 'development' : 'production', 10 | 11 | devtool: isDevelopment ? 'eval-source-map' : 'source-map', 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(j|t)sx?$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'babel-loader' 22 | } 23 | }, 24 | { 25 | test: /\.css$/i, 26 | use: ['style-loader', 'css-loader'] 27 | }, 28 | { 29 | test: /\.html$/, 30 | use: [ 31 | { 32 | loader: 'html-loader' 33 | } 34 | ] 35 | }, 36 | { test: /\.svg$/, loader: 'svg-react-loader' }, 37 | { 38 | test: /\.(png|jp(e*)g)$/, 39 | use: [{ 40 | loader: 'url-loader', 41 | options: { 42 | limit: 8000, // Convert images < 8kb to base64 strings 43 | name: 'images/[hash]-[name].[ext]' 44 | } 45 | }] 46 | } 47 | ] 48 | }, 49 | plugins: [ 50 | isDevelopment && new ReactRefreshWebpackPlugin() 51 | ].filter(Boolean), 52 | optimization: { 53 | runtimeChunk: 'single', 54 | }, 55 | devServer: { 56 | historyApiFallback: true, 57 | hot: true, 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-env node */ 3 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 6 | const path = require('path'); 7 | 8 | const base = require('./webpack.config.base') 9 | 10 | base.entry = './src/index.tsx' 11 | base.plugins.push( 12 | new HtmlWebPackPlugin({ 13 | template: './src/index.html', 14 | filename: './index.html', 15 | }), 16 | new CleanWebpackPlugin({ 17 | protectWebpackAssets: false, 18 | cleanAfterEveryBuildPatterns: ['*.LICENSE.txt'], 19 | }) 20 | ) 21 | base.output = { 22 | path: path.resolve(__dirname, 'dist/'), 23 | publicPath: '/', 24 | } 25 | base.devtool = 'source-map' 26 | 27 | module.exports = base 28 | -------------------------------------------------------------------------------- /cmd/mdl/webapp/webpack.config.static.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-env node */ 3 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 5 | const path = require('path'); 6 | 7 | const base = require('./webpack.config.base') 8 | 9 | base.entry = './src/static/index.tsx' 10 | base.plugins.push( 11 | new HtmlWebPackPlugin({ 12 | template: './src/static/index.html', 13 | filename: './index.html', 14 | }), 15 | new CleanWebpackPlugin({ 16 | protectWebpackAssets: false, 17 | cleanAfterEveryBuildPatterns: ['*.LICENSE.txt'], 18 | }) 19 | ) 20 | base.output = { 21 | path: path.resolve(__dirname, 'dist-static/'), 22 | publicPath: '.', 23 | } 24 | 25 | module.exports = base 26 | -------------------------------------------------------------------------------- /codegen/json.go: -------------------------------------------------------------------------------- 1 | package codegen 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path" 8 | 9 | "goa.design/goa/v3/codegen" 10 | "golang.org/x/tools/go/packages" 11 | ) 12 | 13 | // TmpDirPrefix is the prefix used to create temporary directories. 14 | const TmpDirPrefix = "mdl--" 15 | 16 | // JSON generates a JSON representation of the model described in pkg. 17 | // pkg must be a valid Go package import path. 18 | func JSON(pkg string, debug bool) ([]byte, error) { 19 | // Validate package import path 20 | if _, err := packages.Load(&packages.Config{Mode: packages.NeedName}, pkg); err != nil { 21 | return nil, err 22 | } 23 | 24 | // Write program that generates JSON 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | cwd = "." 28 | } 29 | tmpDir, err := os.MkdirTemp(cwd, TmpDirPrefix) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer func() { 34 | if err := os.RemoveAll(tmpDir); err != nil { 35 | fmt.Fprintf(os.Stderr, "failed to remove temp dir: %v\n", err) 36 | } 37 | }() 38 | var sections []*codegen.SectionTemplate 39 | { 40 | imports := []*codegen.ImportSpec{ 41 | codegen.SimpleImport("fmt"), 42 | codegen.SimpleImport("encoding/json"), 43 | codegen.SimpleImport("os"), 44 | codegen.SimpleImport("goa.design/model/mdl"), 45 | codegen.NewImport("_", pkg), 46 | } 47 | sections = []*codegen.SectionTemplate{ 48 | codegen.Header("Code Generator", "main", imports), 49 | {Name: "main", Source: mainT}, 50 | } 51 | } 52 | cf := &codegen.File{Path: "main.go", SectionTemplates: sections} 53 | if _, err := cf.Render(tmpDir); err != nil { 54 | return nil, err 55 | } 56 | 57 | // Compile program 58 | gobin, err := exec.LookPath("go") 59 | if err != nil { 60 | return nil, fmt.Errorf(`failed to find a go compiler, looked in "%s"`, os.Getenv("PATH")) 61 | } 62 | if _, err := runCmd(gobin, tmpDir, "build", "-o", "mdl"); err != nil { 63 | return nil, err 64 | } 65 | 66 | // Run program 67 | o, err := runCmd(path.Join(tmpDir, "mdl"), tmpDir, "model.json") 68 | if debug { 69 | fmt.Fprintln(os.Stderr, o) 70 | } 71 | if err != nil { 72 | return nil, err 73 | } 74 | return os.ReadFile(path.Join(tmpDir, "model.json")) 75 | } 76 | 77 | func runCmd(path, dir string, args ...string) (string, error) { 78 | args = append([]string{path}, args...) // args[0] becomes exec path 79 | c := exec.Cmd{Path: path, Args: args, Dir: dir} 80 | b, err := c.CombinedOutput() 81 | if err != nil { 82 | if len(b) > 0 { 83 | return "", fmt.Errorf("%s", string(b)) 84 | } 85 | return "", fmt.Errorf("failed to run command %q in directory %q: %s", path, dir, err) 86 | } 87 | return string(b), nil 88 | } 89 | 90 | // mainT is the template for the generator main. 91 | const mainT = `func main() { 92 | // Retrieve output path 93 | out := os.Args[1] 94 | 95 | // Run the model DSL 96 | w, err := mdl.RunDSL() 97 | if err != nil { 98 | fmt.Fprint(os.Stderr, err.Error()) 99 | os.Exit(1) 100 | } 101 | b, err := json.MarshalIndent(w, "", " ") 102 | if err != nil { 103 | fmt.Fprintf(os.Stderr, "failed to encode into JSON: %s", err.Error()) 104 | os.Exit(1) 105 | } 106 | if err := os.WriteFile(out, b, 0644); err != nil { 107 | fmt.Fprintf(os.Stderr, "failed to write file: %s", err.Error()) 108 | os.Exit(1) 109 | } 110 | } 111 | ` 112 | -------------------------------------------------------------------------------- /dsl/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package dsl implements a Go based DSL that makes it possible to describe 3 | softare architecture models following the C4 model (https://c4model.com). 4 | 5 | It is recommended to use "dot import" when using this package to write DSLs, 6 | for example: 7 | 8 | package design 9 | 10 | import . "goa.design/model/dsl" 11 | 12 | var _ = Design("", "[description]", func() { 13 | // ... 14 | }) 15 | 16 | Some DSL functions accept a anonymous function as last argument (such as 17 | Design above) which makes it possible to define a nesting structure. The 18 | general shape of the DSL is: 19 | 20 | Design Design 21 | ├── Version └── Views 22 | ├── Enterprise ├── SystemLandscapeView 23 | ├── Person │ ├── Title 24 | │ ├── Tag │ ├── AddDefault 25 | │ ├── URL │ ├── Add 26 | │ ├── External │ ├── AddAll 27 | │ ├── Prop │ ├── AddNeighbors 28 | │ ├── Uses │ ├── Link 29 | │ └── InteractsWith │ ├── Remove 30 | ├── SoftwareSystem │ ├── RemoveTagged 31 | │ ├── Tag │ ├── RemoveUnreachable 32 | │ ├── URL │ ├── RemoveUnrelated 33 | │ ├── External │ ├── Unlink 34 | │ ├── Prop │ ├── AutoLayout 35 | │ ├── Uses │ ├── AnimationStep 36 | │ ├── Delivers │ ├── PaperSize 37 | │ └── Container │ └── EnterpriseBoundaryVisible 38 | │ ├── Tag ├── SystemContextView 39 | │ ├── URL │ └── ... (same as SystemLandsapeView) 40 | │ ├── Prop ├── ContainerView 41 | │ ├── Uses │ ├── AddContainers 42 | │ ├── Delivers │ ├── AddInfluencers 43 | │ └── Component │ ├── SystemBoundariesVisible 44 | │ ├── Tag │ └── ... (same as SystemLandscapeView*) 45 | │ ├── URL ├── ComponentView 46 | │ ├── Prop │ ├── AddContainers 47 | │ ├── Uses │ ├── AddComponents 48 | │ └── Delivers │ ├── ContainerBoundariesVisible 49 | └── DeploymentEnvironment │ └── ... (same as SystemLandscapeView*) 50 | ├── DeploymentNode ├── FilteredView 51 | │ ├── Tag │ ├── FilterTag 52 | │ ├── Instances │ └── Exclude 53 | │ ├── URL ├── DynamicView 54 | │ ├── Prop │ ├── Title 55 | │ └── DeploymentNode │ ├── AutoLayout 56 | │ └── ... │ ├── PaperSize 57 | ├── InfrastructureNode │ ├── Add 58 | │ ├── Tag ├── DeploymentView 59 | │ ├── URL │ └── ... (same as SystemLandscapeView*) 60 | │ └── Prop └── Style 61 | └── ContainerInstance ├── ElementStyle 62 | ├── Tag └── RelationshipStyle 63 | ├── HealthCheck 64 | └── Prop (* minus EnterpriseBoundaryVisible) 65 | */ 66 | package dsl 67 | -------------------------------------------------------------------------------- /dsl/person.go: -------------------------------------------------------------------------------- 1 | package dsl 2 | 3 | import ( 4 | "strings" 5 | 6 | "goa.design/goa/v3/eval" 7 | "goa.design/model/expr" 8 | ) 9 | 10 | // Person defines a person (user, actor, role or persona). 11 | // 12 | // Person must appear in a Model expression. 13 | // 14 | // Person takes one to three arguments. The first argument is the name of the 15 | // person. An optional description may be passed as second argument. The last 16 | // argument may be a function that defines tags associated with the Person. 17 | // 18 | // The valid syntax for Person is thus: 19 | // 20 | // Person("name") 21 | // 22 | // Person("name", "description") 23 | // 24 | // Person("name", func()) 25 | // 26 | // Person("name", "description", func()) 27 | // 28 | // Example: 29 | // 30 | // var _ = Design(func() { 31 | // Person("Employee") 32 | // Person("Customer", "A customer", func() { 33 | // Tag("system") 34 | // External() 35 | // URL("https://acme.com/docs/customer.html") 36 | // Uses(System) 37 | // InteractsWith(Employee) 38 | // }) 39 | // }) 40 | func Person(name string, args ...any) *expr.Person { 41 | w, ok := eval.Current().(*expr.Design) 42 | if !ok { 43 | eval.IncompatibleDSL() 44 | return nil 45 | } 46 | if strings.Contains(name, "/") { 47 | eval.ReportError("Person: name cannot include slashes") 48 | } 49 | var ( 50 | desc string 51 | dsl func() 52 | ) 53 | if len(args) > 0 { 54 | switch a := args[0].(type) { 55 | case string: 56 | desc = a 57 | case func(): 58 | dsl = a 59 | default: 60 | eval.InvalidArgError("description or DSL function", args[0]) 61 | } 62 | if len(args) > 1 { 63 | if dsl != nil { 64 | eval.ReportError("Person: DSL function must be last argument") 65 | } 66 | dsl, ok = args[1].(func()) 67 | if !ok { 68 | eval.InvalidArgError("DSL function", args[1]) 69 | } 70 | if len(args) > 2 { 71 | eval.ReportError("Person: too many arguments") 72 | } 73 | } 74 | } 75 | p := &expr.Person{ 76 | Element: &expr.Element{ 77 | Name: name, 78 | Description: desc, 79 | DSLFunc: dsl, 80 | }, 81 | } 82 | return w.Model.AddPerson(p) 83 | } 84 | -------------------------------------------------------------------------------- /editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goadesign/model/bd8a3a9db6a60d208e63be12487f2d4cdd1f8890/editor.png -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Model Example 2 | 3 | This example `model` package contains a valid DSL describing the example used 4 | in the README of the repo. 5 | 6 | ## Usage 7 | 8 | ### Using the example command 9 | 10 | The example contains a command that uploads the diagram to the Structurizr 11 | service. The `main` function loads the Structurizr workspace ID, API key and 12 | API secret from the environment: 13 | 14 | * `$STRUCTURIZR_WORKSPACE_ID`: Workspace ID 15 | * `$STRUCTURIZR_KEY`: API key 16 | * `$STRUCTURIZR_SECRET`: API secret 17 | 18 | Follow the steps below to run the command in `bash` (substitute the values 19 | between brackets): 20 | 21 | ```bash 22 | cd $GOPATH/src/goa.design/model/examples/basic 23 | export STRUCTURIZR_WORKSPACE_ID="" 24 | export STRUCTURIZR_KEY="" 25 | export STRUCTURIZR_SECRET="" 26 | go run main.go 27 | ``` 28 | 29 | Open the diagram in a browser: 30 | 31 | ```bash 32 | open https://structurizr.com/workspace/$STRUCTURUZR_WORKSPACE_ID/diagrams#SystemContext 33 | ``` 34 | 35 | ## Using the `mdl` tool 36 | 37 | Alternatively the `mdl` tool can be used to render the diagram locally. Make sure the 38 | tool is installed: 39 | 40 | ```bash 41 | mdl version 42 | ``` 43 | 44 | If the command above returns an error then try reinstalling the tool: 45 | 46 | ```bash 47 | go install goa.design/model/cmd/mdl 48 | ``` 49 | 50 | Serve the static page using the tool: 51 | 52 | ```bash 53 | mdl serve goa.design/model/examples/basic/model 54 | ``` 55 | 56 | Open the diagram in a browser: 57 | 58 | ```bash 59 | open http://localhost:8080 60 | ``` 61 | -------------------------------------------------------------------------------- /examples/basic/gen/SystemContext.svg: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | UsesSoftware System[system]My software system.User[person]A user of my softwaresystem. -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | _ "goa.design/model/examples/basic/model" // DSL 8 | "goa.design/model/stz" 9 | ) 10 | 11 | // Executes the DSL and uploads the corresponding workspace to Structurizr. 12 | func main() { 13 | // Run the model DSL 14 | w, err := stz.RunDSL() 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "invalid design: %s", err.Error()) 17 | os.Exit(1) 18 | } 19 | 20 | // Upload the design to the Structurizr service. 21 | // The API key and secret must be set in the STRUCTURIZR_KEY and 22 | // STRUCTURIZR_SECRET environment variables respectively. The 23 | // workspace ID must be set in STRUCTURIZR_WORKSPACE_ID. 24 | var ( 25 | key = os.Getenv("STRUCTURIZR_KEY") 26 | secret = os.Getenv("STRUCTURIZR_SECRET") 27 | wid = os.Getenv("STRUCTURIZR_WORKSPACE_ID") 28 | ) 29 | if key == "" || secret == "" || wid == "" { 30 | fmt.Fprintln(os.Stderr, "missing STRUCTURIZR_KEY, STRUCTURIZR_SECRET or STRUCTURIZR_WORKSPACE_ID environment variable.") 31 | os.Exit(1) 32 | } 33 | c := stz.NewClient(key, secret) 34 | if err := c.Put(wid, w); err != nil { 35 | fmt.Fprintf(os.Stderr, "failed to store workspace: %s\n", err.Error()) 36 | os.Exit(1) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/basic/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import . "goa.design/model/dsl" 4 | 5 | var _ = Design("Getting Started", "This is a model of my software system.", func() { 6 | var System = SoftwareSystem("Software System", "My software system.", func() { 7 | Tag("system") 8 | }) 9 | 10 | Person("User", "A user of my software system.", func() { 11 | Uses(System, "Uses") 12 | Tag("person") 13 | }) 14 | 15 | Views(func() { 16 | SystemContextView(System, "SystemContext", "An example of a System Context diagram.", func() { 17 | AddAll() 18 | AutoLayout(RankLeftRight) 19 | }) 20 | Styles(func() { 21 | ElementStyle("system", func() { 22 | Background("#1168bd") 23 | Color("#ffffff") 24 | }) 25 | ElementStyle("person", func() { 26 | Background("#08427b") 27 | Color("#ffffff") 28 | Shape(ShapePerson) 29 | }) 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /examples/basic/model/model.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Getting Started", 3 | "description": "This is a model of my software system.", 4 | "model": { 5 | "people": [ 6 | { 7 | "id": "1qbyk9e", 8 | "name": "User", 9 | "description": "A user of my software system.", 10 | "tags": "person,Element,Person", 11 | "relationships": [ 12 | { 13 | "id": "1t0pjo3", 14 | "description": "Uses", 15 | "tags": "Relationship", 16 | "sourceId": "1qbyk9e", 17 | "destinationId": "122st75", 18 | "interactionStyle": "Undefined" 19 | } 20 | ], 21 | "location": "Internal" 22 | } 23 | ], 24 | "softwareSystems": [ 25 | { 26 | "id": "122st75", 27 | "name": "Software System", 28 | "description": "My software system.", 29 | "tags": "system,Element,Software System", 30 | "location": "Internal" 31 | } 32 | ] 33 | }, 34 | "views": { 35 | "systemContextViews": [ 36 | { 37 | "description": "An example of a System Context diagram.", 38 | "key": "SystemContext", 39 | "automaticLayout": { 40 | "rankDirection": "LeftRight", 41 | "rankSeparation": 300, 42 | "nodeSeparation": 600, 43 | "edgeSeparation": 200 44 | }, 45 | "elements": [ 46 | { 47 | "id": "1qbyk9e" 48 | }, 49 | { 50 | "id": "122st75" 51 | } 52 | ], 53 | "relationships": [ 54 | { 55 | "id": "1t0pjo3" 56 | } 57 | ], 58 | "softwareSystemId": "122st75" 59 | } 60 | ], 61 | "configuration": { 62 | "styles": { 63 | "elements": [ 64 | { 65 | "tag": "system", 66 | "background": "#1168bd", 67 | "color": "#ffffff" 68 | }, 69 | { 70 | "tag": "person", 71 | "background": "#08427b", 72 | "color": "#ffffff", 73 | "shape": "Person" 74 | } 75 | ] 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /examples/big_bank_plc/README.md: -------------------------------------------------------------------------------- 1 | # Big Bank PLC Example 2 | 3 | This examples reproduces the Structurizr [Big Bank PLC](https://structurizr.com/share/36141) example with Model. 4 | See 5 | [model.go](https://github.com/goadesign/model/blob/master/examples/big_bank_plc/model/model.go) 6 | for the complete DSL. 7 | 8 | ## Running 9 | 10 | The example can be uploaded to the Structurizr service using the `stz` 11 | command line tool (see the 12 | [README](https://github.com/goadesign/model/tree/master/README.md) for 13 | details on installing and using the tool). 14 | 15 | ```bash 16 | stz gen goa.design/model/examples/big_bank_plc/model 17 | ``` 18 | 19 | This generates the file `model.json` that contains the JSON representation of 20 | the design. This file can be uploaded to a Structurizr workspace: 21 | 22 | ```bash 23 | stz put model.json -id XXX -key YYY -secret ZZZ 24 | ``` 25 | -------------------------------------------------------------------------------- /examples/big_bank_plc/model.layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "Components": { 3 | "elements": [ 4 | { 5 | "id": "11kj53l", 6 | "x": 1925, 7 | "y": 817 8 | }, 9 | { 10 | "id": "15o55d9", 11 | "x": 105, 12 | "y": 817 13 | }, 14 | { 15 | "id": "1hfi68p", 16 | "x": 105, 17 | "y": 1307 18 | }, 19 | { 20 | "id": "1njagt4", 21 | "x": 1925, 22 | "y": 1307 23 | }, 24 | { 25 | "id": "1olxg1c", 26 | "x": 1925, 27 | "y": 436 28 | }, 29 | { 30 | "id": "1qm4oey", 31 | "x": 560, 32 | "y": 10 33 | }, 34 | { 35 | "id": "ay5wb5", 36 | "x": 1015, 37 | "y": 1307 38 | }, 39 | { 40 | "id": "do2bvv", 41 | "x": 1015, 42 | "y": 817 43 | }, 44 | { 45 | "id": "hd9fd5", 46 | "x": 1470, 47 | "y": 11 48 | }, 49 | { 50 | "id": "jdi4s4", 51 | "x": 1015, 52 | "y": 436 53 | }, 54 | { 55 | "id": "sks1g4", 56 | "x": 105, 57 | "y": 436 58 | } 59 | ] 60 | }, 61 | "Containers": { 62 | "elements": [ 63 | { 64 | "id": "1a44kjx", 65 | "x": 1056, 66 | "y": 24 67 | }, 68 | { 69 | "id": "1hfi68p", 70 | "x": 37, 71 | "y": 1214 72 | }, 73 | { 74 | "id": "1njagt4", 75 | "x": 2012, 76 | "y": 1214 77 | }, 78 | { 79 | "id": "1qm4oey", 80 | "x": 780, 81 | "y": 664 82 | }, 83 | { 84 | "id": "1rhkirw", 85 | "x": 37, 86 | "y": 664 87 | }, 88 | { 89 | "id": "ay5wb5", 90 | "x": 2012, 91 | "y": 664 92 | }, 93 | { 94 | "id": "hd9fd5", 95 | "x": 1283, 96 | "y": 664 97 | }, 98 | { 99 | "id": "uu8ny2", 100 | "x": 1031, 101 | "y": 1214 102 | } 103 | ] 104 | }, 105 | "SystemLandscape": { 106 | "elements": [ 107 | { 108 | "id": "1a44kjx", 109 | "x": 87, 110 | "y": 643 111 | }, 112 | { 113 | "id": "1mmpe00", 114 | "x": 1947, 115 | "y": 36 116 | }, 117 | { 118 | "id": "1njagt4", 119 | "x": 1922, 120 | "y": 693 121 | }, 122 | { 123 | "id": "23ylt4", 124 | "x": 1947, 125 | "y": 1241 126 | }, 127 | { 128 | "id": "ay5wb5", 129 | "x": 1012, 130 | "y": 1326 131 | }, 132 | { 133 | "id": "gsdtx7", 134 | "x": 1012, 135 | "y": 813 136 | }, 137 | { 138 | "id": "o14fz3", 139 | "x": 1012, 140 | "y": 301 141 | } 142 | ], 143 | "relationships": [ 144 | { 145 | "id": "avismw", 146 | "description": "Asks questions to", 147 | "vertices": [ 148 | { 149 | "x": 285, 150 | "y": 240 151 | } 152 | ] 153 | } 154 | ] 155 | } 156 | } -------------------------------------------------------------------------------- /examples/json/README.md: -------------------------------------------------------------------------------- 1 | # JSON Model Example 2 | 3 | This example `model` package contains a valid DSL definition of a simple 4 | software system that makes use of the `Endpoint` and `Calls` DSLs. The 5 | main function in the `main` package serializes the underlying model as 6 | JSON and prints it to standard output. 7 | 8 | ## Usage 9 | 10 | To run this example, execute the following from the examples/json 11 | directory: 12 | 13 | ``` 14 | go run main.go 15 | ``` 16 | 17 | This will print the model as JSON to standard output. -------------------------------------------------------------------------------- /examples/json/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "goa.design/model/codegen" 7 | ) 8 | 9 | // Executes the DSL and serializes the resulting model to JSON. 10 | func main() { 11 | // Run the model DSL 12 | js, err := codegen.JSON("goa.design/model/examples/json/model", true) 13 | if err != nil { 14 | panic(err) 15 | } 16 | // Print the JSON 17 | fmt.Println(string(js)) 18 | } 19 | -------------------------------------------------------------------------------- /examples/json/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import . "goa.design/model/dsl" 4 | 5 | var _ = Design("Getting Started", "This is a model of my software system.", func() { 6 | var System = SoftwareSystem("Software System", "My software system.", func() { 7 | Container("Application Database", "Stores application data.", func() { 8 | Tag("database") 9 | }) 10 | Container("Web Application", "Delivers content to users.", func() { 11 | Component("Dashboard Endpoint", "Serve dashboard content.", func() { 12 | Tag("endpoint") 13 | }) 14 | Uses("Application Database", "Reads from and writes to", "MySQL", Synchronous) 15 | }) 16 | Container("Load Balancer", "Distributes requests across the Web Application instances.", func() { 17 | Uses("Web Application/Dashboard Endpoint", "Routes requests to", "HTTPS", Synchronous) 18 | }) 19 | Tag("system") 20 | }) 21 | 22 | Person("User", "A user of my software system.", func() { 23 | Uses(System, "Uses", Synchronous) 24 | Tag("person") 25 | }) 26 | 27 | Views(func() { 28 | SystemContextView(System, "SystemContext", "An example of a System Context diagram.", func() { 29 | AddAll() 30 | AutoLayout(RankLeftRight) 31 | }) 32 | Styles(func() { 33 | ElementStyle("system", func() { 34 | Background("#1168bd") 35 | Color("#ffffff") 36 | }) 37 | ElementStyle("person", func() { 38 | Background("#08427b") 39 | Color("#ffffff") 40 | Shape(ShapePerson) 41 | }) 42 | ElementStyle("database", func() { 43 | Shape(ShapeCylinder) 44 | }) 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /examples/nested/README.md: -------------------------------------------------------------------------------- 1 | # Nested Example 2 | 3 | This examples illustrates how multiple packages can be contribute to the same 4 | design. This makes it possible for multiple teams to collaborate on the same 5 | overall software architecture while maintaining only the section relevant to 6 | them. 7 | 8 | The example also illustrates how styles may be shared between multiple 9 | designs by defining them in a shared Go package. 10 | 11 | The nesting is done simply by leveraging Go `import`: the design being 12 | generated imports the packages that defines the nested designs. The parent 13 | design can refer to elements defined in the child designs by using the 14 | functions exposed on the corresponding element structs: 15 | 16 | * The `Person` and `SoftwareSystem` workspace methods return a person or 17 | software system element given their name. 18 | * The `Container` software system method returns a container given its name. 19 | * The `Component` container method returns a component given its name. 20 | * The `DeploymentNode` workspace method returns a deployment node given its 21 | name, the `Child` deployment node method returns a child deployment node 22 | given its name. 23 | * The `InfrastructureNode` deployment node method returns an infrastructure 24 | node given its name. 25 | * The `ContainerInstance` deployment node method returns a container instance 26 | given a reference to the container being instantiated (or its name) and the 27 | corresponding instance ID (1 if there is only one instance of the container 28 | in the deployment node). 29 | 30 | When used that way the `Design` expression defined in imported models gets 31 | overridden by the one defined in the package being generated. 32 | 33 | ## Running 34 | 35 | ### Rendering the diagram locally 36 | 37 | Use the `mdl` tool to serve the static page: 38 | 39 | ```bash 40 | mdl serve goa.design/model/examples/nested/model 41 | ``` 42 | 43 | Open the pages for each view: 44 | 45 | Subsystem 1 context view: 46 | 47 | ```bash 48 | open http://http://localhost:6070/Subsystem%201%20context 49 | ``` 50 | 51 | Subsystem 2 context view: 52 | 53 | ```bash 54 | open http://http://localhost:6070/Subsystem%202%20context 55 | ``` 56 | 57 | ### Using the Structurizr service 58 | 59 | Generate the Structurizr workspace from the design: 60 | 61 | ```bash 62 | stz gen goa.design/model/examples/nested/model 63 | ``` 64 | 65 | This generates the file `model.json` that contains the JSON representation 66 | of the design. This file can be uploaded to a Structurizr workspace, again 67 | using `stz`: 68 | 69 | ```bash 70 | stz put model.json -id XXX -key YYY -secret ZZZ 71 | ``` 72 | 73 | Where `XXX` is the Structurizr workspace ID, `YYY` the API key and `ZZZ` the 74 | API secret. 75 | -------------------------------------------------------------------------------- /examples/nested/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import ( 4 | _ "goa.design/model/examples/nested/model/subsystem1" 5 | s2 "goa.design/model/examples/nested/model/subsystem2" 6 | 7 | . "goa.design/model/dsl" 8 | ) 9 | 10 | var _ = Design("Global workspace", "The model for all systems", func() { 11 | // Add a new dependency for the person "User" defined in subsystem 1 to the 12 | // software system defined in subsystem 2. 13 | Person("User", "A user of both Subsystems.", func() { 14 | Uses(s2.Subsystem2.SoftwareSystem("Subsystem 2"), "Uses") 15 | Tag("person") 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /examples/nested/model/subsystem1/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import ( 4 | . "goa.design/model/dsl" 5 | "goa.design/model/examples/nested/styles" 6 | ) 7 | 8 | // Subsystem1 defines the design for subsystem 1. 9 | var Subsystem1 = Design("Subsystem 1", "This is a model of subsystem 1.", func() { 10 | var System = SoftwareSystem("Subsystem 1", "A software system that belongs to subsystem 1.", func() { 11 | Tag("system") 12 | }) 13 | 14 | Person("User", "A user of Subsystem 1.", func() { 15 | Uses(System, "Uses") 16 | Tag("person") 17 | }) 18 | 19 | Views(func() { 20 | styles.DefineAll() // Use shared styles 21 | 22 | SystemContextView(System, "Subsystem 1 context", "System context diagram for Subsystem 1.", func() { 23 | AddAll() 24 | AutoLayout(RankTopBottom) 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /examples/nested/model/subsystem2/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import ( 4 | . "goa.design/model/dsl" 5 | "goa.design/model/examples/nested/styles" 6 | ) 7 | 8 | // Subsystem2 defines the design for subsystem 2. 9 | var Subsystem2 = Design("Subsystem 2", "This is a model of subsystem 2.", func() { 10 | var System = SoftwareSystem("Subsystem 2", "A software system that belongs to subsystem 2.", func() { 11 | Container("Microservice A", "A microservice of subsystem 2", "Go and Goa") 12 | Tag("system") 13 | }) 14 | 15 | Views(func() { 16 | styles.DefineAll() // Use shared styles 17 | 18 | SystemContextView(System, "Subsystem 2 context", "System context diagram for Subsystem 2.", func() { 19 | AddAll() 20 | AutoLayout(RankTopBottom) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /examples/nested/styles/styles.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package styles provide shared styles used by multiple models. 3 | */ 4 | package styles 5 | 6 | import . "goa.design/model/dsl" 7 | 8 | // DefineAll defines all the styles described in this package. 9 | func DefineAll() { 10 | SystemStyle() 11 | PersonStyle() 12 | } 13 | 14 | // SystemStyle defines the style used to render software systems. All elements tagged 15 | // with "system" inherit the style. 16 | func SystemStyle() { 17 | Styles(func() { 18 | ElementStyle("person", func() { 19 | Background("#08427b") 20 | Color("#ffffff") 21 | Shape(ShapePerson) 22 | }) 23 | }) 24 | } 25 | 26 | // PersonStyle defines the style used to render people. All elements tagged with 27 | // "person" inherit the style. 28 | func PersonStyle() { 29 | Styles(func() { 30 | ElementStyle("person", func() { 31 | Background("#08427b") 32 | Color("#ffffff") 33 | Shape(ShapePerson) 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /examples/relationship_style/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import ( 4 | . "goa.design/model/dsl" 5 | ) 6 | 7 | var _ = Design("Getting Started", "This is a model of my software system.", func() { 8 | SoftwareSystem("The System", "My Software System") 9 | Person("Person1", "A person using the system.", func() { 10 | Uses("The System", "Thick, red edge\nwith vertices", func() { 11 | Tag("labelPos") 12 | }) 13 | }) 14 | Person("Person2", "Two relationships\nautomatically spread", func() { 15 | Uses("The System", "Read") 16 | Uses("The System", "Write") 17 | Tag("Customer") 18 | }) 19 | Person("Person3", "Another person", func() { 20 | Uses("The System", "Dashed\nOrthogonal", func() { 21 | Tag("knows") 22 | }) 23 | }) 24 | 25 | Views(func() { 26 | SystemContextView("The System", "SystemContext", "System Context diagram.", func() { 27 | AddAll() 28 | Link("Person1", "The System", "Thick, red edge\nwith vertices", func() { 29 | Vertices(300, 300, 300, 800) 30 | }) 31 | AutoLayout(RankLeftRight) 32 | }) 33 | Styles(func() { 34 | ElementStyle("Person", func() { 35 | Shape(ShapePerson) 36 | Background("#fffff0") 37 | }) 38 | ElementStyle("Customer", func() { 39 | Background("#ffffa0") 40 | }) 41 | // Defaults all relationships to solid line 42 | RelationshipStyle("Relationship", func() { 43 | Solid() 44 | }) 45 | RelationshipStyle("labelPos", func() { 46 | Position(40) 47 | Color("#FF0000") 48 | Thickness(7) 49 | }) 50 | RelationshipStyle("knows", func() { 51 | Routing(RoutingOrthogonal) 52 | // overwrite line style 53 | Dashed() 54 | }) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /examples/shapes/gen/layout.json: -------------------------------------------------------------------------------- 1 | {"SystemContext":{"14fm4f4":{"x":2003.3348518984822,"y":241.86133680745627},"14plq43":{"x":2476.3399322293926,"y":244.59821259269154},"14zlbt2":{"x":2736.7024792771113,"y":793.852040072419},"159kxi1":{"x":2737.631324863108,"y":1323.2806415208174},"15tk4vz":{"x":574.8291622131244,"y":237.70803643837752},"163jqky":{"x":1046.9837491508706,"y":241.5582973837889},"16djc9x":{"x":1504.7881174204226,"y":245.19838527663768},"17rhcqs":{"x":2453.135227666788,"y":1821.3414763959354},"181gyfr":{"x":1836.4088503401708,"y":1816.057778022873},"1mf4fhg":{"x":329.1562988505294,"y":765.0630598432717},"1mp416f":{"x":330.1961748540052,"y":1276.851803676052},"1mz3mve":{"x":547.7653661528783,"y":1818.0208915646112},"1n938kd":{"x":1209.053099909411,"y":1817.1735275595934},"1qbyk9e":{"x":1514.1359246632694,"y":943.5224778579358}}} -------------------------------------------------------------------------------- /examples/shapes/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import ( 4 | "fmt" 5 | 6 | . "goa.design/model/dsl" 7 | "goa.design/model/mdl" 8 | ) 9 | 10 | var shapes = []ShapeKind{ 11 | ShapeBox, 12 | ShapeCircle, 13 | ShapeCylinder, 14 | ShapeEllipse, 15 | ShapeHexagon, 16 | ShapeRoundedBox, 17 | ShapeComponent, 18 | ShapeFolder, 19 | ShapeMobileDeviceLandscape, 20 | ShapeMobileDevicePortrait, 21 | //ShapePerson, 22 | ShapePipe, 23 | ShapeRobot, 24 | ShapeWebBrowser, 25 | } 26 | 27 | var _ = Design("Getting Started", "This is a model of my software system.", func() { 28 | for i, sh := range shapes { 29 | func(i int) { 30 | SoftwareSystem(fmt.Sprintf("System %d", i+1), fmt.Sprintf("Shape: %s.", shapeName(sh)), func() { 31 | Tag(fmt.Sprintf("system%d", i+1)) 32 | }) 33 | }(i) 34 | } 35 | 36 | Person("User", "A person using shapes.", func() { 37 | for i := range shapes { 38 | Uses(fmt.Sprintf("System %d", i+1), "Uses") 39 | } 40 | Tag("person") 41 | }) 42 | 43 | Views(func() { 44 | SystemContextView("System 1", "SystemContext", "An example of a System Context diagram.", func() { 45 | AddAll() 46 | AutoLayout(RankLeftRight) 47 | }) 48 | Styles(func() { 49 | ElementStyle("person", func() { 50 | Shape(ShapePerson) 51 | Background("#f0f7ff") 52 | }) 53 | for i, shape := range shapes { 54 | ElementStyle(fmt.Sprintf("system%d", i+1), func() { 55 | Shape(shape) 56 | Background("#f0f7ff") 57 | }) 58 | } 59 | }) 60 | }) 61 | }) 62 | 63 | func shapeName(sh ShapeKind) string { 64 | b, _ := mdl.ShapeKind(sh).MarshalJSON() 65 | return string(b) 66 | } 67 | -------------------------------------------------------------------------------- /examples/staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = ["all", "-ST1000", "-ST1001"] -------------------------------------------------------------------------------- /examples/usage/README.md: -------------------------------------------------------------------------------- 1 | # Model Usage Diagram 2 | 3 | This example is used to produce the diagram that illustrates the multiple 4 | Model usage flows in this repo README. 5 | -------------------------------------------------------------------------------- /examples/usage/model/model.go: -------------------------------------------------------------------------------- 1 | package design 2 | 3 | import . "goa.design/model/dsl" 4 | 5 | var _ = Design("Model Usage", "Not a software architecture but a diagram illustrating how to use Model.", func() { 6 | SoftwareSystem("Model Usage", func() { 7 | Container("Model Usage", func() { 8 | Component("Design", "Go package containing Model DSL that describes the system architecture", func() { 9 | Uses("Visual Editor", "mdl serve") 10 | Uses("Design JSON", "mdl gen") 11 | // Uses("Static HTML", "mdl gen") 12 | Uses("Structurizr workspace JSON", "stz gen") 13 | Tag("Design") 14 | }) 15 | Component("Design JSON", "JSON representation of the design") 16 | Component("Structurizr workspace JSON", "JSON representation of a Structurizr workspace corresponding to the software architecture model described in the design", func() { 17 | Uses("Structurizr Service", "stz put") 18 | Tag("Structurizr") 19 | }) 20 | Component("Visual Editor", "Edit diagram element positions and save to SVG", func() { 21 | Uses("View Renderings", "Save") 22 | Tag("Editor") 23 | }) 24 | // Component("Static HTML", "Static HTML rendering of each view") 25 | Component("View Renderings", "SVG files corresponding to design views.", func() { 26 | Tag("SVG") 27 | }) 28 | Component("Structurizr Service", "Structurizr service hosted at https://structurizr.com", func() { 29 | Tag("Editor", "Structurizr") 30 | }) 31 | }) 32 | }) 33 | 34 | Person("User", "Model user.", func() { 35 | Uses("Model Usage/Model Usage/Design", "Writes") 36 | Uses("Model Usage/Model Usage/Visual Editor", "Uses") 37 | Tag("Person") 38 | }) 39 | 40 | Views(func() { 41 | ComponentView("Model Usage/Model Usage", "view", func() { 42 | AddDefault() 43 | }) 44 | Styles(func() { 45 | ElementStyle("Person", func() { 46 | Shape(ShapePerson) 47 | Stroke("#55AAEE") 48 | }) 49 | ElementStyle("Design", func() { 50 | Shape(ShapeFolder) 51 | }) 52 | ElementStyle("Editor", func() { 53 | Shape(ShapeWebBrowser) 54 | Stroke("#EEAA55") 55 | }) 56 | ElementStyle("SVG", func() { 57 | Shape(ShapeRoundedBox) 58 | Stroke("#EEAA55") 59 | }) 60 | ElementStyle("Structurizr", func() { 61 | Stroke("#55AAEE") 62 | }) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /expr/component.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type ( 8 | // Component represents a component. 9 | Component struct { 10 | *Element 11 | Container *Container 12 | } 13 | 14 | // Components is a slice of components that can be easily converted into 15 | // a slice of ElementHolder. 16 | Components []*Component 17 | ) 18 | 19 | // ComponentTags lists the tags that are added to all components. 20 | var ComponentTags = []string{"Element", "Component"} 21 | 22 | // EvalName returns the generic expression name used in error messages. 23 | func (c *Component) EvalName() string { 24 | if c.Name == "" { 25 | return "unnamed component" 26 | } 27 | return fmt.Sprintf("component %q", c.Name) 28 | } 29 | 30 | // Finalize adds the 'Component' tag ands finalizes relationships. 31 | func (c *Component) Finalize() { 32 | c.PrefixTags(ComponentTags...) 33 | c.Element.Finalize() 34 | } 35 | 36 | // Elements returns a slice of ElementHolder that contains the elements of c. 37 | func (cs Components) Elements() []ElementHolder { 38 | res := make([]ElementHolder, len(cs)) 39 | for i, cc := range cs { 40 | res[i] = cc 41 | } 42 | return res 43 | } 44 | -------------------------------------------------------------------------------- /expr/component_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestComponentEvalName(t *testing.T) { 9 | tests := []struct { 10 | name, want string 11 | }{ 12 | {name: "", want: "unnamed component"}, 13 | {name: "foo", want: `component "foo"`}, 14 | } 15 | for _, tt := range tests { 16 | tt := tt 17 | t.Run(tt.name, func(t *testing.T) { 18 | t.Parallel() 19 | component := Component{ 20 | Element: &Element{ 21 | Name: tt.name, 22 | }, 23 | } 24 | if got := component.EvalName(); got != tt.want { 25 | t.Errorf("got %s, want %s", got, tt.want) 26 | } 27 | }) 28 | } 29 | } 30 | 31 | func TestComponentFinalize(t *testing.T) { 32 | t.Parallel() 33 | component := Component{ 34 | Element: &Element{ 35 | Name: "foo", 36 | }, 37 | } 38 | seq := []struct { 39 | pre func() 40 | want string 41 | }{ 42 | {want: ""}, 43 | {pre: func() { component.Tags = "foo" }, want: "foo"}, 44 | {pre: func() { component.Finalize() }, want: "Element,Component,foo"}, 45 | } 46 | for i, tt := range seq { 47 | tt := tt 48 | t.Run(fmt.Sprint(i), func(t *testing.T) { 49 | if tt.pre != nil { 50 | tt.pre() 51 | } 52 | if got := component.Tags; got != tt.want { 53 | t.Errorf("got %s, want %s", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestComponentsElements(t *testing.T) { 60 | t.Parallel() 61 | components := Components{ 62 | {Element: &Element{Name: "foo"}}, 63 | {Element: &Element{Name: "bar"}}, 64 | {Element: &Element{Name: "baz"}}, 65 | } 66 | if got := components.Elements(); len(got) != len(components) { 67 | t.Errorf("got %d, want %d", len(got), len(components)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /expr/container.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type ( 9 | // Container represents a container. 10 | Container struct { 11 | *Element 12 | Components Components 13 | System *SoftwareSystem 14 | } 15 | 16 | // Containers is a slice of containers that can be easily 17 | // converted into a slice of ElementHolder. 18 | Containers []*Container 19 | ) 20 | 21 | // ContainerTags lists the tags that are added to all containers. 22 | var ContainerTags = []string{"Element", "Container"} 23 | 24 | // EvalName returns the generic expression name used in error messages. 25 | func (c *Container) EvalName() string { 26 | if c.Name == "" { 27 | return "unnamed container" 28 | } 29 | return fmt.Sprintf("container %q", c.Name) 30 | } 31 | 32 | // Finalize adds the 'Container' tag ands finalizes relationships. 33 | func (c *Container) Finalize() { 34 | c.PrefixTags(ContainerTags...) 35 | c.Element.Finalize() 36 | } 37 | 38 | // Elements returns a slice of ElementHolder that contains the elements of c. 39 | func (c Containers) Elements() []ElementHolder { 40 | res := make([]ElementHolder, len(c)) 41 | for i, cc := range c { 42 | res[i] = cc 43 | } 44 | return res 45 | } 46 | 47 | // Component returns the component with the given name if any, nil otherwise. 48 | func (c *Container) Component(name string) *Component { 49 | for _, cc := range c.Components { 50 | if cc.Name == name { 51 | return cc 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | // AddComponent adds the given component to the container. If there is already a 58 | // component with the given name then AddComponent merges both definitions. The 59 | // merge algorithm: 60 | // 61 | // - overrides the description, technology and URL if provided, 62 | // - merges any new tag or propery into the existing tags and properties, 63 | // 64 | // AddComponent returns the new or merged component. 65 | func (c *Container) AddComponent(cmp *Component) *Component { 66 | existing := c.Component(cmp.Name) 67 | if existing == nil { 68 | Identify(cmp) 69 | c.Components = append(c.Components, cmp) 70 | return cmp 71 | } 72 | if cmp.Description != "" { 73 | existing.Description = cmp.Description 74 | } 75 | if cmp.Technology != "" { 76 | existing.Technology = cmp.Technology 77 | } 78 | if cmp.URL != "" { 79 | existing.URL = cmp.URL 80 | } 81 | existing.MergeTags(strings.Split(cmp.Tags, ",")...) 82 | if olddsl := existing.DSLFunc; olddsl != nil { 83 | existing.DSLFunc = func() { olddsl(); cmp.DSLFunc() } 84 | } 85 | return existing 86 | } 87 | -------------------------------------------------------------------------------- /expr/container_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestContainerEvalName(t *testing.T) { 9 | tests := []struct { 10 | name, want string 11 | }{ 12 | {name: "", want: "unnamed container"}, 13 | {name: "foo", want: `container "foo"`}, 14 | } 15 | for _, tt := range tests { 16 | tt := tt 17 | t.Run(tt.name, func(t *testing.T) { 18 | t.Parallel() 19 | container := Container{ 20 | Element: &Element{ 21 | Name: tt.name, 22 | }, 23 | } 24 | if got := container.EvalName(); got != tt.want { 25 | t.Errorf("got %s, want %s", got, tt.want) 26 | } 27 | }) 28 | } 29 | } 30 | 31 | func TestContainerFinalize(t *testing.T) { 32 | t.Parallel() 33 | container := Container{ 34 | Element: &Element{ 35 | Name: "foo", 36 | }, 37 | } 38 | seq := []struct { 39 | pre func() 40 | want string 41 | }{ 42 | {want: ""}, 43 | {pre: func() { container.Tags = "foo" }, want: "foo"}, 44 | {pre: func() { container.Finalize() }, want: "Element,Container,foo"}, 45 | } 46 | for i, tt := range seq { 47 | tt := tt 48 | t.Run(fmt.Sprint(i), func(t *testing.T) { 49 | if tt.pre != nil { 50 | tt.pre() 51 | } 52 | if got := container.Tags; got != tt.want { 53 | t.Errorf("got %s, want %s", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestContainersElements(t *testing.T) { 60 | t.Parallel() 61 | containers := Containers{ 62 | {Element: &Element{Name: "foo"}}, 63 | {Element: &Element{Name: "bar"}}, 64 | {Element: &Element{Name: "baz"}}, 65 | } 66 | if got := containers.Elements(); len(got) != len(containers) { 67 | t.Errorf("got %d, want %d", len(got), len(containers)) 68 | } 69 | } 70 | 71 | func TestContainerComponent(t *testing.T) { 72 | container := Container{ 73 | Components: Components{ 74 | {Element: &Element{Name: "foo"}}, 75 | {Element: &Element{Name: "bar"}}, 76 | {Element: &Element{Name: "baz"}}, 77 | }, 78 | } 79 | tests := []struct { 80 | name string 81 | want *Component 82 | }{ 83 | {name: "thud", want: nil}, 84 | {name: "bar", want: container.Components[1]}, 85 | } 86 | for i, tt := range tests { 87 | tt := tt 88 | t.Run(fmt.Sprint(i), func(t *testing.T) { 89 | t.Parallel() 90 | if got := container.Component(tt.name); got != tt.want { 91 | t.Errorf("got %#v, want %#v", got.Element, tt.want.Element) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func TestAddComponent(t *testing.T) { 98 | t.Parallel() 99 | mElementFoo := Element{ID: "1", Name: "foo", Description: ""} 100 | mElementBar := Element{ID: "2", Name: "bar", Description: ""} 101 | componentFoo := Component{ 102 | Element: &Element{ID: "1", Name: "foo", Description: "", DSLFunc: func() { 103 | fmt.Println("1") 104 | }}, 105 | } 106 | components := make([]*Component, 1) 107 | components[0] = &componentFoo 108 | OuterContainer := Container{ 109 | Element: &mElementFoo, 110 | Components: components, 111 | System: nil, 112 | } 113 | InnerContainer := Container{ 114 | Element: &mElementBar, 115 | Components: components, 116 | System: nil, 117 | } 118 | componentBar := Component{ 119 | Element: &Element{ID: "2", Name: "bar", Description: ""}, 120 | Container: &InnerContainer, 121 | } 122 | componentFooPlus := Component{ 123 | Element: &Element{ID: "3", Name: "foo", Description: "Description", Technology: "Golang", URL: "https://github.com/goadesign/model/", DSLFunc: func() { 124 | fmt.Printf("hello") 125 | }}, 126 | } 127 | 128 | seq := []struct { 129 | name string 130 | component2Add *Component 131 | want *Component 132 | }{ 133 | {name: "foo", component2Add: &componentFoo, want: &componentFoo}, // already in container 134 | {name: "bar", component2Add: &componentBar, want: &componentBar}, // now appended 135 | {name: "foo", component2Add: &componentFooPlus, want: &componentFoo}, 136 | } 137 | for i, tt := range seq { 138 | tt := tt 139 | t.Run(fmt.Sprint(i), func(t *testing.T) { 140 | got := OuterContainer.AddComponent(tt.component2Add) 141 | if got != tt.want { 142 | t.Errorf("got %#v, want %#v", got.Element, tt.want.Element) 143 | } 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /expr/design.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | 6 | "goa.design/goa/v3/eval" 7 | "goa.design/goa/v3/expr" 8 | model "goa.design/model/pkg" 9 | ) 10 | 11 | type ( 12 | // Design contains the AST generated from the DSL. 13 | Design struct { 14 | Name string 15 | Description string 16 | Version string 17 | Model *Model 18 | Views *Views 19 | } 20 | ) 21 | 22 | // Root is the design root expression. 23 | var Root = &Design{Model: &Model{}, Views: &Views{}} 24 | 25 | // Register design root with eval engine. 26 | func init() { 27 | eval.Register(Root) // nolint: errcheck 28 | } 29 | 30 | // WalkSets iterates over the elements and views. 31 | // Elements DSL cannot be executed on init because all elements must first be 32 | // loaded and their IDs captured in the registry before relationships can be 33 | // built with DSL. 34 | func (d *Design) WalkSets(walk eval.SetWalker) { 35 | // 1. Model 36 | walk([]eval.Expression{d.Model}) 37 | // 2. People 38 | walk(eval.ToExpressionSet(d.Model.People)) 39 | // 3. Systems 40 | walk(eval.ToExpressionSet(d.Model.Systems)) 41 | // 4. Containers 42 | for _, s := range d.Model.Systems { 43 | walk(eval.ToExpressionSet(s.Containers)) 44 | } 45 | // 5. Components 46 | for _, s := range d.Model.Systems { 47 | for _, c := range s.Containers { 48 | walk(eval.ToExpressionSet(c.Components)) 49 | } 50 | } 51 | // 6. Deployment environments 52 | walkDeploymentNodes(d.Model.DeploymentNodes, walk) 53 | // 7. Views 54 | walk([]eval.Expression{d.Views}) 55 | } 56 | 57 | // Packages returns the import path to the Go packages that make 58 | // up the DSL. This is used to skip frames that point to files 59 | // in these packages when computing the location of errors. 60 | func (*Design) Packages() []string { 61 | return []string{ 62 | "goa.design/model/expr", 63 | "goa.design/model/dsl", 64 | fmt.Sprintf("goa.design/model@%s/expr", model.Version()), 65 | fmt.Sprintf("goa.design/model@%s/dsl", model.Version()), 66 | } 67 | } 68 | 69 | // DependsOn tells the eval engine to run the goa DSL first. 70 | func (*Design) DependsOn() []eval.Root { return []eval.Root{expr.Root} } 71 | 72 | // EvalName returns the generic expression name used in error messages. 73 | func (*Design) EvalName() string { return "root" } 74 | 75 | func walkDeploymentNodes(n []*DeploymentNode, walk eval.SetWalker) { 76 | if n == nil { 77 | return 78 | } 79 | walk(eval.ToExpressionSet(n)) 80 | for _, d := range n { 81 | walk(eval.ToExpressionSet(d.InfrastructureNodes)) 82 | walk(eval.ToExpressionSet(d.ContainerInstances)) 83 | walkDeploymentNodes(d.Children, walk) 84 | } 85 | } 86 | 87 | // Person returns the person with the given name if any, nil otherwise. 88 | func (d *Design) Person(name string) *Person { 89 | return d.Model.Person(name) 90 | } 91 | 92 | // SoftwareSystem returns the software system with the given name if any, nil 93 | // otherwise. 94 | func (d *Design) SoftwareSystem(name string) *SoftwareSystem { 95 | return d.Model.SoftwareSystem(name) 96 | } 97 | 98 | // DeploymentNode returns the deployment node with the given name in the given 99 | // environment if any, nil otherwise. 100 | func (d *Design) DeploymentNode(env, name string) *DeploymentNode { 101 | return d.Model.DeploymentNode(env, name) 102 | } 103 | -------------------------------------------------------------------------------- /expr/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package expr contains the data structure and associated logic that are built 3 | by the DSL. These data structures are leveraged by the code generation 4 | package to produce the software architecture model and views. 5 | 6 | The expression data structures implement interfaces defined by the Goa eval 7 | package. The corresponding methods (EvalName, Prepare, Validate, Finalize 8 | etc.) are invoked by the eval package when evaluating the DSL. 9 | */ 10 | package expr 11 | -------------------------------------------------------------------------------- /expr/element.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type ( 8 | // Element describes an element. 9 | Element struct { 10 | ID string 11 | Name string 12 | Description string 13 | Technology string 14 | Tags string 15 | URL string 16 | Properties map[string]string 17 | Relationships []*Relationship 18 | DSLFunc func() 19 | } 20 | 21 | // ElementHolder provides access to the underlying element. 22 | ElementHolder interface { 23 | GetElement() *Element 24 | } 25 | 26 | // LocationKind is the enum for possible locations. 27 | LocationKind int 28 | ) 29 | 30 | const ( 31 | // LocationUndefined means no location specified in design. 32 | LocationUndefined LocationKind = iota 33 | // LocationInternal defines an element internal to the enterprise. 34 | LocationInternal 35 | // LocationExternal defines an element external to the enterprise. 36 | LocationExternal 37 | ) 38 | 39 | // DSL returns the attached DSL. 40 | func (e *Element) DSL() func() { return e.DSLFunc } 41 | 42 | // Finalize finalizes the relationships. 43 | func (e *Element) Finalize() { 44 | for _, rel := range e.Relationships { 45 | rel.Finalize() 46 | } 47 | } 48 | 49 | // GetElement returns the underlying element. 50 | func (e *Element) GetElement() *Element { return e } 51 | 52 | // MergeTags adds the given tags. It skips tags already present in e.Tags. 53 | func (e *Element) MergeTags(tags ...string) { 54 | e.Tags = mergeTags(e.Tags, tags) 55 | } 56 | 57 | // PrefixTags adds the given tags to the beginning of the comma separated list. 58 | func (e *Element) PrefixTags(tags ...string) { 59 | prefix := strings.Join(tags, ",") 60 | if e.Tags == "" { 61 | e.Tags = prefix 62 | return 63 | } 64 | e.Tags = mergeTags(prefix, strings.Split(e.Tags, ",")) 65 | } 66 | 67 | // mergeTags merges the comma separated tags in old with the ones in tags and 68 | // returns a comma separated string with the results. 69 | func mergeTags(existing string, tags []string) string { 70 | if existing == "" { 71 | return strings.Join(tags, ",") 72 | } 73 | old := strings.Split(existing, ",") 74 | var merged []string 75 | for _, o := range old { 76 | found := false 77 | for _, tag := range tags { 78 | if tag == o { 79 | found = true 80 | break 81 | } 82 | } 83 | if !found { 84 | merged = append(merged, o) 85 | } 86 | } 87 | for _, tag := range tags { 88 | if tag == "" { 89 | continue 90 | } 91 | found := false 92 | for _, o := range merged { 93 | if tag == o { 94 | found = true 95 | break 96 | } 97 | } 98 | if !found { 99 | merged = append(merged, tag) 100 | } 101 | } 102 | return strings.Join(merged, ",") 103 | } 104 | -------------------------------------------------------------------------------- /expr/filtered_views.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type ( 8 | // FilteredView describes a filtered view on top of a specified view. 9 | FilteredView struct { 10 | Title string 11 | Description string 12 | Key string `json:"key"` 13 | BaseKey string 14 | Exclude bool 15 | FilterTags []string 16 | } 17 | ) 18 | 19 | // EvalName returns the generic expression name used in error messages. 20 | func (fv *FilteredView) EvalName() string { 21 | var suffix string 22 | if fv.Key != "" { 23 | suffix = fmt.Sprintf(" key %q and", fv.Key) 24 | } 25 | return fmt.Sprintf("filtered view with%s base key %q", suffix, fv.BaseKey) 26 | } 27 | -------------------------------------------------------------------------------- /expr/idify_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "encoding/base32" 5 | "hash/fnv" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkIdify(b *testing.B) { 10 | sample := []string{"0", "medium", "a longer string", "a super long duper long very very long string"} 11 | for n := 0; n < b.N; n++ { 12 | for _, s := range sample { 13 | idify(s) 14 | } 15 | } 16 | } 17 | 18 | func BenchmarkBase32(b *testing.B) { 19 | var h = fnv.New32a() 20 | sample := []string{"0", "medium", "a longer string", "a super long duper long very very long string"} 21 | for n := 0; n < b.N; n++ { 22 | for _, s := range sample { 23 | h.Reset() 24 | h.Write([]byte(s)) 25 | b := h.Sum(nil) 26 | base32.StdEncoding.EncodeToString(b) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /expr/person.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type ( 8 | // Person represents a person. 9 | Person struct { 10 | *Element 11 | Location LocationKind 12 | } 13 | 14 | // People is a slide of Person that can easily be converted into a slice of ElementHolder. 15 | People []*Person 16 | ) 17 | 18 | // PersonTags list the tags that are automatically added to all people. 19 | var PersonTags = []string{"Element", "Person"} 20 | 21 | // EvalName returns the generic expression name used in error messages. 22 | func (p *Person) EvalName() string { 23 | if p.Name == "" { 24 | return "unnamed person" 25 | } 26 | return fmt.Sprintf("person %q", p.Name) 27 | } 28 | 29 | // Finalize adds the 'Person' tag ands finalizes relationships. 30 | func (p *Person) Finalize() { 31 | p.PrefixTags(PersonTags...) 32 | p.Element.Finalize() 33 | } 34 | 35 | // Elements returns a slice of ElementHolder that contains the people. 36 | func (p People) Elements() []ElementHolder { 37 | res := make([]ElementHolder, len(p)) 38 | for i, pp := range p { 39 | res[i] = pp 40 | } 41 | return res 42 | } 43 | -------------------------------------------------------------------------------- /expr/person_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestPersonEvalName(t *testing.T) { 9 | tests := []struct { 10 | name, want string 11 | }{ 12 | {name: "", want: "unnamed person"}, 13 | {name: "foo", want: `person "foo"`}, 14 | } 15 | for _, tt := range tests { 16 | tt := tt 17 | t.Run(tt.name, func(t *testing.T) { 18 | t.Parallel() 19 | person := Person{ 20 | Element: &Element{ 21 | Name: tt.name, 22 | }, 23 | } 24 | if got := person.EvalName(); got != tt.want { 25 | t.Errorf("got %s, want %s", got, tt.want) 26 | } 27 | }) 28 | } 29 | } 30 | 31 | func TestPersonFinalize(t *testing.T) { 32 | t.Parallel() 33 | person := Person{ 34 | Element: &Element{ 35 | Name: "foo", 36 | }, 37 | } 38 | seq := []struct { 39 | pre func() 40 | want string 41 | }{ 42 | {want: ""}, 43 | {pre: func() { person.Tags = "foo" }, want: "foo"}, 44 | {pre: func() { person.Finalize() }, want: "Element,Person,foo"}, 45 | } 46 | for i, tt := range seq { 47 | tt := tt 48 | t.Run(fmt.Sprint(i), func(t *testing.T) { 49 | if tt.pre != nil { 50 | tt.pre() 51 | } 52 | if got := person.Tags; got != tt.want { 53 | t.Errorf("got %s, want %s", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestPeopleElements(t *testing.T) { 60 | t.Parallel() 61 | people := People{ 62 | {Element: &Element{Name: "foo"}}, 63 | {Element: &Element{Name: "bar"}}, 64 | {Element: &Element{Name: "baz"}}, 65 | } 66 | if got := people.Elements(); len(got) != len(people) { 67 | t.Errorf("got %d, want %d", len(got), len(people)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /expr/registry.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | "math/big" 7 | "sort" 8 | ) 9 | 10 | // Registry captures all the elements, people and relationships. 11 | var Registry = make(map[string]any) 12 | 13 | // Iterate iterates through all elements, people and relationships in the 14 | // registry in a consistent order. 15 | func Iterate(visitor func(elem any)) { 16 | keys := make([]string, len(Registry)) 17 | i := 0 18 | for k := range Registry { 19 | keys[i] = k 20 | i++ 21 | } 22 | sort.Strings(keys) 23 | for _, k := range keys { 24 | visitor(Registry[k]) 25 | } 26 | } 27 | 28 | // IterateRelationships iterates through all relationships in the registry in a 29 | // consistent order. 30 | func IterateRelationships(visitor func(r *Relationship)) { 31 | Iterate(func(e any) { 32 | if r, ok := e.(*Relationship); ok { 33 | visitor(r) 34 | } 35 | }) 36 | } 37 | 38 | // Identify sets the ID field of the given element or relationship and registers 39 | // it with the global registry. The algorithm first compute a unique moniker 40 | // for the element or relatioship (based on names and parent scope ID) then 41 | // hashes and base36 encodes the result. 42 | func Identify(element any) { 43 | var id string 44 | switch e := element.(type) { 45 | case *Person: 46 | id = idify(e.Name) 47 | e.ID = id 48 | case *SoftwareSystem: 49 | id = idify(e.Name) 50 | e.ID = id 51 | case *Container: 52 | id = idify(e.System.ID + ":" + e.Name) 53 | e.ID = id 54 | case *Component: 55 | id = idify(e.Container.ID + ":" + e.Name) 56 | e.ID = id 57 | case *DeploymentNode: 58 | prefix := "dn:" + e.Environment + ":" 59 | for f := e.Parent; f != nil; f = f.Parent { 60 | prefix += f.ID + ":" 61 | } 62 | id = idify(prefix + e.Name) 63 | e.ID = id 64 | case *InfrastructureNode: 65 | id = idify(e.Environment + ":" + e.Parent.ID + ":" + e.Name) 66 | e.ID = id 67 | case *ContainerInstance: 68 | id = idify(e.Environment + ":" + e.Parent.ID + ":" + e.ContainerID) 69 | e.ID = id 70 | case *Relationship: 71 | var dest string 72 | if e.Destination != nil { 73 | dest = e.Destination.ID 74 | } else { 75 | dest = e.DestinationPath 76 | } 77 | id = idify(e.Source.ID + ":" + dest + ":" + e.Description) 78 | e.ID = id 79 | default: 80 | panic(fmt.Sprintf("element of type %T does not have an ID", element)) // bug 81 | } 82 | if _, ok := Registry[id]; ok { 83 | // Could have been imported from another model package 84 | return 85 | } 86 | Registry[id] = element 87 | } 88 | 89 | var h = fnv.New32a() 90 | 91 | func idify(s string) string { 92 | h.Reset() 93 | h.Write([]byte(s)) 94 | return encodeToBase36(h.Sum(nil)) 95 | } 96 | 97 | var encodeStd = [36]byte{ 98 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 99 | 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 100 | 'u', 'v', 'w', 'x', 'y', 'z', 101 | } 102 | 103 | var bigRadix = big.NewInt(36) 104 | var bigZero = big.NewInt(0) 105 | 106 | func encodeToBase36(b []byte) string { 107 | x := new(big.Int) 108 | x.SetBytes(b) 109 | res := make([]byte, 0, len(b)*136/100) 110 | for x.Cmp(bigZero) > 0 { 111 | mod := new(big.Int) 112 | x.DivMod(x, bigRadix, mod) 113 | res = append(res, encodeStd[mod.Int64()]) 114 | } 115 | for _, i := range b { 116 | if i != 0 { 117 | break 118 | } 119 | res = append(res, byte(48)) 120 | } 121 | alen := len(res) 122 | for i := 0; i < alen/2; i++ { 123 | res[i], res[alen-1-i] = res[alen-1-i], res[i] 124 | } 125 | return string(res) 126 | } 127 | -------------------------------------------------------------------------------- /expr/relationship.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type ( 9 | // Relationship describes a uni-directional relationship between two elements. 10 | Relationship struct { 11 | ID string 12 | Source *Element 13 | Description string 14 | Technology string 15 | InteractionStyle InteractionStyleKind 16 | Tags string 17 | URL string 18 | 19 | // DestinationPath is used to compute the destination after all DSL has 20 | // completed execution. 21 | DestinationPath string 22 | 23 | // Destination is only guaranteed to be initialized after the DSL has 24 | // been executed. It can be used in validations and finalizers. 25 | Destination *Element 26 | 27 | // LinkedRelationshipID is the ID of the relationship pointing to the 28 | // container corresponding to the container instance with this 29 | // relationship. 30 | LinkedRelationshipID string 31 | } 32 | 33 | // InteractionStyleKind is the enum for possible interaction styles. 34 | InteractionStyleKind int 35 | ) 36 | 37 | const ( 38 | // InteractionUndefined means no interaction style specified in design. 39 | InteractionUndefined InteractionStyleKind = iota 40 | // InteractionSynchronous describes a synchronous interaction. 41 | InteractionSynchronous 42 | // InteractionAsynchronous describes an asynchronous interaction. 43 | InteractionAsynchronous 44 | ) 45 | 46 | // RelationshipTags lists the tags that are added to all relationships. 47 | var RelationshipTags = []string{"Relationship", "Asynchronous", "Synchronous"} 48 | 49 | // EvalName is the qualified name of the expression. 50 | func (r *Relationship) EvalName() string { 51 | var src, dest = "", "" 52 | if r.Source != nil { 53 | src = r.Source.Name 54 | } 55 | if r.Destination != nil { 56 | dest = r.Destination.Name 57 | } 58 | return fmt.Sprintf("relationship %q [%s -> %s]", r.Description, src, dest) 59 | } 60 | 61 | // Finalize computes the destination and adds the "Relationship" tag. 62 | func (r *Relationship) Finalize() { 63 | // prefix tags 64 | if r.InteractionStyle == InteractionAsynchronous { 65 | r.Tags = mergeTags("Asynchronous", strings.Split(r.Tags, ",")) 66 | } 67 | r.Tags = mergeTags(RelationshipTags[0], strings.Split(r.Tags, ",")) 68 | } 69 | 70 | // PrefixTags adds the given tags to the beginning of the comm 71 | // Dup creates a new relationship with identical description, tags, URL, 72 | // technology and interaction style as r. Dup also creates a new ID for the 73 | // result. 74 | func (r *Relationship) Dup(newSrc, newDest *Element) *Relationship { 75 | dup := &Relationship{ 76 | Source: newSrc, 77 | InteractionStyle: r.InteractionStyle, 78 | Tags: r.Tags, 79 | URL: r.URL, 80 | Destination: newDest, 81 | Description: r.Description, 82 | Technology: r.Technology, 83 | } 84 | Identify(dup) 85 | return dup 86 | } 87 | 88 | // MergeTags adds the given tags. It skips tags already present in e.Tags. 89 | func (r *Relationship) MergeTags(tags ...string) { 90 | r.Tags = mergeTags(r.Tags, tags) 91 | } 92 | -------------------------------------------------------------------------------- /expr/relationship_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | // Don't use t.parallel. Crashes when file tests are run as concurrent map read & write. 9 | 10 | func Test_Relationship_EvalName(t *testing.T) { 11 | mRelationship := Relationship{ 12 | Source: &Element{Name: "Source"}, 13 | Destination: &Element{Name: "Destination"}, 14 | Description: "Sample", 15 | } 16 | 17 | tests := []struct { 18 | want string 19 | }{ 20 | {want: "relationship \"Sample\" [Source -> Destination]"}, 21 | } 22 | for i, tt := range tests { 23 | tt := tt 24 | t.Run(fmt.Sprint(i), func(t *testing.T) { 25 | if got := mRelationship.EvalName(); got != tt.want { 26 | t.Errorf("got %s, want %s", got, tt.want) 27 | } 28 | }) 29 | } 30 | 31 | } 32 | 33 | func Test_Relationship_Finalize(t *testing.T) { 34 | mRelationship := Relationship{ 35 | Description: "Sample", 36 | Tags: "Tag0", 37 | } 38 | 39 | tests := []struct { 40 | InteractionStyle InteractionStyleKind 41 | want string 42 | }{ 43 | {InteractionStyle: InteractionAsynchronous, want: "Relationship,Asynchronous"}, 44 | {InteractionStyle: InteractionSynchronous, want: "Relationship"}, 45 | } 46 | for i, tt := range tests { 47 | mRelationship.InteractionStyle = tt.InteractionStyle 48 | mRelationship.Tags = "" 49 | t.Run(fmt.Sprint(i), func(t *testing.T) { 50 | mRelationship.Finalize() 51 | if mRelationship.Tags != tt.want { 52 | t.Errorf("received %s, wanted %s", mRelationship.Tags, tt.want) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func Test_Relationship_MergeTags(t *testing.T) { 59 | mRelationship := Relationship{ 60 | Source: &Element{Name: "Source"}, 61 | Destination: &Element{Name: "Destination"}, 62 | Description: "Sample", 63 | Tags: "Tag0", 64 | } 65 | 66 | tests := []struct { 67 | tag1 string 68 | tag2 string 69 | }{ 70 | {tag1: "Tag1", tag2: "Tag2"}, 71 | } 72 | for i, tt := range tests { 73 | tt := tt 74 | t.Run(fmt.Sprint(i), func(t *testing.T) { 75 | mRelationship.MergeTags(tt.tag1, tt.tag2) 76 | if mRelationship.Tags != "Tag0,Tag1,Tag2" { 77 | t.Errorf("had %s, wanted %s", mRelationship.Tags, "Tag0,Tag1,Tag2") 78 | } 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /expr/system.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type ( 9 | // SoftwareSystem represents a software system. 10 | SoftwareSystem struct { 11 | *Element 12 | Location LocationKind 13 | Containers Containers 14 | } 15 | 16 | // SoftwareSystems is a slice of software system that can be easily 17 | // converted into a slice of ElementHolder. 18 | SoftwareSystems []*SoftwareSystem 19 | ) 20 | 21 | // SoftwareSystemTags lists the tags that are added to all software systems. 22 | var SoftwareSystemTags = []string{"Element", "Software System"} 23 | 24 | // EvalName returns the generic expression name used in error messages. 25 | func (s *SoftwareSystem) EvalName() string { 26 | if s.Name == "" { 27 | return "unnamed software system" 28 | } 29 | return fmt.Sprintf("software system %q", s.Name) 30 | } 31 | 32 | // Finalize adds the 'SoftwareSystem' tag ands finalizes relationships. 33 | func (s *SoftwareSystem) Finalize() { 34 | s.PrefixTags(SoftwareSystemTags...) 35 | s.Element.Finalize() 36 | } 37 | 38 | // Elements returns a slice of ElementHolder that contains the elements of s. 39 | func (s SoftwareSystems) Elements() []ElementHolder { 40 | res := make([]ElementHolder, len(s)) 41 | for i, ss := range s { 42 | res[i] = ss 43 | } 44 | return res 45 | } 46 | 47 | // Container returns the container with the given name if any, nil otherwise. 48 | func (s *SoftwareSystem) Container(name string) *Container { 49 | for _, c := range s.Containers { 50 | if c.Name == name { 51 | return c 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | // AddContainer adds the given container to the software system. If there is 58 | // already a container with the given name then AddContainer merges both 59 | // definitions. The merge algorithm: 60 | // 61 | // - overrides the description, technology and URL if provided, 62 | // - merges any new tag or propery into the existing tags and properties, 63 | // - merges any new component into the existing components. 64 | // 65 | // AddContainer returns the new or merged person. 66 | func (s *SoftwareSystem) AddContainer(c *Container) *Container { 67 | existing := s.Container(c.Name) 68 | if existing == nil { 69 | Identify(c) 70 | s.Containers = append(s.Containers, c) 71 | return c 72 | } 73 | if c.Description != "" { 74 | existing.Description = c.Description 75 | } 76 | if c.Technology != "" { 77 | existing.Technology = c.Technology 78 | } 79 | if c.URL != "" { 80 | existing.URL = c.URL 81 | } 82 | existing.MergeTags(strings.Split(c.Tags, ",")...) 83 | for _, cmp := range c.Components { 84 | existing.AddComponent(cmp) // will merge if needed 85 | } 86 | if olddsl := existing.DSLFunc; olddsl != nil && c.DSLFunc != nil { 87 | existing.DSLFunc = func() { olddsl(); c.DSLFunc() } 88 | } 89 | return existing 90 | } 91 | -------------------------------------------------------------------------------- /expr/system_test.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func Test_AddContainer(t *testing.T) { 9 | 10 | mSoftwareSystem := SoftwareSystem{ 11 | Element: &Element{ID: "1", Name: "Software system"}, 12 | } 13 | Identify(&mSoftwareSystem) 14 | mContainer := Container{ 15 | Element: &Element{ID: "1", Name: "Container", Tags: "One,Two"}, 16 | System: &mSoftwareSystem, 17 | } 18 | 19 | mNewContainer := Container{ 20 | Element: &Element{ID: "1", 21 | Name: "Container", 22 | Description: "Uncertain", 23 | Technology: "CottonOnString", 24 | URL: "microsoft.com", 25 | Tags: "Three,Four", 26 | }, 27 | System: &mSoftwareSystem, 28 | } 29 | 30 | tests := []struct { 31 | in *Container 32 | want *Container 33 | }{ 34 | {in: &mContainer, want: &mNewContainer}, 35 | {in: &mNewContainer, want: &mNewContainer}, 36 | } 37 | for i, tt := range tests { 38 | t.Run(fmt.Sprint(i), func(t *testing.T) { 39 | got := mSoftwareSystem.AddContainer(tt.in) 40 | if i == 0 { 41 | if got.Name != mContainer.Name { 42 | t.Errorf("Got %s, wanted %s", got.Name, tt.want.Name) 43 | } 44 | } else { 45 | if got.Description != mContainer.Description { 46 | t.Errorf("Got %s, wanted %s", got.Description, tt.want.Description) 47 | } 48 | if got.Technology != mContainer.Technology { 49 | t.Errorf("Got %s, wanted %s", got.Technology, tt.want.Technology) 50 | } 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /expr/view_props.go: -------------------------------------------------------------------------------- 1 | package expr 2 | 3 | import ( 4 | "fmt" 5 | 6 | "goa.design/goa/v3/eval" 7 | ) 8 | 9 | type ( 10 | // ViewProps contains common properties of a view as well as helper 11 | // methods to fetch them. 12 | ViewProps struct { 13 | Key string 14 | Description string 15 | Title string 16 | AutoLayout *AutoLayout 17 | PaperSize PaperSizeKind 18 | ElementViews []*ElementView 19 | RelationshipViews []*RelationshipView 20 | AnimationSteps []*AnimationStep 21 | 22 | // The following fields are used to compute the elements and 23 | // relationships that should be added to the view. 24 | AddAll bool 25 | AddDefault bool 26 | AddNeighbors []*Element 27 | RemoveElements []*Element 28 | RemoveTags []string 29 | RemoveRelationships []*Relationship 30 | RemoveUnreachable []*Element 31 | RemoveUnrelated bool 32 | CoalescedRelationships []*CoalescedRelationship 33 | CoalesceAllRelationships bool 34 | } 35 | 36 | // ElementView describes an instance of a model element (Person, 37 | // Software System, Container or Component) in a View. 38 | ElementView struct { 39 | Element *Element 40 | NoRelationship bool 41 | X *int 42 | Y *int 43 | } 44 | 45 | // RelationshipView describes an instance of a model relationship in a 46 | // view. 47 | RelationshipView struct { 48 | Source *Element 49 | Destination *Element 50 | Description string 51 | Technology string 52 | Order string 53 | Vertices []*Vertex 54 | Routing RoutingKind 55 | Position *int 56 | 57 | // RelationshipID is computed in finalize. 58 | RelationshipID string 59 | } 60 | 61 | // AutoLayout describes an automatic layout. 62 | AutoLayout struct { 63 | Implementation ImplementationKind 64 | RankDirection RankDirectionKind 65 | RankSep *int 66 | NodeSep *int 67 | EdgeSep *int 68 | Vertices *bool 69 | } 70 | 71 | // Vertex describes the x and y coordinate of a bend in a line. 72 | Vertex struct { 73 | X int 74 | Y int 75 | } 76 | 77 | // AnimationStep represents an animation step. 78 | AnimationStep struct { 79 | Elements []ElementHolder 80 | RelationshipIDs []string 81 | Order int 82 | View View 83 | } 84 | 85 | // CoalescedRelationship describes relationships that should be coalesced. 86 | CoalescedRelationship struct { 87 | Source *Element 88 | Destination *Element 89 | Description string 90 | Technology string 91 | } 92 | 93 | // PaperSizeKind is the enum for possible paper kinds. 94 | PaperSizeKind int 95 | 96 | // RoutingKind is the enum for possible routing algorithms. 97 | RoutingKind int 98 | 99 | // ImplementationKind is the enum for possible automatic layout implementations 100 | ImplementationKind int 101 | 102 | // RankDirectionKind is the enum for possible automatic layout rank 103 | // directions. 104 | RankDirectionKind int 105 | ) 106 | 107 | const ( 108 | SizeUndefined PaperSizeKind = iota 109 | SizeA0Landscape 110 | SizeA0Portrait 111 | SizeA1Landscape 112 | SizeA1Portrait 113 | SizeA2Landscape 114 | SizeA2Portrait 115 | SizeA3Landscape 116 | SizeA3Portrait 117 | SizeA4Landscape 118 | SizeA4Portrait 119 | SizeA5Landscape 120 | SizeA5Portrait 121 | SizeA6Landscape 122 | SizeA6Portrait 123 | SizeLegalLandscape 124 | SizeLegalPortrait 125 | SizeLetterLandscape 126 | SizeLetterPortrait 127 | SizeSlide16X10 128 | SizeSlide16X9 129 | SizeSlide4X3 130 | ) 131 | 132 | const ( 133 | RoutingUndefined RoutingKind = iota 134 | RoutingDirect 135 | RoutingOrthogonal 136 | RoutingCurved 137 | ) 138 | 139 | const ( 140 | ImplementationUndefined ImplementationKind = iota 141 | ImplementationGraphviz 142 | ImplementationDagre 143 | ) 144 | 145 | const ( 146 | RankUndefined RankDirectionKind = iota 147 | RankTopBottom 148 | RankBottomTop 149 | RankLeftRight 150 | RankRightLeft 151 | ) 152 | 153 | // ElementView returns the element view for the element with the given ID if 154 | // any. 155 | func (v *ViewProps) ElementView(id string) *ElementView { 156 | for _, ev := range v.ElementViews { 157 | if ev.Element.ID == id { 158 | return ev 159 | } 160 | } 161 | return nil 162 | } 163 | 164 | // Props returns the underlying properties object. 165 | func (v *ViewProps) Props() *ViewProps { return v } 166 | 167 | // EvalName returns the generic expression name used in error messages. 168 | func (v *ViewProps) EvalName() string { 169 | var suf string 170 | switch { 171 | case v.Title != "": 172 | suf = fmt.Sprintf(" with title %q and key %q", v.Title, v.Key) 173 | default: 174 | suf = fmt.Sprintf(" %q", v.Key) 175 | } 176 | return fmt.Sprintf("view%s", suf) 177 | } 178 | 179 | // EvalName returns the generic expression name used in error messages. 180 | func (*AnimationStep) EvalName() string { return "animation step" } 181 | 182 | // Add adds the given elements to the animation step. 183 | func (l *AnimationStep) Add(eh ElementHolder) { 184 | l.Elements = append(l.Elements, eh) 185 | } 186 | 187 | // EvalName returns the generic expression name used in error messages. 188 | func (*AutoLayout) EvalName() string { return "automatic layout" } 189 | 190 | // EvalName returns the generic expression name used in error messages. 191 | func (*ElementView) EvalName() string { return "element view" } 192 | 193 | // EvalName returns the generic expression name used in error messages. 194 | func (*RelationshipView) EvalName() string { return "relationship view" } 195 | 196 | // EvalName returns the generic expression name used in error messages. 197 | func (*CoalescedRelationship) EvalName() string { return "coalesced relationship" } 198 | 199 | // Validate makes sure there is a corresponding relationship (and exactly one). 200 | func (v *RelationshipView) Validate() error { 201 | verr := new(eval.ValidationErrors) 202 | var rel *Relationship 203 | found := false 204 | IterateRelationships(func(r *Relationship) { 205 | if r.Source.ID == v.Source.ID && r.Destination.ID == v.Destination.ID { 206 | if v.Description != "" { 207 | if r.Description == v.Description { 208 | rel = r 209 | } 210 | } else { 211 | rel = r 212 | if found { 213 | verr.Add(v, "Link: there exists multiple relationships between %q and %q, specify the relationship description.", v.Source.Name, v.Destination.Name) 214 | } 215 | found = true 216 | } 217 | } 218 | }) 219 | if rel == nil { 220 | var suffix string 221 | if v.Description != "" { 222 | suffix = fmt.Sprintf(" with description %q", v.Description) 223 | } 224 | verr.Add(v, "Link: no relationship between %q and %q%s", v.Source.Name, v.Destination.Name, suffix) 225 | } 226 | return verr 227 | } 228 | 229 | // reachable returns the IDs of all elements that can be reached by traversing 230 | // the relationships from the given root. 231 | func reachable(e *Element) (res []string) { 232 | seen := make(map[string]struct{}) 233 | traverse(e, seen) 234 | res = make([]string, len(seen)) 235 | for k := range seen { 236 | res = append(res, k) 237 | } 238 | return 239 | } 240 | 241 | func traverse(e *Element, seen map[string]struct{}) { 242 | add := func(nid string) bool { 243 | for id := range seen { 244 | if id == nid { 245 | return false 246 | } 247 | } 248 | seen[nid] = struct{}{} 249 | return true 250 | } 251 | IterateRelationships(func(r *Relationship) { 252 | if r.Source.ID == e.ID { 253 | if add(r.Destination.ID) { 254 | traverse(r.Destination, seen) 255 | } 256 | } 257 | if r.Destination.ID == e.ID { 258 | if add(r.Source.ID) { 259 | traverse(r.Source, seen) 260 | } 261 | } 262 | }) 263 | } 264 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module goa.design/model 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.9.0 9 | github.com/jaschaephraim/lrserver v0.0.0-20240306232639-afed386b3640 10 | github.com/kylelemons/godebug v1.1.0 11 | github.com/stretchr/testify v1.10.0 12 | goa.design/goa/v3 v3.21.1 13 | golang.org/x/tools v0.33.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect 19 | github.com/go-chi/chi/v5 v5.2.1 // indirect 20 | github.com/google/uuid v1.6.0 // indirect 21 | github.com/gorilla/websocket v1.5.3 // indirect 22 | github.com/kr/pretty v0.1.0 // indirect 23 | github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | golang.org/x/mod v0.24.0 // indirect 26 | golang.org/x/sync v0.14.0 // indirect 27 | golang.org/x/sys v0.33.0 // indirect 28 | golang.org/x/text v0.25.0 // indirect 29 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= 4 | github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI= 5 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 6 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 7 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 8 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 9 | github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= 10 | github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 14 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 15 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 16 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 17 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 18 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/jaschaephraim/lrserver v0.0.0-20240306232639-afed386b3640 h1:qxoA9wh1IZAbMhfFSE81tn8RsB48LNd7ecH6lFpxucc= 20 | github.com/jaschaephraim/lrserver v0.0.0-20240306232639-afed386b3640/go.mod h1:1Dkfm1/kgjeZc+2TBUAyZ3TJeQ/HaKbj8ig+7nAHkws= 21 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 22 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 23 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 24 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 25 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 26 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 27 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 28 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 29 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 30 | github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= 31 | github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= 32 | github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= 33 | github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b/go.mod h1:Bj8LjjP0ReT1eKt5QlKjwgi5AFm5mI6O1A2G4ChI0Ag= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= 37 | github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= 38 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 39 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | goa.design/goa/v3 v3.21.1 h1:tLwhbcNoEBJm1CcJc3ks6oZ8BHYl6vFuxEBnl2kC428= 43 | goa.design/goa/v3 v3.21.1/go.mod h1:E+97AYffVIvDi6LkuNdfdvMZb8UFb/+ie3V0/WBBdgc= 44 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 45 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 46 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 47 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 48 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 49 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 50 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 51 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 52 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 53 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 56 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 58 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | -------------------------------------------------------------------------------- /mdl/deployment.go: -------------------------------------------------------------------------------- 1 | package mdl 2 | 3 | type ( 4 | // DeploymentNode describes a single deployment node. 5 | DeploymentNode struct { 6 | // ID of element. 7 | ID string `json:"id"` 8 | // Name of element - not applicable to ContainerInstance. 9 | Name string `json:"name,omitempty"` 10 | // Description of element if any. 11 | Description string `json:"description,omitempty"` 12 | // Technology used by element if any - not applicable to Person. 13 | Technology string `json:"technology,omitempty"` 14 | // Environment is the deployment environment in which this deployment 15 | // node resides (e.g. "Development", "Live", etc). 16 | Environment string `json:"environment"` 17 | // Instances is the number of instances. 18 | Instances *string `json:"instances,omitempty"` 19 | // Tags attached to element as comma separated list if any. 20 | Tags string `json:"tags,omitempty"` 21 | // URL where more information about this element can be found. 22 | URL string `json:"url,omitempty"` 23 | // Children describe the child deployment nodes if any. 24 | Children []*DeploymentNode `json:"children,omitempty"` 25 | // InfrastructureNodes describe the infrastructure nodes (load 26 | // balancers, firewall etc.) 27 | InfrastructureNodes []*InfrastructureNode `json:"infrastructureNodes,omitempty"` 28 | // ContainerInstances describe instances of containers deployed in 29 | // deployment node. 30 | ContainerInstances []*ContainerInstance `json:"containerInstances,omitempty"` 31 | // Set of arbitrary name-value properties (shown in diagram tooltips). 32 | Properties map[string]string `json:"properties,omitempty"` 33 | // Relationships is the set of relationships from this element to other 34 | // elements. 35 | Relationships []*Relationship `json:"relationships,omitempty"` 36 | } 37 | 38 | // InfrastructureNode describes an infrastructure node. 39 | InfrastructureNode struct { 40 | // ID of element. 41 | ID string `json:"id"` 42 | // Name of element - not applicable to ContainerInstance. 43 | Name string `json:"name,omitempty"` 44 | // Description of element if any. 45 | Description string `json:"description,omitempty"` 46 | // Technology used by element if any - not applicable to Person. 47 | Technology string `json:"technology,omitempty"` 48 | // Tags attached to element as comma separated list if any. 49 | Tags string `json:"tags,omitempty"` 50 | // URL where more information about this element can be found. 51 | URL string `json:"url,omitempty"` 52 | // Set of arbitrary name-value properties (shown in diagram tooltips). 53 | Properties map[string]string `json:"properties,omitempty"` 54 | // Relationships is the set of relationships from this element to other 55 | // elements. 56 | Relationships []*Relationship `json:"relationships,omitempty"` 57 | // Environment is the deployment environment in which this 58 | // infrastructure node resides (e.g. "Development", "Live", 59 | // etc). 60 | Environment string `json:"environment"` 61 | } 62 | 63 | // ContainerInstance describes an instance of a container. 64 | ContainerInstance struct { 65 | // ID of element. 66 | ID string `json:"id"` 67 | // Tags attached to element as comma separated list if any. 68 | Tags string `json:"tags,omitempty"` 69 | // URL where more information about this element can be found. 70 | URL string `json:"url,omitempty"` 71 | // Set of arbitrary name-value properties (shown in diagram tooltips). 72 | Properties map[string]string `json:"properties,omitempty"` 73 | // Relationships is the set of relationships from this element to other 74 | // elements. 75 | Relationships []*Relationship `json:"relationships,omitempty"` 76 | // ID of container that is instantiated. 77 | ContainerID string `json:"containerId"` 78 | // InstanceID is the number/index of this instance. 79 | InstanceID int `json:"instanceId"` 80 | // Environment is the deployment environment of this instance. 81 | Environment string `json:"environment"` 82 | // HealthChecks is the set of HTTP-based health checks for this 83 | // container instance. 84 | HealthChecks []*HealthCheck `json:"healthChecks,omitempty"` 85 | } 86 | 87 | // HealthCheck is a HTTP-based health check. 88 | HealthCheck struct { 89 | // Name of health check. 90 | Name string `json:"name"` 91 | // Health check URL/endpoint. 92 | URL string `json:"url"` 93 | // Polling interval, in seconds. 94 | Interval int `json:"interval"` 95 | // Timeout after which health check is deemed as failed, in milliseconds. 96 | Timeout int `json:"timeout"` 97 | // Set of name-value pairs corresponding to HTTP headers to be sent with request. 98 | Headers map[string]string `json:"headers"` 99 | } 100 | ) 101 | -------------------------------------------------------------------------------- /mdl/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package mdl defines data structures that represent a software architecture 3 | model and accompanying views that follow the C4 model (https://c4model.com). 4 | 5 | The data structures can be serialized into JSON. The top level data structure 6 | is Design which defines the model and views as well as a name, description 7 | and version for the design. Model describes the people, software systems, 8 | containers and components that make up the architecture as well as the 9 | deployment nodes that represent runtime deployments. Views describes diagrams 10 | that represent different level of details - from contextual views that 11 | represent the overall system in context with other systems to component level 12 | views that render software components and their relationships. 13 | */ 14 | package mdl 15 | -------------------------------------------------------------------------------- /mdl/elements.go: -------------------------------------------------------------------------------- 1 | package mdl 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | type ( 9 | // Person represents a person. 10 | Person struct { 11 | // ID of element. 12 | ID string `json:"id"` 13 | // Name of element - not applicable to ContainerInstance. 14 | Name string `json:"name,omitempty"` 15 | // Description of element if any. 16 | Description string `json:"description,omitempty"` 17 | // Tags attached to element as comma separated list if any. 18 | Tags string `json:"tags,omitempty"` 19 | // URL where more information about this element can be found. 20 | URL string `json:"url,omitempty"` 21 | // Set of arbitrary name-value properties (shown in diagram tooltips). 22 | Properties map[string]string `json:"properties,omitempty"` 23 | // Relationships is the set of relationships from this element to other 24 | // elements. 25 | Relationships []*Relationship `json:"relationships,omitempty"` 26 | // Location of person. 27 | Location LocationKind `json:"location,omitempty"` 28 | } 29 | 30 | // SoftwareSystem represents a software system. 31 | SoftwareSystem struct { 32 | // ID of element. 33 | ID string `json:"id"` 34 | // Name of element - not applicable to ContainerInstance. 35 | Name string `json:"name,omitempty"` 36 | // Description of element if any. 37 | Description string `json:"description,omitempty"` 38 | // Tags attached to element as comma separated list if any. 39 | Tags string `json:"tags,omitempty"` 40 | // URL where more information about this element can be found. 41 | URL string `json:"url,omitempty"` 42 | // Set of arbitrary name-value properties (shown in diagram tooltips). 43 | Properties map[string]string `json:"properties,omitempty"` 44 | // Relationships is the set of relationships from this element to other 45 | // elements. 46 | Relationships []*Relationship `json:"relationships,omitempty"` 47 | // Location of element. 48 | Location LocationKind `json:"location,omitempty"` 49 | // Containers list the containers within the software system. 50 | Containers []*Container `json:"containers,omitempty"` 51 | } 52 | 53 | // Container represents a container. 54 | Container struct { 55 | // ID of element. 56 | ID string `json:"id"` 57 | // Name of element - not applicable to ContainerInstance. 58 | Name string `json:"name,omitempty"` 59 | // Description of element if any. 60 | Description string `json:"description,omitempty"` 61 | // Technology used by element if any - not applicable to Person. 62 | Technology string `json:"technology,omitempty"` 63 | // Tags attached to element as comma separated list if any. 64 | Tags string `json:"tags,omitempty"` 65 | // URL where more information about this element can be found. 66 | URL string `json:"url,omitempty"` 67 | // Set of arbitrary name-value properties (shown in diagram tooltips). 68 | Properties map[string]string `json:"properties,omitempty"` 69 | // Relationships is the set of relationships from this element to other 70 | // elements. 71 | Relationships []*Relationship `json:"relationships,omitempty"` 72 | // Components list the components within the container. 73 | Components []*Component `json:"components,omitempty"` 74 | } 75 | 76 | // Component represents a component. 77 | Component struct { 78 | // ID of element. 79 | ID string `json:"id"` 80 | // Name of element - not applicable to ContainerInstance. 81 | Name string `json:"name,omitempty"` 82 | // Description of element if any. 83 | Description string `json:"description,omitempty"` 84 | // Technology used by element if any - not applicable to Person. 85 | Technology string `json:"technology,omitempty"` 86 | // Tags attached to element as comma separated list if any. 87 | Tags string `json:"tags,omitempty"` 88 | // URL where more information about this element can be found. 89 | URL string `json:"url,omitempty"` 90 | // Set of arbitrary name-value properties (shown in diagram tooltips). 91 | Properties map[string]string `json:"properties,omitempty"` 92 | // Relationships is the set of relationships from this element to other 93 | // elements. 94 | Relationships []*Relationship `json:"relationships,omitempty"` 95 | } 96 | 97 | // LocationKind is the enum for possible locations. 98 | LocationKind int 99 | ) 100 | 101 | const ( 102 | // LocationUndefined means no location specified in design. 103 | LocationUndefined LocationKind = iota 104 | // LocationInternal defines an element internal to the enterprise. 105 | LocationInternal 106 | // LocationExternal defines an element external to the enterprise. 107 | LocationExternal 108 | ) 109 | 110 | // MarshalJSON replaces the constant value with the proper string value. 111 | func (l LocationKind) MarshalJSON() ([]byte, error) { 112 | buf := bytes.NewBufferString(`"`) 113 | switch l { 114 | case LocationInternal: 115 | buf.WriteString("Internal") 116 | case LocationExternal: 117 | buf.WriteString("External") 118 | case LocationUndefined: 119 | buf.WriteString("Undefined") 120 | } 121 | buf.WriteString(`"`) 122 | return buf.Bytes(), nil 123 | } 124 | 125 | // UnmarshalJSON sets the constant from its JSON representation. 126 | func (l *LocationKind) UnmarshalJSON(data []byte) error { 127 | var val string 128 | if err := json.Unmarshal(data, &val); err != nil { 129 | return err 130 | } 131 | switch val { 132 | case "Internal": 133 | *l = LocationInternal 134 | case "External": 135 | *l = LocationExternal 136 | case "Undefined": 137 | *l = LocationUndefined 138 | } 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /mdl/model.go: -------------------------------------------------------------------------------- 1 | package mdl 2 | 3 | import ( 4 | "encoding/json" 5 | "sort" 6 | ) 7 | 8 | type ( 9 | // Model describes a software architecture model. 10 | Model struct { 11 | // Enterprise associated with model if any. 12 | Enterprise *Enterprise `json:"enterprise,omitempty"` 13 | // People lists Person elements. 14 | People []*Person `json:"people,omitempty"` 15 | // Systems lists Software System elements. 16 | Systems []*SoftwareSystem `json:"softwareSystems,omitempty"` 17 | // DeploymentNodes list the deployment nodes. 18 | DeploymentNodes []*DeploymentNode `json:"deploymentNodes,omitempty"` 19 | } 20 | 21 | // Enterprise describes a named enterprise / organization. 22 | Enterprise struct { 23 | // Name of enterprise. 24 | Name string `json:"name"` 25 | } 26 | 27 | // alias to call original json.Unmarshal 28 | _model Model 29 | ) 30 | 31 | // MarshalJSON guarantees the order of elements in generated JSON arrays that 32 | // correspond to sets. 33 | func (m *Model) MarshalJSON() ([]byte, error) { 34 | sort.Slice(m.People, func(i, j int) bool { return m.People[i].Name < m.People[j].Name }) 35 | for _, p := range m.People { 36 | sort.Slice(p.Relationships, func(i, j int) bool { return p.Relationships[i].ID < p.Relationships[j].ID }) 37 | } 38 | sort.Slice(m.Systems, func(i, j int) bool { return m.Systems[i].Name < m.Systems[j].Name }) 39 | for _, sys := range m.Systems { 40 | sort.Slice(sys.Relationships, func(i, j int) bool { return sys.Relationships[i].ID < sys.Relationships[j].ID }) 41 | sort.Slice(sys.Containers, func(i, j int) bool { return sys.Containers[i].Name < sys.Containers[j].Name }) 42 | for _, c := range sys.Containers { 43 | sort.Slice(c.Relationships, func(i, j int) bool { return c.Relationships[i].ID < c.Relationships[j].ID }) 44 | sort.Slice(c.Components, func(i, j int) bool { return c.Components[i].Name < c.Components[j].Name }) 45 | for _, cmp := range c.Components { 46 | sort.Slice(cmp.Relationships, func(i, j int) bool { return cmp.Relationships[i].ID < cmp.Relationships[j].ID }) 47 | } 48 | } 49 | } 50 | sortDeploymentNodes(m.DeploymentNodes) 51 | mm := _model(*m) 52 | return json.Marshal(&mm) 53 | } 54 | 55 | func sortDeploymentNodes(nodes []*DeploymentNode) { 56 | sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name }) 57 | for _, node := range nodes { 58 | sortDeploymentNodes(node.Children) 59 | sort.Slice(node.InfrastructureNodes, func(i, j int) bool { return node.InfrastructureNodes[i].Name < node.InfrastructureNodes[j].Name }) 60 | sort.Slice(node.ContainerInstances, func(i, j int) bool { return node.ContainerInstances[i].ID < node.ContainerInstances[j].ID }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /mdl/relationship.go: -------------------------------------------------------------------------------- 1 | package mdl 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | type ( 9 | // Relationship describes a uni-directional relationship between two elements. 10 | Relationship struct { 11 | // ID of relationship. 12 | ID string `json:"id"` 13 | // Description of relationship if any. 14 | Description string `json:"description"` 15 | // Tags attached to relationship as comma separated list if any. 16 | Tags string `json:"tags,omitempty"` 17 | // URL where more information can be found. 18 | URL string `json:"url,omitempty"` 19 | // SourceID is the ID of the source element. 20 | SourceID string `json:"sourceId"` 21 | // DestinationID is ID the destination element. 22 | DestinationID string `json:"destinationId"` 23 | // Technology associated with relationship. 24 | Technology string `json:"technology,omitempty"` 25 | // InteractionStyle describes whether the interaction is synchronous or 26 | // asynchronous 27 | InteractionStyle InteractionStyleKind `json:"interactionStyle"` 28 | // ID of container-container relationship upon which this container 29 | // instance-container instance relationship is based. 30 | LinkedRelationshipID string `json:"linkedRelationshipId,omitempty"` 31 | } 32 | 33 | // InteractionStyleKind is the enum for possible interaction styles. 34 | InteractionStyleKind int 35 | ) 36 | 37 | const ( 38 | // InteractionUndefined means no interaction style specified in design. 39 | InteractionUndefined InteractionStyleKind = iota 40 | // InteractionSynchronous describes a synchronous interaction. 41 | InteractionSynchronous 42 | // InteractionAsynchronous describes an asynchronous interaction. 43 | InteractionAsynchronous 44 | ) 45 | 46 | // MarshalJSON replaces the constant value with the proper string value. 47 | func (i InteractionStyleKind) MarshalJSON() ([]byte, error) { 48 | buf := bytes.NewBufferString(`"`) 49 | switch i { 50 | case InteractionSynchronous: 51 | buf.WriteString("Synchronous") 52 | case InteractionAsynchronous: 53 | buf.WriteString("Asynchronous") 54 | case InteractionUndefined: 55 | buf.WriteString("Undefined") 56 | } 57 | buf.WriteString(`"`) 58 | return buf.Bytes(), nil 59 | } 60 | 61 | // UnmarshalJSON sets the constant from its JSON representation. 62 | func (i *InteractionStyleKind) UnmarshalJSON(data []byte) error { 63 | var val string 64 | if err := json.Unmarshal(data, &val); err != nil { 65 | return err 66 | } 67 | switch val { 68 | case "Synchronous": 69 | *i = InteractionSynchronous 70 | case "Asynchronous": 71 | *i = InteractionAsynchronous 72 | case "Undefined": 73 | *i = InteractionUndefined 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/version.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | // Major version number 9 | Major = 1 10 | // Minor version number 11 | Minor = 11 12 | // Build number 13 | Build = 2 14 | // Suffix - set to empty string in release tag commits. 15 | Suffix = "" 16 | ) 17 | 18 | // Version returns the complete version number. 19 | func Version() string { 20 | if Suffix != "" { 21 | return fmt.Sprintf("v%d.%d.%d-%s", Major, Minor, Build, Suffix) 22 | } 23 | return fmt.Sprintf("v%d.%d.%d", Major, Minor, Build) 24 | } 25 | -------------------------------------------------------------------------------- /plugin/generate.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "text/template" 8 | 9 | "goa.design/goa/v3/codegen" 10 | "goa.design/goa/v3/eval" 11 | "goa.design/model/expr" 12 | "goa.design/model/stz" 13 | ) 14 | 15 | // init registers the plugin generator function. 16 | func init() { 17 | codegen.RegisterPlugin("model", "gen", nil, Generate) 18 | } 19 | 20 | // Generate produces the design JSON representation inside the top level gen 21 | // directory. 22 | func Generate(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { 23 | err := eval.RunDSL() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // Generate structurizr JSON 29 | path := filepath.Join(codegen.Gendir, "structurizr", "model.json") 30 | if _, err := os.Stat(path); !os.IsNotExist(err) { 31 | if err := os.Remove(path); err != nil { 32 | return nil, err 33 | } 34 | } 35 | section := &codegen.SectionTemplate{ 36 | Name: "model", 37 | FuncMap: template.FuncMap{"toJSON": toJSON}, 38 | Source: "{{ toJSON . }}", 39 | Data: stz.WorkspaceFromDesign(expr.Root), 40 | } 41 | files = append(files, &codegen.File{ 42 | Path: path, 43 | SectionTemplates: []*codegen.SectionTemplate{section}, 44 | }) 45 | 46 | return files, nil 47 | } 48 | 49 | func toJSON(d any) string { 50 | b, err := json.MarshalIndent(d, "", " ") 51 | if err != nil { 52 | panic("design: " + err.Error()) // bug 53 | } 54 | return string(b) 55 | } 56 | -------------------------------------------------------------------------------- /stz/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package stz implements a client for the 3 | [Structurizr service HTTP APIs](https://structurizr.com/). 4 | */ 5 | package stz 6 | 7 | import ( 8 | "bytes" 9 | "crypto/hmac" 10 | "crypto/md5" 11 | "crypto/sha256" 12 | "encoding/base64" 13 | "encoding/hex" 14 | "encoding/json" 15 | "errors" 16 | "fmt" 17 | "io" 18 | "net/http" 19 | "net/url" 20 | "os" 21 | "os/user" 22 | "strconv" 23 | "time" 24 | 25 | goahttp "goa.design/goa/v3/http" 26 | ) 27 | 28 | var ( 29 | // Host is the Structurizr API host (var for testing). 30 | Host = "api.structurizr.com" 31 | 32 | // Scheme is the HTTP scheme used to make requests to the Structurizr service. 33 | Scheme = "https" 34 | ) 35 | 36 | // UserAgent is the user agent used by this package. 37 | const UserAgent = "structurizr-go/1.0" 38 | 39 | // Response describes the API response returned by some endpoints. 40 | type Response struct { 41 | // Success is true if the API call was successful, false otherwise. 42 | Success bool `json:"success"` 43 | // Message is a human readable response message. 44 | Message string `json:"message"` 45 | // Revision is hte internal revision number. 46 | Revision int `json:"revision"` 47 | } 48 | 49 | // Doer is an interface that encapsulate a HTTP client Do method. 50 | type Doer interface { 51 | Do(*http.Request) (*http.Response, error) 52 | } 53 | 54 | // Client holds the credentials needed to make the requests. 55 | type Client struct { 56 | // Key is the API key. 57 | Key string 58 | // Secret is the API secret. 59 | Secret string 60 | // HTTP is the low level HTTP client. 61 | HTTP Doer 62 | } 63 | 64 | // NewClient instantiates a client using the default HTTP client. 65 | func NewClient(key, secret string) *Client { 66 | return &Client{Key: key, Secret: secret, HTTP: http.DefaultClient} 67 | } 68 | 69 | // Get retrieves the given workspace. 70 | func (c *Client) Get(id string) (*Workspace, error) { 71 | u := &url.URL{Scheme: Scheme, Host: Host, Path: fmt.Sprintf("/workspace/%s", id)} 72 | req, _ := http.NewRequest("GET", u.String(), http.NoBody) 73 | c.sign(req, "", "") 74 | resp, err := c.HTTP.Do(req) 75 | if err != nil { 76 | return nil, err 77 | } 78 | defer func() { 79 | if err := resp.Body.Close(); err != nil { 80 | fmt.Fprintf(os.Stderr, "failed to close response body: %v\n", err) 81 | } 82 | }() 83 | if resp.StatusCode != http.StatusOK { 84 | body, _ := io.ReadAll(resp.Body) 85 | return nil, fmt.Errorf("service error: %s", string(body)) 86 | } 87 | var workspace Workspace 88 | if err := json.NewDecoder(resp.Body).Decode(&workspace); err != nil { 89 | return nil, err 90 | } 91 | return &workspace, nil 92 | } 93 | 94 | // Put stores the given workspace. 95 | func (c *Client) Put(id string, w *Workspace) error { 96 | u := &url.URL{Scheme: Scheme, Host: Host, Path: fmt.Sprintf("/workspace/%s", id)} 97 | body, _ := json.Marshal(w) 98 | req, _ := http.NewRequest("PUT", u.String(), bytes.NewReader(body)) 99 | ct := "application/json; charset=UTF-8" 100 | c.sign(req, string(body), ct) 101 | resp, err := c.HTTP.Do(req) 102 | if err != nil { 103 | return err 104 | } 105 | defer func() { 106 | if err := resp.Body.Close(); err != nil { 107 | fmt.Fprintf(os.Stderr, "failed to close response body: %v\n", err) 108 | } 109 | }() 110 | if resp.StatusCode != http.StatusOK { 111 | body, _ := io.ReadAll(resp.Body) 112 | return fmt.Errorf("service error: %s", string(body)) 113 | } 114 | return nil 115 | } 116 | 117 | // Lock locks the given workspace. 118 | func (c *Client) Lock(id string) error { return c.lockUnlock(id, true) } 119 | 120 | // Unlock unlocks a previously locked workspace. 121 | func (c *Client) Unlock(id string) error { return c.lockUnlock(id, false) } 122 | 123 | // EnableDebug causes the client to print debug information to Stderr. 124 | func (c *Client) EnableDebug() { 125 | c.HTTP = goahttp.NewDebugDoer(c.HTTP) 126 | } 127 | 128 | // lockUnlock implements the Lock and Unlock calls. 129 | func (c *Client) lockUnlock(id string, lock bool) error { 130 | u := &url.URL{Scheme: Scheme, Host: Host, Path: fmt.Sprintf("/workspace/%s/lock", id)} 131 | name := "anon" 132 | if usr, err := user.Current(); err == nil { 133 | name = usr.Name 134 | if name == "" { 135 | name = usr.Username 136 | } 137 | } 138 | // the order matters :( 139 | u.RawQuery = "user=" + url.QueryEscape(name) + "&agent=" + url.QueryEscape(UserAgent) 140 | 141 | verb := "PUT" 142 | if !lock { 143 | verb = "DELETE" 144 | } 145 | req, _ := http.NewRequest(verb, u.String(), http.NoBody) 146 | c.sign(req, "", "") 147 | resp, err := c.HTTP.Do(req) 148 | if err != nil { 149 | return err 150 | } 151 | defer func() { 152 | if err := resp.Body.Close(); err != nil { 153 | fmt.Fprintf(os.Stderr, "failed to close response body: %v\n", err) 154 | } 155 | }() 156 | 157 | if resp.StatusCode != http.StatusOK { 158 | var res Response 159 | json.NewDecoder(resp.Body).Decode(&res) // nolint: errcheck 160 | err = fmt.Errorf("service error: %s", resp.Status) 161 | if res.Message != "" { 162 | err = errors.New(res.Message) 163 | } 164 | return err 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // sign signs the requests as per https://structurizr.com/help/web-api 171 | func (c *Client) sign(req *http.Request, content, ct string) { 172 | // 1. Compute digest 173 | var digest, nonce string 174 | var md5s string 175 | { 176 | h := md5.New() 177 | h.Write([]byte(content)) 178 | md5b := h.Sum(nil) 179 | md5s = hex.EncodeToString(md5b) 180 | nonce = strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) 181 | digest = fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n", req.Method, req.URL.RequestURI(), md5s, ct, nonce) 182 | } 183 | 184 | // 2. Compute HMAC 185 | var hm []byte 186 | { 187 | h := hmac.New(sha256.New, []byte(c.Secret)) 188 | h.Write([]byte(digest)) 189 | hm = h.Sum(nil) 190 | } 191 | 192 | // 3. Write headers 193 | auth := base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(hm))) 194 | req.Header.Set("X-Authorization", fmt.Sprintf("%s:%s", c.Key, auth)) 195 | req.Header.Set("Nonce", nonce) 196 | if req.Method == http.MethodPut { 197 | req.Header.Set("Content-Md5", base64.StdEncoding.EncodeToString([]byte(md5s))) 198 | req.Header.Set("Content-Type", ct) 199 | } 200 | req.Header.Set("User-Agent", UserAgent) 201 | } 202 | -------------------------------------------------------------------------------- /stz/configuration.go: -------------------------------------------------------------------------------- 1 | package stz 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "goa.design/model/mdl" 8 | ) 9 | 10 | type ( 11 | // Configuration encapsulate Structurizr service specific view configuration information. 12 | Configuration struct { 13 | // Styles associated with views. 14 | Styles *mdl.Styles `json:"styles,omitempty"` 15 | // Key of view that was saved most recently. 16 | LastSavedView string `json:"lastSavedView,omitempty"` 17 | // Key of view shown by default. 18 | DefaultView string `json:"defaultView,omitempty"` 19 | // URL(s) of theme(s) used when rendering diagram. 20 | Themes []string `json:"themes,omitempty"` 21 | // Branding used in views. 22 | Branding *Branding `json:"branding,omitempty"` 23 | // Terminology used in workspace. 24 | Terminology *Terminology `json:"terminology,omitempty"` 25 | // Type of symbols used when rendering metadata. 26 | MetadataSymbols SymbolKind `json:"metadataSymbols,omitempty"` 27 | } 28 | 29 | // Branding is a wrapper for font and logo for diagram/documentation 30 | // branding purposes. 31 | Branding struct { 32 | // URL of PNG/JPG/GIF file, or Base64 data URI representation. 33 | Logo string `json:"logo,omitempty"` 34 | // Font details. 35 | Font *Font `json:"font,omitempty"` 36 | } 37 | 38 | // Terminology used on diagrams. 39 | Terminology struct { 40 | // Terminology used when rendering enterprise boundaries. 41 | Enterprise string `json:"enterprise,omitempty"` 42 | // Terminology used when rendering people. 43 | Person string `json:"person,omitempty"` 44 | // Terminology used when rendering software systems. 45 | SoftwareSystem string `json:"softwareSystem,omitempty"` 46 | // Terminology used when rendering containers. 47 | Container string `json:"container,omitempty"` 48 | // Terminology used when rendering components. 49 | Component string `json:"component,omitempty"` 50 | // Terminology used when rendering code elements. 51 | Code string `json:"code,omitempty"` 52 | // Terminology used when rendering deployment nodes. 53 | DeploymentNode string `json:"deploymentNode,omitempty"` 54 | // Terminology used when rendering relationships. 55 | Relationship string `json:"relationship,omitempty"` 56 | } 57 | 58 | // Font details including name and optional URL for web fonts. 59 | Font struct { 60 | // Name of font. 61 | Name string `json:"name,omitempty"` 62 | // Web font URL. 63 | URL string `json:"url,omitempty"` 64 | } 65 | 66 | // SymbolKind is the enum used to represent symbols used to render metadata. 67 | SymbolKind int 68 | ) 69 | 70 | const ( 71 | SymbolUndefined SymbolKind = iota 72 | SymbolSquareBrackets 73 | SymbolRoundBrackets 74 | SymbolCurlyBrackets 75 | SymbolAngleBrackets 76 | SymbolDoubleAngleBrackets 77 | SymbolNone 78 | ) 79 | 80 | // MarshalJSON replaces the constant value with the proper string value. 81 | func (s SymbolKind) MarshalJSON() ([]byte, error) { 82 | buf := bytes.NewBufferString(`"`) 83 | switch s { 84 | case SymbolSquareBrackets: 85 | buf.WriteString("SquareBrackets") 86 | case SymbolRoundBrackets: 87 | buf.WriteString("RoundBrackets") 88 | case SymbolCurlyBrackets: 89 | buf.WriteString("CurlyBrackets") 90 | case SymbolAngleBrackets: 91 | buf.WriteString("AngleBrackets") 92 | case SymbolDoubleAngleBrackets: 93 | buf.WriteString("DoubleAngleBrackets") 94 | case SymbolNone: 95 | buf.WriteString("None") 96 | } 97 | buf.WriteString(`"`) 98 | return buf.Bytes(), nil 99 | } 100 | 101 | // UnmarshalJSON sets the constant from its JSON representation. 102 | func (s *SymbolKind) UnmarshalJSON(data []byte) error { 103 | var val string 104 | if err := json.Unmarshal(data, &val); err != nil { 105 | return err 106 | } 107 | switch val { 108 | case "SquareBrackets": 109 | *s = SymbolSquareBrackets 110 | case "RoundBrackets": 111 | *s = SymbolRoundBrackets 112 | case "CurlyBrackets": 113 | *s = SymbolCurlyBrackets 114 | case "AngleBrackets": 115 | *s = SymbolAngleBrackets 116 | case "DoubleAngleBrackets": 117 | *s = SymbolDoubleAngleBrackets 118 | case "None": 119 | *s = SymbolNone 120 | } 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /stz/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package stz complements the serializable model defined in mdl package 3 | with data specific to Structurizr service. 4 | 5 | The top level data structure is Workspace which replaces mdl.Design. 6 | 7 | The JSON representation of the workspace data structure is compatible with 8 | the Structurizr service API (https://structurizr.com). The package also 9 | includes a Structurize API client that can be used to upload and download 10 | workspaces to and from the service. 11 | */ 12 | package stz 13 | -------------------------------------------------------------------------------- /stz/eval.go: -------------------------------------------------------------------------------- 1 | package stz 2 | 3 | import ( 4 | "goa.design/goa/v3/eval" 5 | "goa.design/model/expr" 6 | "goa.design/model/mdl" 7 | ) 8 | 9 | // RunDSL runs the DSL defined in a global variable and returns the corresponding 10 | // Structurize workspace. 11 | func RunDSL() (*Workspace, error) { 12 | if err := eval.RunDSL(); err != nil { 13 | return nil, err 14 | } 15 | return WorkspaceFromDesign(expr.Root), nil 16 | } 17 | 18 | // WorkspaceFromDesign returns a Structurizr workspace initialized from the 19 | // given design. 20 | func WorkspaceFromDesign(d *expr.Design) *Workspace { 21 | design := mdl.ModelizeDesign(d) 22 | v := design.Views 23 | 24 | return &Workspace{ 25 | Name: d.Name, 26 | Description: d.Description, 27 | Version: d.Version, 28 | Model: design.Model, 29 | Views: &Views{ 30 | LandscapeViews: v.LandscapeViews, 31 | ContextViews: v.ContextViews, 32 | ContainerViews: v.ContainerViews, 33 | ComponentViews: v.ComponentViews, 34 | DynamicViews: v.DynamicViews, 35 | DeploymentViews: v.DeploymentViews, 36 | FilteredViews: v.FilteredViews, 37 | Configuration: &Configuration{Styles: v.Styles}, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /stz/views.go: -------------------------------------------------------------------------------- 1 | package stz 2 | 3 | import ( 4 | "goa.design/model/mdl" 5 | ) 6 | 7 | type ( 8 | // Views is the container for all views. 9 | Views struct { 10 | // LandscapeViewss describe the system landscape views. 11 | LandscapeViews []*mdl.LandscapeView `json:"systemLandscapeViews,omitempty"` 12 | // ContextViews lists the system context views. 13 | ContextViews []*mdl.ContextView `json:"systemContextViews,omitempty"` 14 | // ContainerViews lists the container views. 15 | ContainerViews []*mdl.ContainerView `json:"containerViews,omitempty"` 16 | // ComponentViews lists the component views. 17 | ComponentViews []*mdl.ComponentView `json:"componentViews,omitempty"` 18 | // DynamicViews lists the dynamic views. 19 | DynamicViews []*mdl.DynamicView `json:"dynamicViews,omitempty"` 20 | // DeploymentViews lists the deployment views. 21 | DeploymentViews []*mdl.DeploymentView `json:"deploymentViews,omitempty"` 22 | // FilteredViews lists the filtered views. 23 | FilteredViews []*mdl.FilteredView `json:"filteredViews,omitempty"` 24 | // Configuration contains view specific configuration information. 25 | Configuration *Configuration `json:"configuration,omitempty"` 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /stz/workspace.go: -------------------------------------------------------------------------------- 1 | package stz 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "goa.design/model/mdl" 8 | ) 9 | 10 | type ( 11 | // Workspace describes a Structurizr service workspace. 12 | Workspace struct { 13 | // ID of workspace. 14 | ID int `json:"id,omitempty"` 15 | // Name of workspace. 16 | Name string `json:"name"` 17 | // Description of workspace if any. 18 | Description string `json:"description,omitempty"` 19 | // Version number for the workspace. 20 | Version string `json:"version,omitempty"` 21 | // Revision number, automatically generated. 22 | Revision int `json:"revision,omitempty"` 23 | // Thumbnail associated with the workspace; a Base64 encoded PNG file as a 24 | // data URI (data:image/png;base64). 25 | Thumbnail string `json:"thumbnail,omitempty"` 26 | // The last modified date, in ISO 8601 format (e.g. "2018-09-08T12:40:03Z"). 27 | LastModifiedDate string `json:"lastModifiedDate,omitempty"` 28 | // A string identifying the user who last modified the workspace (e.g. an 29 | // e-mail address or username). 30 | LastModifiedUser string `json:"lastModifiedUser,omitempty"` 31 | // A string identifying the agent that was last used to modify the workspace 32 | // (e.g. "model-go/1.2.0"). 33 | LastModifiedAgent string `json:"lastModifiedAgent,omitempty"` 34 | // Model is the software architecture model. 35 | Model *mdl.Model `json:"model,omitempty"` 36 | // Views contains the views if any. 37 | Views *Views `json:"views,omitempty"` 38 | // Documentation associated with software architecture model. 39 | Documentation *Documentation `json:"documentation,omitempty"` 40 | // Configuration of workspace. 41 | Configuration *WorkspaceConfiguration `json:"configuration,omitempty"` 42 | } 43 | 44 | // WorkspaceConfiguration describes the workspace configuration. 45 | WorkspaceConfiguration struct { 46 | // Users that have access to the workspace. 47 | Users []*User `json:"users"` 48 | } 49 | 50 | // User of Structurizr service. 51 | User struct { 52 | Username string `json:"username"` 53 | // Role of user, one of "ReadWrite" or "ReadOnly". 54 | Role string `json:"role"` 55 | } 56 | 57 | // Documentation associated with software architecture model. 58 | Documentation struct { 59 | // Documentation sections. 60 | Sections []*DocumentationSection `json:"sections,omitempty"` 61 | // ADR decisions. 62 | Decisions []*Decision `json:"decisions,omitempty"` 63 | // Images used in documentation. 64 | Images []*Image `json:"images,omitempty"` 65 | // Information about template used to render documentation. 66 | Template *DocumentationTemplateMetadata `json:"template,omitempty"` 67 | } 68 | 69 | // DocumentationSection corresponds to a documentation section. 70 | DocumentationSection struct { 71 | // Title (name/section heading) of section. 72 | Title string `json:"title"` 73 | // Markdown or AsciiDoc content of section. 74 | Content string `json:"string"` 75 | // Content format. 76 | Format DocFormatKind `json:"format"` 77 | // Order (index) of section in document. 78 | Order int `json:"order"` 79 | // ID of element (in model) that section applies to (optional). 80 | ElementID string `json:"elementId,omitempty"` 81 | } 82 | 83 | // Decision record (e.g. architecture decision record). 84 | Decision struct { 85 | // ID of decision. 86 | ID string `json:"id"` 87 | // Date of decision in ISO 8601 format. 88 | Date string `json:"date"` 89 | // Status of decision. 90 | Decision DecisionStatusKind `json:"decision"` 91 | // Title of decision 92 | Title string `json:"title"` 93 | // Markdown or AsciiDoc content of decision. 94 | Content string `json:"content"` 95 | // Content format. 96 | Format DocFormatKind `json:"format"` 97 | // ID of element (in model) that decision applies to (optional). 98 | ElementID string `json:"elementId,omitempty"` 99 | } 100 | 101 | // Image represents a Base64 encoded image (PNG/JPG/GIF). 102 | Image struct { 103 | // Name of image. 104 | Name string `json:"image"` 105 | // Base64 encoded content. 106 | Content string `json:"content"` 107 | // Image MIME type (e.g. "image/png") 108 | Type string `json:"type"` 109 | } 110 | 111 | // DocumentationTemplateMetadata provides information about a documentation 112 | // template used to create documentation. 113 | DocumentationTemplateMetadata struct { 114 | // Name of documentation template. 115 | Name string `json:"name"` 116 | // Name of author of documentation template. 117 | Author string `json:"author,omitempty"` 118 | // URL that points to more information about template. 119 | URL string `json:"url,omitempty"` 120 | } 121 | 122 | // DocFormatKind is the enum used to represent documentation format. 123 | DocFormatKind int 124 | 125 | // DecisionStatusKind is the enum used to represent status of decision. 126 | DecisionStatusKind int 127 | ) 128 | 129 | const ( 130 | FormatUndefined DocFormatKind = iota 131 | FormatMarkdown 132 | FormatASCIIDoc 133 | ) 134 | 135 | const ( 136 | DecisionUndefined DecisionStatusKind = iota 137 | DecisionProposed 138 | DecisionAccepted 139 | DecisionSuperseded 140 | DecisionDeprecated 141 | DecisionRejected 142 | ) 143 | 144 | // MarshalJSON replaces the constant value with the proper string value. 145 | func (d DocFormatKind) MarshalJSON() ([]byte, error) { 146 | buf := bytes.NewBufferString(`"`) 147 | switch d { 148 | case FormatMarkdown: 149 | buf.WriteString("Markdown") 150 | case FormatASCIIDoc: 151 | buf.WriteString("AsciiDoc") 152 | } 153 | buf.WriteString(`"`) 154 | return buf.Bytes(), nil 155 | } 156 | 157 | // UnmarshalJSON sets the constant from its JSON representation. 158 | func (d *DocFormatKind) UnmarshalJSON(data []byte) error { 159 | var val string 160 | if err := json.Unmarshal(data, &val); err != nil { 161 | return err 162 | } 163 | switch val { 164 | case "Markdown": 165 | *d = FormatMarkdown 166 | case "AsciiDoc": 167 | *d = FormatASCIIDoc 168 | } 169 | return nil 170 | } 171 | 172 | // MarshalJSON replaces the constant value with the proper string value. 173 | func (d DecisionStatusKind) MarshalJSON() ([]byte, error) { 174 | buf := bytes.NewBufferString(`"`) 175 | switch d { 176 | case DecisionProposed: 177 | buf.WriteString("Proposed") 178 | case DecisionAccepted: 179 | buf.WriteString("Accepted") 180 | case DecisionSuperseded: 181 | buf.WriteString("Superseded") 182 | case DecisionDeprecated: 183 | buf.WriteString("Deprecated") 184 | case DecisionRejected: 185 | buf.WriteString("Rejected") 186 | } 187 | buf.WriteString(`"`) 188 | return buf.Bytes(), nil 189 | } 190 | 191 | // UnmarshalJSON sets the constant from its JSON representation. 192 | func (d *DecisionStatusKind) UnmarshalJSON(data []byte) error { 193 | var val string 194 | if err := json.Unmarshal(data, &val); err != nil { 195 | return err 196 | } 197 | switch val { 198 | case "Proposed": 199 | *d = DecisionProposed 200 | case "Accepted": 201 | *d = DecisionAccepted 202 | case "Superseded": 203 | *d = DecisionSuperseded 204 | case "Deprecated": 205 | *d = DecisionDeprecated 206 | case "Rejected": 207 | *d = DecisionRejected 208 | } 209 | return nil 210 | } 211 | --------------------------------------------------------------------------------