diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7c877035..661b8b03 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -43,7 +43,7 @@ To be able to develop and run diagrams locally on you Mac device, you should hav 1. Go to diagrams root directory. -2. Install poetry, the Python project management packge used by diagrams. +2. Install poetry, the Python project management package used by diagrams. ```shell pip install poetry diff --git a/README.md b/README.md index 0633255b..f958ab2e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Diagrams lets you draw the cloud system architecture **in Python code**. It was ![generic provider](https://img.shields.io/badge/Generic-orange?color=5f87bf) ![programming provider](https://img.shields.io/badge/Programming-orange?color=5f87bf) ![saas provider](https://img.shields.io/badge/SaaS-orange?color=5f87bf) +![c4 provider](https://img.shields.io/badge/C4-orange?color=5f87bf) ## Getting Started diff --git a/config.py b/config.py index f527ab6e..cbec9c9d 100644 --- a/config.py +++ b/config.py @@ -411,7 +411,8 @@ ALIASES = { }, "programming": { "framework": { - "Fastapi": "FastAPI" + "Fastapi": "FastAPI", + "Graphql": "GraphQL" }, "language": { "Javascript": "JavaScript", diff --git a/diagrams/c4/__init__.py b/diagrams/c4/__init__.py new file mode 100644 index 00000000..40577c8c --- /dev/null +++ b/diagrams/c4/__init__.py @@ -0,0 +1,97 @@ +""" +A set of nodes and edges to visualize software architecture using the C4 model. +""" +import html +import textwrap +from diagrams import Cluster, Node, Edge + + +def _format_node_label(name, key, description): + """Create a graphviz label string for a C4 node""" + title = f'{html.escape(name)}
' + subtitle = f'[{html.escape(key)}]
' if key else "" + text = f'
{_format_description(description)}' if description else "" + return f"<{title}{subtitle}{text}>" + + +def _format_description(description): + """ + Formats the description string so it fits into the C4 nodes. + + It line-breaks the description so it fits onto exactly three lines. If there are more + than three lines, all further lines are discarded and "..." inserted on the last line to + indicate that it was shortened. This will also html-escape the description so it can + safely be included in a HTML label. + """ + wrapper = textwrap.TextWrapper(width=40, max_lines=3) + lines = [html.escape(line) for line in wrapper.wrap(description)] + lines += [""] * (3 - len(lines)) # fill up with empty lines so it is always three + return "
".join(lines) + + +def _format_edge_label(description): + """Create a graphviz label string for a C4 edge""" + wrapper = textwrap.TextWrapper(width=24, max_lines=3) + lines = [html.escape(line) for line in wrapper.wrap(description)] + text = "
".join(lines) + return f'<{text}>' + + +def C4Node(name, technology="", description="", type="Container", **kwargs): + key = f"{type}: {technology}" if technology else type + node_attributes = { + "label": _format_node_label(name, key, description), + "labelloc": "c", + "shape": "rect", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "filled", + "fillcolor": "dodgerblue3", + "fontcolor": "white", + } + # collapse boxes to a smaller form if they don't have a description + if not description: + node_attributes.update({"width": "2", "height": "1"}) + node_attributes.update(kwargs) + return Node(**node_attributes) + + +def Container(name, technology="", description="", **kwargs): + return C4Node(name, technology=technology, description=description, type="Container") + + +def Database(name, technology="", description="", **kwargs): + return C4Node(name, technology=technology, description=description, type="Database", shape="cylinder", labelloc="b") + + +def System(name, description="", external=False, **kwargs): + type = "External System" if external else "System" + fillcolor = "gray60" if external else "dodgerblue4" + return C4Node(name, description=description, type=type, fillcolor=fillcolor) + + +def Person(name, description="", external=False, **kwargs): + type = "External Person" if external else "Person" + fillcolor = "gray60" if external else "dodgerblue4" + style = "rounded,filled" + return C4Node(name, description=description, type=type, fillcolor=fillcolor, style=style) + + +def SystemBoundary(name, **kwargs): + graph_attributes = { + "label": html.escape(name), + "bgcolor": "white", + "margin": "16", + "style": "dashed", + } + graph_attributes.update(kwargs) + return Cluster(name, graph_attr=graph_attributes) + + +def Relationship(label="", **kwargs): + edge_attribtues = {"style": "dashed", "color": "gray60"} + if label: + edge_attribtues.update({"label": _format_edge_label(label)}) + edge_attribtues.update(kwargs) + return Edge(**edge_attribtues) diff --git a/diagrams/programming/framework.py b/diagrams/programming/framework.py index 64fd6397..e4ba3b68 100644 --- a/diagrams/programming/framework.py +++ b/diagrams/programming/framework.py @@ -36,6 +36,10 @@ class Flutter(_Framework): _icon = "flutter.png" +class Graphql(_Framework): + _icon = "graphql.png" + + class Laravel(_Framework): _icon = "laravel.png" @@ -67,3 +71,4 @@ class Vue(_Framework): # Aliases FastAPI = Fastapi +GraphQL = Graphql diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index 8aed1225..8ec78b36 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -6,7 +6,7 @@ RUN apk update && apk add --no-cache \ gcc libc-dev g++ graphviz git bash go imagemagick inkscape ttf-opensans curl fontconfig xdg-utils # install go package. -RUN go get github.com/mingrammer/round +RUN go install github.com/mingrammer/round@latest # install fonts RUN curl -O https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip \ diff --git a/docs/nodes/c4.md b/docs/nodes/c4.md new file mode 100644 index 00000000..9c21c2c8 --- /dev/null +++ b/docs/nodes/c4.md @@ -0,0 +1,77 @@ +--- +id: c4 +title: C4 +--- + +## C4 Diagrams + +[C4](https://c4model.com/) is a standardized model to visualize software architecture. +You can generate C4 diagrams by using the node and edge classes from the `diagrams.c4` package: + +```python +from diagrams import Diagram +from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Relationship + +graph_attr = { + "splines": "spline", +} + +with Diagram("Container diagram for Internet Banking System", direction="TB", graph_attr=graph_attr): + customer = Person( + name="Personal Banking Customer", description="A customer of the bank, with personal bank accounts." + ) + + with SystemBoundary("Internet Banking System"): + webapp = Container( + name="Web Application", + technology="Java and Spring MVC", + description="Delivers the static content and the Internet banking single page application.", + ) + + spa = Container( + name="Single-Page Application", + technology="Javascript and Angular", + description="Provides all of the Internet banking functionality to customers via their web browser.", + ) + + mobileapp = Container( + name="Mobile App", + technology="Xamarin", + description="Provides a limited subset of the Internet banking functionality to customers via their mobile device.", + ) + + api = Container( + name="API Application", + technology="Java and Spring MVC", + description="Provides Internet banking functionality via a JSON/HTTPS API.", + ) + + database = Database( + name="Database", + technology="Oracle Database Schema", + description="Stores user registration information, hashed authentication credentials, access logs, etc.", + ) + + email = System(name="E-mail System", description="The internal Microsoft Exchange e-mail system.", external=True) + + mainframe = System( + name="Mainframe Banking System", + description="Stores all of the core banking information about customers, accounts, transactions, etc.", + external=True, + ) + + customer >> Relationship("Visits bigbank.com/ib using [HTTPS]") >> webapp + customer >> Relationship("Views account balances, and makes payments using") >> [spa, mobileapp] + webapp >> Relationship("Delivers to the customer's web browser") >> spa + spa >> Relationship("Make API calls to [JSON/HTTPS]") >> api + mobileapp >> Relationship("Make API calls to [JSON/HTTPS]") >> api + + api >> Relationship("reads from and writes to") >> database + api >> Relationship("Sends email using [SMTP]") >> email + api >> Relationship("Makes API calls to [XML/HTTPS]") >> mainframe + customer << Relationship("Sends e-mails to") << email +``` + +It will produce the following diagram: + +![c4](/img/c4.png) diff --git a/docs/nodes/onprem.md b/docs/nodes/onprem.md index 3c9528e2..03c6aff7 100644 --- a/docs/nodes/onprem.md +++ b/docs/nodes/onprem.md @@ -161,8 +161,8 @@ Node classes list of onprem provider. - **diagrams.onprem.monitoring.Dynatrace** - **diagrams.onprem.monitoring.Grafana** - **diagrams.onprem.monitoring.Humio** -- **diagrams.onprem.monitoring.Newrelic** - **diagrams.onprem.monitoring.Nagios** +- **diagrams.onprem.monitoring.Newrelic** - **diagrams.onprem.monitoring.PrometheusOperator** - **diagrams.onprem.monitoring.Prometheus** - **diagrams.onprem.monitoring.Sentry** diff --git a/docs/nodes/programming.md b/docs/nodes/programming.md index 3bfb9076..df813899 100644 --- a/docs/nodes/programming.md +++ b/docs/nodes/programming.md @@ -41,6 +41,7 @@ Node classes list of programming provider. - **diagrams.programming.framework.Fastapi**, **FastAPI** (alias) - **diagrams.programming.framework.Flask** - **diagrams.programming.framework.Flutter** +- **diagrams.programming.framework.Graphql**, **GraphQL** (alias) - **diagrams.programming.framework.Laravel** - **diagrams.programming.framework.Micronaut** - **diagrams.programming.framework.Rails** diff --git a/resources/programming/framework/graphql.png b/resources/programming/framework/graphql.png new file mode 100644 index 00000000..fe9e52d5 Binary files /dev/null and b/resources/programming/framework/graphql.png differ diff --git a/tests/test_c4.py b/tests/test_c4.py new file mode 100644 index 00000000..25c85455 --- /dev/null +++ b/tests/test_c4.py @@ -0,0 +1,64 @@ +import os +import random +import string +import unittest + +from diagrams import Diagram +from diagrams import setcluster, setdiagram +from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Relationship + + +class C4Test(unittest.TestCase): + def setUp(self): + self.name = "diagram-" + "".join([random.choice(string.hexdigits) for n in range(7)]) + + def tearDown(self): + setdiagram(None) + setcluster(None) + try: + os.remove(self.name + ".png") + except FileNotFoundError: + pass + + def test_nodes(self): + with Diagram(name=self.name, show=False): + person = Person("person", "A person.") + container = Container("container", "Java application", "The application.") + database = Database("database", "Oracle database", "Stores information.") + + def test_external_nodes(self): + with Diagram(name=self.name, show=False): + external_person = Person("person", external=True) + external_system = System("external", external=True) + + def test_systems(self): + with Diagram(name=self.name, show=False): + system = System("system", "The internal system.") + system_without_description = System("unknown") + + def test_edges(self): + with Diagram(name=self.name, show=False): + c1 = Container("container1") + c2 = Container("container2") + + c1 >> c2 + + def test_edges_with_labels(self): + with Diagram(name=self.name, show=False): + c1 = Container("container1") + c2 = Container("container2") + + c1 >> Relationship("depends on") >> c2 + c1 << Relationship("is depended on by") << c2 + + def test_edge_without_constraint(self): + with Diagram(name=self.name, show=False): + s1 = System("system 1") + s2 = System("system 2") + + s1 >> Relationship(constraint="False") >> s2 + + def test_cluster(self): + with Diagram(name=self.name, show=False): + with SystemBoundary("System"): + Container("container", "type", "description") diff --git a/website/static/img/c4.png b/website/static/img/c4.png new file mode 100644 index 00000000..e3ea5cc0 Binary files /dev/null and b/website/static/img/c4.png differ