Last active
August 13, 2019 07:28
-
-
Save nrktkt/55e345e08b16f9f01c920866716988cb to your computer and use it in GitHub Desktop.
Dynamic diagram generation with c4 and plantUML
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
#!/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))) |
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
/* | |
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