-
-
Save coldacid/465fa8f3a4cd3fdd7b640a65ad5b86f4 to your computer and use it in GitHub Desktop.
Structurizr .NET writer for C4-PlantUML library
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Linq; | |
using System.Text; | |
using Structurizr; | |
using Structurizr.IO; | |
namespace Northcloud.Structurizr.Writers | |
{ | |
class C4LibraryPlantUMLWriter : PlantUMLWriterBase | |
{ | |
public enum LayoutDirection | |
{ | |
TopDown, | |
LeftRight | |
} | |
public sealed class Tags | |
{ | |
public const string Database = "Database"; | |
public const string Rel_Back = "Back Relationship"; | |
public const string Rel_Neighbour = "Neighbour Relationship"; | |
public const string Rel_Back_Neighbour = "Back Neighbour Relationship"; | |
public const string Rel_Up = "Relationship Direction Up"; | |
public const string Rel_Down = "Relationship Direction Down"; | |
public const string Rel_Left = "Relationship Direction Left"; | |
public const string Rel_Right = "Relationship Direction Right"; | |
} | |
public bool LayoutWithLegend { get; set; } = true; | |
public bool LayoutAsSketch { get; set; } = false; | |
public LayoutDirection? Layout { get; set; } | |
public string BaseUrl { get; set; } = "https://raw.githubusercontent.com/RicardoNiepel/C4-PlantUML/release/1-0/"; | |
protected override void Write(SystemLandscapeView view, TextWriter writer) | |
{ | |
var showBoundary = view.EnterpriseBoundaryVisible ?? true; | |
WriteProlog(view, writer); | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is Person && ((Person)e).Location == Location.External) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, 0)); | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is SoftwareSystem && ((SoftwareSystem)e).Location == Location.External) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, 0)); | |
if (showBoundary) | |
{ | |
var enterpriseName = view.Model.Enterprise.Name; | |
writer.WriteLine($"Enterprise_Boundary({TokenizeName(enterpriseName)}, \"{enterpriseName}\") {{"); | |
} | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is Person && ((Person)e).Location == Location.Internal) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, showBoundary ? 1 : 0)); | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is SoftwareSystem && ((SoftwareSystem)e).Location == Location.Internal) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, showBoundary ? 1 : 0)); | |
if (showBoundary) | |
writer.WriteLine("}"); | |
Write(view.Relationships, writer); | |
WriteEpilog(view, writer); | |
} | |
protected override void Write(SystemContextView view, TextWriter writer) | |
{ | |
var showBoundary = view.EnterpriseBoundaryVisible ?? true; | |
WriteProlog(view, writer); | |
if (view.EnterpriseBoundaryVisible ?? false) | |
{ | |
var enterpriseName = view.Model.Enterprise.Name; | |
writer.WriteLine($"Enterprise_Boundary({TokenizeName(enterpriseName)}, \"{enterpriseName}\") {{"); | |
} | |
view.Elements | |
.Select(ev => ev.Element) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, showBoundary ? 1 : 0)); | |
Write(view.Relationships, writer); | |
if (showBoundary) | |
writer.WriteLine("}"); | |
WriteEpilog(view, writer); | |
} | |
protected override void Write(ContainerView view, TextWriter writer) | |
{ | |
var externals = view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => !(e is Container)); | |
var showBoundary = externals.Any(); | |
WriteProlog(view, writer); | |
externals | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, 0)); | |
if (showBoundary) | |
Write(view.SoftwareSystem, writer, 0, true); | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is Container) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, showBoundary ? 1 : 0)); | |
if (showBoundary) | |
writer.WriteLine("}"); | |
Write(view.Relationships, writer); | |
WriteEpilog(view, writer); | |
} | |
protected override void Write(ComponentView view, TextWriter writer) | |
{ | |
var nonComponents = view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => !(e is Component)); | |
var nonContainedComponents = | |
from ev in view.Elements | |
let e = ev.Element | |
where e is Component && e.Parent?.Id != view.Container.Id | |
group e by e.Parent; | |
var showBoundary = nonComponents.Any() || nonContainedComponents.Any(); | |
WriteProlog(view, writer); | |
nonComponents | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, 0)); | |
if (showBoundary) | |
Write(view.Container, writer, 0, true); | |
view.Elements | |
.Select(ev => ev.Element) | |
.Where(e => e is Component && e.Parent?.Id == view.Container.Id) | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, showBoundary ? 1 : 0)); | |
if (showBoundary) | |
writer.WriteLine("}"); | |
foreach (var container in nonContainedComponents) | |
{ | |
Write(container.Key, writer, 0, true); | |
container | |
.OrderBy(e => e.Name).ToList() | |
.ForEach(e => Write(e, writer, 1)); | |
writer.WriteLine("}"); | |
} | |
Write(view.Relationships, writer); | |
WriteEpilog(view, writer); | |
} | |
protected override void Write(DynamicView view, TextWriter writer) | |
{ | |
throw new NotImplementedException(); | |
} | |
protected override void WriteProlog(View view, TextWriter writer) | |
{ | |
writer.WriteLine("@startuml"); | |
switch (view) | |
{ | |
case SystemLandscapeView _: | |
case SystemContextView _: | |
writer.WriteLine($"!includeurl {BaseUrl}C4_Context.puml"); | |
break; | |
case ComponentView _: | |
case DynamicView _: | |
writer.WriteLine($"!includeurl {BaseUrl}C4_Component.puml"); | |
break; | |
default: | |
writer.WriteLine($"!includeurl {BaseUrl}C4_Container.puml"); | |
break; | |
} | |
writer.WriteLine($"\n' {view.GetType()}: {view.Key}"); | |
writer.WriteLine("title " + GetTitle(view)); | |
writer.WriteLine(); | |
if (LayoutWithLegend) | |
writer.WriteLine("LAYOUT_WITH_LEGEND"); | |
if (LayoutAsSketch) | |
writer.WriteLine("LAYOUT_AS_SKETCH"); | |
if (Layout.HasValue) | |
{ | |
switch (Layout) | |
{ | |
case LayoutDirection.LeftRight: | |
writer.WriteLine("LAYOUT_LEFT_RIGHT"); | |
break; | |
case LayoutDirection.TopDown: | |
writer.WriteLine("LAYOUT_TOP_DOWN"); | |
break; | |
default: | |
throw new InvalidOperationException($"Unknown {nameof(LayoutDirection)} value"); | |
} | |
} | |
if (LayoutWithLegend || LayoutAsSketch || Layout.HasValue) | |
writer.WriteLine(); | |
} | |
protected virtual void Write(Element element, TextWriter writer, int indentLevel = 0, bool asBoundary = false) | |
{ | |
var indent = indentLevel == 0 ? "" : new String(' ', indentLevel * 2); | |
string | |
macro, | |
alias = TokenizeName(element), | |
title = element.Name; | |
if (asBoundary) | |
{ | |
switch (element) | |
{ | |
case SoftwareSystem _: | |
macro = "System_Boundary"; | |
break; | |
case Container _: | |
macro = "Container_Boundary"; | |
break; | |
default: | |
throw new NotSupportedException($"{element.GetType()} not supported boundary type"); | |
} | |
writer.WriteLine($"{indent}{macro}({alias}, \"{title}\") {{"); | |
return; | |
} | |
string | |
description = element.Description, | |
technology = null; | |
bool external = false; | |
var tags = element.GetTags(); | |
if (element is Person p) | |
{ | |
macro = "Person"; | |
external = p.Location == Location.External; | |
} | |
else | |
{ | |
switch (element) | |
{ | |
case SoftwareSystem sys: | |
macro = "System"; | |
external = sys.Location == Location.External; | |
break; | |
case Container cnt: | |
macro = "Container"; | |
technology = cnt.Technology ?? ""; | |
break; | |
case Component cmp: | |
macro = "Component"; | |
technology = cmp.Technology ?? ""; | |
break; | |
default: | |
throw new NotSupportedException($"Unsupported element type {element.GetType()}"); | |
} | |
if (element.Tags.Contains(Tags.Database)) | |
macro += "Db"; | |
} | |
if (external) | |
macro += "_Ext"; | |
writer.Write($"{indent}{macro}({alias}, \"{title}\""); | |
if (technology != null) // specifically null, empty or whitespace should be handled in this block | |
{ | |
writer.Write($", \"{EscapeText(technology)}\""); | |
} | |
if (!String.IsNullOrWhiteSpace(description)) | |
{ | |
writer.Write($", \"{EscapeText(description)}\""); | |
} | |
writer.WriteLine(")"); | |
} | |
protected virtual void Write(HashSet<RelationshipView> relationships, TextWriter writer) | |
{ | |
relationships | |
.Select(rv => rv.Relationship) | |
.OrderBy(r => r.Source.Name + r.Destination.Name).ToList() | |
.ForEach(r => Write(r, writer)); | |
} | |
protected virtual void Write(Relationship relationship, TextWriter writer) | |
{ | |
string | |
macro = null, | |
source = TokenizeName(relationship.Source), | |
dest = TokenizeName(relationship.Destination), | |
label = relationship.Description ?? "", | |
tech = !String.IsNullOrWhiteSpace(relationship.Technology) ? relationship.Technology : null; | |
foreach (var tag in relationship.GetTags()) | |
{ | |
switch (tag) | |
{ | |
case Tags.Rel_Back: | |
macro = "Rel_Back"; | |
break; | |
case Tags.Rel_Neighbour: | |
macro = "Rel_Neighbour"; | |
break; | |
case Tags.Rel_Back_Neighbour: | |
macro = "Rel_Back_Neighbour"; | |
break; | |
case Tags.Rel_Up: | |
macro = "Rel_Up"; | |
break; | |
case Tags.Rel_Down: | |
macro = "Rel_Down"; | |
break; | |
case Tags.Rel_Left: | |
macro = "Rel_Left"; | |
break; | |
case Tags.Rel_Right: | |
macro = "Rel_Right"; | |
break; | |
} | |
if (macro != null) | |
break; | |
} | |
macro = macro ?? "Rel"; | |
writer.Write($"{macro}({source}, {dest}, \"{EscapeText(label)}\""); | |
if (tech != null) | |
writer.Write($", \"{EscapeText(tech)}\""); | |
writer.WriteLine(")"); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.IO; | |
using Structurizr; | |
namespace Northcloud.Structurizr.Writers | |
{ | |
interface IPlantUMLWriter | |
{ | |
void Write(Workspace workspace, TextWriter writer); | |
void Write(View view, TextWriter writer); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.IO; | |
using System.Linq; | |
using Structurizr; | |
namespace Northcloud.Structurizr.Writers | |
{ | |
/// <summary> | |
/// Provides a starting point and helper methods for <see cref="IPlantUMLWriter"/> implementations. | |
/// </summary> | |
abstract class PlantUMLWriterBase : IPlantUMLWriter | |
{ | |
/// <inheritdoc/> | |
public void Write(Workspace workspace, TextWriter writer) | |
{ | |
if (workspace == null) throw new ArgumentNullException(nameof(workspace)); | |
if (writer == null) throw new ArgumentNullException(nameof(writer)); | |
workspace.Views.SystemLandscapeViews.ToList().ForEach(v => Write(v, writer)); | |
workspace.Views.SystemContextViews.ToList().ForEach(v => Write(v, writer)); | |
workspace.Views.ContainerViews.ToList().ForEach(v => Write(v, writer)); | |
workspace.Views.ComponentViews.ToList().ForEach(v => Write(v, writer)); | |
workspace.Views.DynamicViews.ToList().ForEach(v => Write(v, writer)); | |
} | |
/// <inheritdoc/> | |
public void Write(View view, TextWriter writer) | |
{ | |
if (view == null) throw new ArgumentNullException(nameof(view)); | |
if (writer == null) throw new ArgumentNullException(nameof(writer)); | |
switch (view) | |
{ | |
case SystemLandscapeView sl: | |
Write(sl, writer); | |
break; | |
case SystemContextView sc: | |
Write(sc, writer); | |
break; | |
case ContainerView ct: | |
Write(ct, writer); | |
break; | |
case ComponentView cp: | |
Write(cp, writer); | |
break; | |
case DynamicView dn: | |
Write(dn, writer); | |
break; | |
default: | |
throw new NotSupportedException($"{view.GetType()} not supported for export"); | |
} | |
} | |
/// <summary> | |
/// Writes a system landscape view in PlantUML format to the provided writer. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
protected abstract void Write(SystemLandscapeView view, TextWriter writer); | |
/// <summary> | |
/// Writes a system context view in PlantUML format to the provided writer. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
protected abstract void Write(SystemContextView view, TextWriter writer); | |
/// <summary> | |
/// Writes a container view in PlantUML format to the provided writer. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
protected abstract void Write(ContainerView view, TextWriter writer); | |
/// <summary> | |
/// Writes a component view in PlantUML format to the provided writer. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
protected abstract void Write(ComponentView view, TextWriter writer); | |
/// <summary> | |
/// Writes a dynamic view in PlantUML format to the provided writer. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
protected abstract void Write(DynamicView view, TextWriter writer); | |
/// <summary> | |
/// Produces a standard PlantUML diagram prolog for the provided view. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
/// <exception cref="ArgumentNullException"><paramref name="view"/> is <see langword="null"/>.</exception> | |
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is <see langword="null"/>.</exception> | |
protected virtual void WriteProlog(View view, TextWriter writer) | |
{ | |
if (view == null) throw new ArgumentNullException(nameof(view)); | |
if (writer == null) throw new ArgumentNullException(nameof(writer)); | |
writer.WriteLine("@startuml"); | |
writer.WriteLine("' key: " + view.Key); | |
writer.WriteLine("title " + GetTitle(view)); | |
} | |
/// <summary> | |
/// Produces a standard PlantUML diagram epilog for the provided view. | |
/// </summary> | |
/// <param name="view"></param> | |
/// <param name="writer"></param> | |
/// <exception cref="ArgumentNullException"><paramref name="view"/> is <see langword="null"/>.</exception> | |
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is <see langword="null"/>.</exception> | |
protected virtual void WriteEpilog(View view, TextWriter writer) | |
{ | |
if (view == null) throw new ArgumentNullException(nameof(view)); | |
if (writer == null) throw new ArgumentNullException(nameof(writer)); | |
writer.WriteLine("@enduml"); | |
writer.WriteLine(""); | |
} | |
/// <summary> | |
/// Creates a tokenized string from the provided <see cref="Element"/> for use as a PlantUML object name. | |
/// </summary> | |
/// <param name="e">The element whose name will be tokenized.</param> | |
/// <returns>A string that may be used as a name or ID token for the provided element.</returns> | |
/// <exception cref="ArgumentNullException"><paramref name="e"> is <see langword="null"/>.</exception> | |
protected string TokenizeName(Element e) => | |
e != null | |
? TokenizeName(e.CanonicalName, e.GetHashCode()) | |
: throw new ArgumentNullException(nameof(e)); | |
/// <summary> | |
/// Creates a tokenized variant of the provided string, with optional hash code appended. | |
/// </summary> | |
/// <param name="s">The string to tokenize.</param> | |
/// <param name="hash">An optional hash code to append to the tokenized string.</param> | |
/// <returns> | |
/// The tokenized variant of the string and optional hash code, or the empty string if <paramref name="s"/> is | |
/// <see langword="null"/> or empty. | |
/// </returns> | |
protected string TokenizeName(string s, int? hash = null) | |
{ | |
if (String.IsNullOrWhiteSpace(s)) return ""; | |
s = s | |
.Trim('/') | |
.Replace(" ", "") | |
.Replace("-", "") | |
.Replace("/", "__"); | |
if (hash.HasValue) | |
{ | |
s = s + "__" + hash.Value.ToString("x"); | |
} | |
return s; | |
} | |
/// <summary> | |
/// Gets the title to use for the provided view. | |
/// </summary> | |
/// <param name="view">The view whose title or name to retrieve.</param> | |
/// <returns>The title of the view, if it was set; otherwise the name of the view.</returns> | |
/// <exception cref="ArgumentNullException"><paramref name="view"/> is <see langword="null"/>.</exception> | |
protected virtual string GetTitle(View view) => | |
view != null | |
? String.IsNullOrWhiteSpace(view.Title) ? view.Name : view.Title | |
: throw new ArgumentNullException(nameof(view)); | |
protected string EscapeText(string s) => s.Replace("\"", """); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment