Cloud Foundry Application manifest using Kotlin DSL
I had a blast working with and getting my head around the excellent support for creating DSL’s in Kotlin Language.
This feature is now being used for creating gradle build files, for defining routes in Spring Webflux, for creating html templates using kotlinx.html library.
Here I am going to demonstrate creating a kotlin based DSL to represent a Cloud Foundry Application Manifest content.
A sample manifest looks like this when represented as a yaml file:
applications: - name: myapp memory: 512M instances: 1 path: target/someapp.jar routes: - somehost.com - antother.com/path envs: ENV_NAME1: VALUE1 ENV_NAME2: VALUE2
And here is the kind of DSL I am aiming for:
cf { name = "myapp" memory = 512(M) instances = 1 path = "target/someapp.jar" routes { +"somehost.com" +"another.com/path" } envs { env["ENV_NAME1"] = "VALUE1" env["ENV_NAME2"] = "VALUE2" } }
Getting the basic structure
Let me start with a simpler structure that looks like this:
cf { name = "myapp" instances = 1 path = "target/someapp.jar" }
and want this kind of a DSL to map to a structure which looks like this:
data class CfManifest( var name: String = "", var instances: Int? = 0, var path: String? = null )
It would translate to a Kotlin function which takes a Lambda expression:
fun cf(init: CfManifest.() -> Unit) { ... }
The parameter which looks like this:
() -> Unit
is fairly self-explanatory, a lambda expression which does not take any parameters and does not return anything.
The part that took a while to seep into my mind is this modified lambda expression, referred to as a Lambda expression with receiver:
CfManifest.() -> Unit
It does two things the way I have understood it:
1. It defines in the scope of the wrapped function an extension function for the receiver type – in my case the
CfManifest
class
2. this
within the lambda expression now refers to the receiver function.
Given this, thecf
function translates to :
fun cf(init: CfManifest.() -> Unit): CfManifest { val manifest = CfManifest() manifest.init() return manifest }
which can be succinctly expressed as:
fun cf(init: CfManifest.() -> Unit) = CfManifest().apply(init)
so now when I call:
cf { name = "myapp" instances = 1 path = "target/someapp.jar" }
It translates to:
CFManifest().apply { this.name = "myapp" this.instances = 1 this.path = "target/someapp.jar" }
More DSL
Expanding on the basic structure:
cf { name = "myapp" memory = 512(M) instances = 1 path = "target/someapp.jar" routes { +"somehost.com" +"another.com/path" } envs { env["ENV_NAME1"] = "VALUE1" env["ENV_NAME2"] = "VALUE2" } }
The routes and the envs in turn become methods on theCfManifest
class and look like this:
data class CfManifest( var name: String = "", var path: String? = null, var memory: MEM? = null, ... var routes: ROUTES? = null, var envs: ENVS = ENVS() ) { fun envs(block: ENVS.() -> Unit) { this.envs = ENVS().apply(block) } ... fun routes(block: ROUTES.() -> Unit) { this.routes = ROUTES().apply(block) } } data class ENVS( var env: MutableMap<String, String> = mutableMapOf() ) data class ROUTES( private val routes: MutableList<String> = mutableListOf() ) { operator fun String.unaryPlus() { routes.add(this) } }
See how theroutes
method takes in a Lambda expression with a receiver type ofROUTES
, this allows me to define an expression like this:
cf { ... routes { +"somehost.com" +"another.com/path" } ... }
Another trick here is way a route is being added is using :
+"somehost.com"
which is enabled using a Kotlin convention which translates specific method names to operators, here the unaryPlus method. The cool thing for me is that this operator is visible only in the scope of ROUTES instance!
Another feature of the DSL making use of Kotlin features is the way a memory is specified, there are two parts to it – a number and the modifier, 2G, 500M etc.
This is being specified in a slightly modified way via the DSL as 2(G) and 500(M).
The way it is implemented is using another Kotlin convention where if a class has aninvoke
method then instances can call it the following way:
class ClassWithInvoke() { operator fun invoke(n: Int): String = "" + n } val c = ClassWithInvoke() c(10)
So implementing invoke
method as an extension function onInt
in the scope of theCFManifest
class allows this kind of a DSL:
data class CfManifest( var name: String = "", ... ) { ... operator fun Int.invoke(m: MemModifier): MEM = MEM(this, m) }
This is pure experimentation on my part, I am both new to Kotlin as well as Kotlin DSL’s so very likely there are a lot of things that can be improved in this implementation, any feedback and suggestions are welcome. You can play with this sample code at my github repo here
Reference: | Cloud Foundry Application manifest using Kotlin DSL from our JCG partner Biju Kunjummen at the all and sundry blog. |