Skip to content

Instantly share code, notes, and snippets.

@nrktkt
Last active August 13, 2019 07:28
Show Gist options
  • Save nrktkt/55e345e08b16f9f01c920866716988cb to your computer and use it in GitHub Desktop.
Save nrktkt/55e345e08b16f9f01c920866716988cb to your computer and use it in GitHub Desktop.
Dynamic diagram generation with c4 and plantUML

Usage

  1. Create an ammonite script that models the system like the sample
  2. Call finalRender with expandOnly and focus control which parts of the system you want to detail
  3. Run the script (requires ammonite) and render the output with plantUML

Example output svg

#!/usr/bin/env amm
// implements the example diagrams from https://c4model.com/#coreDiagrams
import $file.c4
import c4._
import scala.language.implicitConversions
implicit def autoSome[A](a: A) = Some(a)
val mainframe = AbstractSystem(
"mainframe", "Mainframe Banking System",
"Stores all of the core banking information about customers, accounts, transactions, etc",
ext = true
)
val emailSystem = AbstractSystem(
"emailSystem", "E-mail System",
"The internal Microsoft Exchange e-mail system",
Set(Relation(_customer, "Sends e-mails to")),
ext = true
)
object InternetBanking {
val db = AbstractContainer(
"db", "Database", "Relational Database Schema",
"Stores user registration information, hashed authentication credentials, access logs, etc",
db = true
)
object ApiApp {
val springMVC = "Spring MVC Rest Controller"
val bean = "Spring Bean"
val signIn = Component(
"signIn", "Sign In Controller", springMVC,
"Allows users to sign in to the internet banking system",
Set(Relation(security))
)
val resetPassword = Component(
"resetPassword", "Reset Password Controller", springMVC,
"Allows users to reset their passwords with a single use URL",
Set(Relation(security), Relation(email))
)
val accountSummary = Component(
"accountSummary", "Accounts Summary Controller", springMVC,
"Provides customers with a summary of their bank accounts",
Set(Relation(bankingFacade))
)
lazy val security = Component(
"security", "Security Component", bean,
"Provides functionality related to signing in, changing passwords, etc",
Set(Relation(db, "Reads from and writes to", "JDBC"))
)
lazy val email = Component(
"email", "E-mail Component", bean,
"Sends e-mails to users",
Set(Relation(emailSystem, "Sends e-mail using", "SMTP"))
)
lazy val bankingFacade = Component(
"bankingFacade", "Mainframe Banking System Facade", bean,
"A facade onto the mainframe banking system",
Set(Relation(mainframe, "Uses", "XML/HTTPS"))
)
val container = DetailedContainer(
"apiApp", "API Application", "Java and Spring MVC",
"Provides internet banking functionality via a JSON/HTTPS API",
Seq(signIn, resetPassword, accountSummary, security, email, bankingFacade)
)
}
val apiCall = "Makes API calls to"
val spa = AbstractContainer(
"spa", "Single-Page Application", "JavaScript and Angular",
"Provides all of the internet banking functionality to customers via their web browser",
Set(
Relation(ApiApp.signIn, apiCall, "JSON/HTTPS"),
Relation(ApiApp.resetPassword, apiCall, "JSON/HTTPS"),
Relation(ApiApp.accountSummary, apiCall, "JSON/HTTPS"),
)
)
val web = AbstractContainer(
"web", "Web Application", "Java and Spring MVC",
"Delivers the static content and the internet banking single page application",
Set(Relation(spa, "Delivers to the customer's web browser"))
)
val mobile = AbstractContainer(
"mobile", "Mobile App", "Xamarin",
"Provides a limited subset of internet banking functionality to customers via their mobile device",
Set(
Relation(ApiApp.signIn, apiCall, "JSON/HTTPS"),
Relation(ApiApp.resetPassword, apiCall, "JSON/HTTPS"),
Relation(ApiApp.accountSummary, apiCall, "JSON/HTTPS"),
)
)
val system = DetailedSystem(
"ibs", "Internet Banking System",
"Allows customers to view information about ther bank accounts, and make payments",
Seq(web, spa, mobile, db, ApiApp.container)
)
}
lazy val _customer = Person(
"customer", "Personal Banking Customer",
"A customer of the bank, with personal bank accounts"
)
lazy val customer = _customer.copy(relations = Set(
Relation(InternetBanking.web, "Visits bigbank.com/ib using", "HTTPS", detail = true),
Relation(InternetBanking.spa, "Views account balances, and makes payments using"),
Relation(InternetBanking.mobile, "Views account balances, and makes payments using")
))
val world: Set[Block] = Set(customer, InternetBanking.system, emailSystem, mainframe)
println(finalRender("System Context Diagram for Internet Banking System", world.map(_.`abstract`)))
println(finalRender("Container Diagram for Internet Banking System", expandOnly(world, "ibs")))
println(finalRender("Component Diagram for Internet Banking System", focus(world, InternetBanking.ApiApp.container)))
/*
example usage:
val a = Component("componentA", "A", "react.js")
val b = Component("componentB", "B", "web browser", relations = Set(Relation(a))
finalRender("my diagram", Set(a, b))
*/
case class Param(value: String, quote: Boolean) {
override def toString = if(quote) '"' + value + '"' else value
}
trait Renderable {
protected def params: Seq[Param]
protected def macroName: String
def render = s"$macroName(${params.mkString(", ")})"
}
trait RenderableBoundary extends Renderable {
def children: Seq[Renderable]
override def render = {
val indentedContent = children.map(_.render).mkString("\n").split('\n').toSeq.map('\t' + _).mkString("\n")
s"""
|${macroName}_Boundary(${params.mkString(", ")}) {
|$indentedContent
|}""".stripMargin
}
}
trait Abstract extends Block {
def relations: Set[Relation]
def withRelations(newRels: Set[Relation]): Abstract
def `abstract` = this
def aliases: Set[String]
def finalRelations = relations.map(name -> _)
}
trait Detailed[C <: Block] extends Block with RenderableBoundary {
def children: Seq[C]
def withChildren(newKids: C*): Detailed[C]
override val params = Seq(Param(name, false), Param(label, true))
def finalRelations = children.flatMap(_.finalRelations).toSet
}
case class Relation(to: Block with Abstract, desc: String = " ", tech: Option[String] = None, detail: Boolean = false) {
def render(from: String) = {
val descStr = '"' + desc + '"'
val techStr = tech.map('"' + _ + '"')
val params = Seq(from, to.name, descStr) ++ techStr
s"Rel(${params.mkString(", ")})"
}
}
sealed trait Block extends Renderable {
def name: String
def label: String
def description: Option[String]
def `abstract`: Block with Abstract
def params = Seq(Param(name, quote = false), Param(label, quote = true)) ++ description.map(Param(_, quote = true))
def finalRelations: Set[(String, Relation)]
}
sealed trait System extends Block {
def macroName = "System"
}
case class AbstractSystem(name: String, label: String, description: Option[String] = None, relations: Set[Relation] = Set.empty[Relation], aliases: Set[String] = Set.empty[String], ext: Boolean = false, db: Boolean = false) extends System with Abstract {
override val macroName = super.macroName + (if(db) "Db" else "") + (if(ext) "_Ext" else "")
def withRelations(newRels: Set[Relation]) = copy(relations = newRels)
}
case class DetailedSystem(name: String, label: String, description: Option[String] = None, children: Seq[Container] = Nil) extends System with Detailed[Container] {
val `abstract` = AbstractSystem(name, label, description, relations = children.flatMap(_.`abstract`.relations).toSet, aliases = (children.flatMap(_.`abstract`.aliases) ++ children.map(_.name)).toSet)
def withChildren(newKids: Container*) = copy(children = newKids)
}
sealed trait Container extends Block {
def macroName = "Container"
def tech: String
override val params = Seq(Param(name, false), Param(label, true), Param(tech, true)) ++ description.map(Param(_, true))
}
case class AbstractContainer(name: String, label: String, tech: String, description: Option[String] = None, relations: Set[Relation] = Set.empty[Relation], aliases: Set[String] = Set.empty[String], db: Boolean = false) extends Container with Abstract {
override val macroName = super.macroName + (if(db) "Db" else "")
def withRelations(newRels: Set[Relation]) = copy(relations = newRels)
}
case class DetailedContainer(name: String, label: String, tech: String, description: Option[String] = None, children: Seq[Component] = Nil) extends Container with Detailed[Component] {
val `abstract` = AbstractContainer(name, label, tech, description, relations = children.flatMap(_.`abstract`.relations).toSet, aliases = (children.flatMap(_.`abstract`.aliases) ++ children.map(_.name)).toSet)
def withChildren(newKids: Component*) = copy(children = newKids)
}
case class Component(name: String, label: String, tech: String, description: Option[String] = None, relations: Set[Relation] = Set.empty[Relation], db: Boolean = false) extends Block with Abstract {
val macroName = "Component" + (if(db) "Db" else "")
val aliases = Set.empty[String]
def withRelations(newRels: Set[Relation]) = copy(relations = newRels)
override val params = Seq(Param(name, false), Param(label, true), Param(tech, true)) ++ description.map(Param(_, true))
}
case class Person(name: String, label: String, description: Option[String] = None, relations: Set[Relation] = Set.empty[Relation], ext: Boolean = false) extends Block with Abstract {
val macroName = "Person" + (if(ext) "_Ext" else "")
val aliases = Set.empty[String]
def withRelations(newRels: Set[Relation]) = copy(relations = newRels)
}
def finalRender(diagramName: String, blocks: Set[Block]): String = {
val relations = blocks.flatMap(_.finalRelations).filterNot{ case (_, rel) => rel.detail }
def findAliases(block: Block): Set[(String, Block with Abstract)] = block match {
case abst: Block with Abstract => abst.aliases.map(_ -> abst)
case detailed: Detailed[Block] => detailed.children.flatMap(findAliases).toSet
}
val aliases = blocks.flatMap(findAliases).toMap
val relationString = relations
.map { case (from, rel) => from -> aliases.get(rel.to.name).map(newTo => rel.copy(to = newTo)).getOrElse(rel) }
.filterNot { case (from, rel) => from == rel.to.name }
.map { case (from, rel) => rel.render(from) }
.mkString("\n")
s"""
|@startuml $diagramName
|
|!includeurl https://raw.githubusercontent.com/RicardoNiepel/C4-PlantUML/master/C4_Component.puml
|
|' auto generated plantuml for c4
""".stripMargin +
blocks.map(_.render).mkString("\n") + "\n" + relationString +
"\n@enduml"
}
def expandOnly(blocks: Set[Block], targets: String*): Set[Block] = {
val multiMap = targets.map { s =>
val arr = s.split('.')
arr.head -> arr.tail
}
//val keys = multiMap.map(_._1).toSet
blocks
.map {
case detailed: Detailed[Block] if detailed.`abstract`.aliases.exists(targets.contains) || targets.contains(detailed.name) =>
//val targs = multiMap.filter(_._1 == detailed.name).map(_._2.mkString("."))
detailed.withChildren(expandOnly(detailed.children.toSet, targets:_*).toSeq:_*)
case block => block.`abstract`
}
}
def focus(blocks: Set[Block], targets: Block*): Set[Block] = {
val targetAliases = targets.flatMap(t => t.`abstract`.aliases + t.name).toSet
val targetRelations = targets.flatMap(_.finalRelations).map(_._2.to.name).toSet
blocks.flatMap {
// a target
case b if targets.contains(b) => Some(b)
// b has a child which is a target
case b: Detailed[Block] if b.`abstract`.aliases.exists(targets.map(_.name).contains) => focus(b.children.toSet, targets:_*)
case b: Detailed[Block] => Some(b.withChildren(focus(b.children.toSet, targets:_*).toSeq:_*))
// a child of a target points to b
case b: Abstract if targetRelations(b.name) => Some(b.withRelations(Set.empty))
// a child of a target points to a child of b
case b: Abstract if b.aliases.exists(targetRelations.contains) => Some(b.withRelations(Set.empty))
// b points to a child of a target
case b: Abstract if b.relations.map(_.to.name).exists(targetAliases.contains) => Some(b.withRelations(b.relations.filter(r => targetAliases.contains(r.to.name))))
case _ => None
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment