Skip to content

Instantly share code, notes, and snippets.

@coldacid
Created April 2, 2019 11:54
Show Gist options
  • Save coldacid/465fa8f3a4cd3fdd7b640a65ad5b86f4 to your computer and use it in GitHub Desktop.
Save coldacid/465fa8f3a4cd3fdd7b640a65ad5b86f4 to your computer and use it in GitHub Desktop.
Structurizr .NET writer for C4-PlantUML library
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(")");
}
}
}
using System.IO;
using Structurizr;
namespace Northcloud.Structurizr.Writers
{
interface IPlantUMLWriter
{
void Write(Workspace workspace, TextWriter writer);
void Write(View view, TextWriter writer);
}
}
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("\"", "&quot;");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment