Skip to content

Commit

Permalink
adding support for environment variables as markup (#151)
Browse files Browse the repository at this point in the history
* adding support for environment variables as markup

Fixes #149

* adding build variable to use output transformers

* adding docs regarding output transformers

* adding key value for environment output transformer

* improving docs
  • Loading branch information
Moises Trovo authored and kailuowang committed Apr 7, 2017
1 parent f17c236 commit a01def3
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 6 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ Also in build.sbt add domain package names for play-swagger to auto generate swa
swaggerDomainNameSpaces := Seq("models")
```

You can also use markup on your swagger files by providing OutputTransformers classes name to the setting `swaggerOutputTransformers` on your build file.

For example you can use environment variables by adding the configuration:
```
swaggerOutputTransformers := Seq(envOutputTransformer)
```

Then on your routes file or root swagger file you can use some markup like the one used below for the host field:
```
swagger: "2.0"
info:
title: "API"
description: "REST API"
version: "1.0.0"
host: ${API_HOST}
```

This way when the swagger file is parsed the markup `${API_HOST}` is going to be substituted by the content of the environent variable `API_HOST`.


This plugin adds a sbt task `swagger`, with which you can generate the `swagger.json` for testing purpose.

This plugin will generate the `swagger.json`and make it available under path `assets/swagger.json` on `sbt package` and `sbt run`.
Expand Down
71 changes: 71 additions & 0 deletions core/src/main/scala/com/iheart/playSwagger/OutputTransformer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.iheart.playSwagger

import java.util.regex.Pattern

import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer
import play.api.libs.json.{JsArray, JsString, JsValue, JsObject}

import scala.util.matching.Regex
import scala.util.{Success, Failure, Try}

/** Specialization of a Kleisli function (A => M[B])*/
trait OutputTransformer extends (JsObject Try[JsObject]) {

/** alias for `andThen` as defined monadic function */
def >=>(b: JsObject Try[JsObject]): OutputTransformer = SimpleOutputTransformer { value: JsObject
this.apply(value).flatMap(b)
}
}

object OutputTransformer {
final case class SimpleOutputTransformer(run: (JsObject Try[JsObject])) extends OutputTransformer {
override def apply(value: JsObject): Try[JsObject] = run(value)
}

def traverseTransformer(vals: JsArray)(transformer: JsValue Try[JsValue]): Try[JsArray] = {
val tryElements = vals.value.map {
case value: JsObject traverseTransformer(value)(transformer)
case value: JsArray traverseTransformer(value)(transformer)
case value: JsValue transformer(value)
}

val failures: Seq[Failure[JsValue]] = tryElements.filter(_.isInstanceOf[Failure[_]]).asInstanceOf[Seq[Failure[JsValue]]]
if (failures.nonEmpty) {
Failure(failures.head.exception)
} else {
Success(JsArray(tryElements.asInstanceOf[Seq[Success[JsValue]]].map(_.value)))
}
}

def traverseTransformer(obj: JsObject)(transformer: JsValue Try[JsValue]): Try[JsObject] = {
val tryFields = obj.fields.map {
case (key, value: JsObject) (key, traverseTransformer(value)(transformer))
case (key, values: JsArray) (key, traverseTransformer(values)(transformer))
case (key, value: JsValue) (key, transformer(value))
}
val failures: Seq[(String, Failure[JsValue])] = tryFields
.filter(_._2.isInstanceOf[Failure[_]])
.asInstanceOf[Seq[(String, Failure[JsValue])]]
if (failures.nonEmpty) {
Failure(failures.head._2.exception)
} else {
Success(JsObject(tryFields.asInstanceOf[Seq[(String, Success[JsValue])]].map {
case (key, Success(result)) (key, result)
}))
}
}
}

class PlaceholderVariablesTransformer(map: String Option[String], pattern: Regex = "^\\$\\{(.*)\\}$".r) extends OutputTransformer {
def apply(value: JsObject) = OutputTransformer.traverseTransformer(value) {
case JsString(pattern(key)) map(key) match {
case Some(result) Success(JsString(result))
case None Failure(new IllegalStateException(s"Unable to find variable $key"))
}
case e: JsValue Success(e)
}
}

final case class MapVariablesTransformer(map: Map[String, String]) extends PlaceholderVariablesTransformer(map.get)
class EnvironmentVariablesTransformer extends PlaceholderVariablesTransformer((key: String) Option(System.getenv(key)))

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.iheart.playSwagger
import java.io.File
import com.fasterxml.jackson.databind.ObjectMapper
import com.iheart.playSwagger.Domain._
import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer
import play.api.libs.json._
import ResourceReader.read
import org.yaml.snakeyaml.Yaml
Expand All @@ -17,13 +18,17 @@ object SwaggerSpecGenerator {
val customMappingsFileName = "swagger-custom-mappings"
val baseSpecFileName = "swagger"
def apply(domainNameSpaces: String*)(implicit cl: ClassLoader): SwaggerSpecGenerator = SwaggerSpecGenerator(PrefixDomainModelQualifier(domainNameSpaces: _*))
def apply(outputTransformers: Seq[OutputTransformer], domainNameSpaces: String*)(implicit cl: ClassLoader): SwaggerSpecGenerator = {
SwaggerSpecGenerator(PrefixDomainModelQualifier(domainNameSpaces: _*), outputTransformers = outputTransformers)
}

case object MissingBaseSpecException extends Exception(s"Missing a $baseSpecFileName.yml or $baseSpecFileName.json to provide base swagger spec")
}

final case class SwaggerSpecGenerator(
modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(),
defaultPostBodyFormat: String = "application/json"
modelQualifier: DomainModelQualifier = PrefixDomainModelQualifier(),
defaultPostBodyFormat: String = "application/json",
outputTransformers: Seq[OutputTransformer] = Nil
)(implicit cl: ClassLoader) {
import SwaggerSpecGenerator.{customMappingsFileName, baseSpecFileName, MissingBaseSpecException}
// routes with their prefix
Expand Down Expand Up @@ -90,7 +95,12 @@ final case class SwaggerSpecGenerator(
}

// starts with empty prefix, assuming that the routesFile is the outermost (usually 'routes')
loop("", routesFile).map(generateFromRoutes(_, base))
loop("", routesFile).flatMap { data
val result: JsObject = generateFromRoutes(data, base)
val initial = SimpleOutputTransformer(Success[JsObject])
val mapper = outputTransformers.foldLeft[OutputTransformer](initial)(_ >=> _)
mapper(result)
}
}

/**
Expand Down
18 changes: 16 additions & 2 deletions core/src/main/scala/com/iheart/playSwagger/SwaggerSpecRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ package com.iheart.playSwagger

import java.nio.file.{Files, Paths, StandardOpenOption}

import scala.util.{Success, Failure, Try}

object SwaggerSpecRunner extends App {
implicit def cl = getClass.getClassLoader

val (targetFile :: routesFile :: domainNameSpaceArgs) = args.toList
val (targetFile :: routesFile :: domainNameSpaceArgs :: outputTransformersArgs :: Nil) = args.toList
private def fileArg = Paths.get(targetFile)
private def swaggerJson = SwaggerSpecGenerator(domainNameSpaceArgs: _*).generate(routesFile).get.toString
private def swaggerJson = {
val domainModelQualifier = PrefixDomainModelQualifier(domainNameSpaceArgs.split(","): _*)
val transformersStrs: Seq[String] = if (outputTransformersArgs.isEmpty) Seq() else outputTransformersArgs.split(",")
val transformers = transformersStrs.map { clazz
Try(cl.loadClass(clazz).asSubclass(classOf[OutputTransformer]).newInstance()) match {
case Failure(ex: ClassCastException)
throw new IllegalArgumentException("Transformer should be a subclass of com.iheart.playSwagger.OutputTransformer:" + clazz, ex)
case Failure(ex) throw new IllegalArgumentException("Could not create transformer", ex)
case Success(el) el
}
}
SwaggerSpecGenerator(domainModelQualifier, outputTransformers = transformers).generate(routesFile).get.toString
}

Files.write(fileArg, swaggerJson.getBytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
}
27 changes: 27 additions & 0 deletions core/src/test/resources/env.routes
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
###
# summary: last track
# description: big deal
# parameters:
# - name: sid
# description: station id
# format: int
# responses:
# 200:
# description: ${LAST_TRACK_DESCRIPTION}
# schema:
# $ref: '#/definitions/com.iheart.playSwagger.Track'
###
GET /api/station/:sid/playedTracks/last @controllers.LiveMeta.playedByStation(sid: Int)

###
# summary: Add track
# parameters:
# - name: body
# description: ${PLAYED_TRACKS_DESCRIPTION}
# schema:
# $ref: '#/definitions/com.iheart.playSwagger.Track'
# responses:
# 200:
# description: success
###
POST /api/station/playedTracks controllers.LiveMeta.addPlayedTracks()
150 changes: 150 additions & 0 deletions core/src/test/scala/com/iheart/playSwagger/OutputTransformerSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.iheart.playSwagger

import com.iheart.playSwagger.OutputTransformer.SimpleOutputTransformer
import org.specs2.mutable.Specification
import play.api.libs.json.{JsNumber, JsObject, JsString, Json}

import scala.util.{Failure, Success}

class OutputTransformerSpec extends Specification {
"OutputTransformer.traverseTransformer" >> {

"traverse and transform object and update simple paths" >> {
val result = OutputTransformer.traverseTransformer(Json.obj(
"a" 1,
"b" "c"
)) { _ Success(JsNumber(10)) }
result === Success(Json.obj("a" 10, "b" 10))
}

"traverse and transform object and update nested paths" >> {
val result = OutputTransformer.traverseTransformer(Json.obj(
"a" 1,
"b" Json.obj(
"c" 1
)
)) { _ Success(JsNumber(10)) }
result === Success(Json.obj("a" 10, "b" Json.obj("c" 10)))
}

"traverse and transform object and update array paths" >> {
val result = OutputTransformer.traverseTransformer(Json.obj(
"a" 1,
"b" Json.arr(
Json.obj("c" 1),
Json.obj("d" 1),
Json.obj("e" 1)
)
)) { _ Success(JsNumber(10)) }
result === Success(Json.obj("a" 10, "b" Json.arr(
Json.obj("c" 10),
Json.obj("d" 10),
Json.obj("e" 10)
)))
}

"return a failure when there's a problem transforming data" >> {
val err: IllegalArgumentException = new scala.IllegalArgumentException("failed")
val result = OutputTransformer.traverseTransformer(Json.obj(
"a" 1,
"b" Json.obj(
"c" 1
)
)) { _ Failure(err) }
result === Failure(err)
}
}
"OutputTransformer.>=>" >> {
"return composed function" >> {
val a = SimpleOutputTransformer(OutputTransformer.traverseTransformer(_) {
case JsString(content) Success(JsString(content + "a"))
case _ Failure(new IllegalStateException())
})
val b = SimpleOutputTransformer(OutputTransformer.traverseTransformer(_) {
case JsString(content) Success(JsString(content + "b"))
case _ Failure(new IllegalStateException())
})

val g = a >=> b
g(Json.obj(
"A" "Z",
"B" "Y"
)) must beSuccessfulTry.withValue(Json.obj(
"A" "Zab",
"B" "Yab"
))
}

"fail if one composed function fails" >> {
val a = SimpleOutputTransformer(OutputTransformer.traverseTransformer(_) {
case JsString(content) Success(JsString("a" + content))
case _ Failure(new IllegalStateException())
})
val b = SimpleOutputTransformer(OutputTransformer.traverseTransformer(_) {
case JsString(content) Failure(new IllegalStateException("not strings"))
case _ Failure(new IllegalStateException())
})

val g = a >=> b
g(Json.obj(
"A" "Z",
"B" "Y"
)) must beFailedTry[JsObject].withThrowable[IllegalStateException]("not strings")
}
}
}

class EnvironmentVariablesSpec extends Specification {
"EnvironmentVariables" >> {
"transform json with markup values" >> {
val envs = Map("A" "B", "C" "D")
val instance = MapVariablesTransformer(envs)
instance(Json.obj(
"a" "${A}",
"b" Json.obj(
"c" "${C}"
)
)) === Success(Json.obj("a" "B", "b" Json.obj("c" "D")))
}

"return failure when using non present environment variables" >> {
val envs = Map("A" "B", "C" "D")
val instance = MapVariablesTransformer(envs)
instance(Json.obj(
"a" "${A}",
"b" Json.obj(
"c" "${NON_EXISTING}"
)
)) must beFailedTry[JsObject].withThrowable[IllegalStateException]("Unable to find variable NON_EXISTING")
}
}
}

class EnvironmentVariablesIntegrationSpec extends Specification {
implicit val cl = getClass.getClassLoader

"integration" >> {
"generate api with placeholders in place" >> {
val envs = Map("LAST_TRACK_DESCRIPTION" "Last track", "PLAYED_TRACKS_DESCRIPTION" "Add tracks")
val json = SwaggerSpecGenerator(
PrefixDomainModelQualifier("com.iheart"),
outputTransformers = MapVariablesTransformer(envs) :: Nil
).generate("env.routes").get
val pathJson = json \ "paths"
val stationJson = (pathJson \ "/api/station/{sid}/playedTracks/last" \ "get").as[JsObject]
val addTrackJson = (pathJson \ "/api/station/playedTracks" \ "post").as[JsObject]

(addTrackJson \ "parameters" \ (0) \ "description").as[String] === "Add tracks"
(stationJson \ "responses" \ "200" \ "description").as[String] === "Last track"
}
}

"fail to generate API if environment variable is not found" >> {
val envs = Map("LAST_TRACK_DESCRIPTION" "Last track")
val json = SwaggerSpecGenerator(
PrefixDomainModelQualifier("com.iheart"),
outputTransformers = MapVariablesTransformer(envs) :: Nil
).generate("env.routes")
json must beFailedTry[JsObject].withThrowable[IllegalStateException]("Unable to find variable PLAYED_TRACKS_DESCRIPTION")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ trait SwaggerKeys {
val swaggerTarget = SettingKey[File]("swaggerTarget", "the location of the swagger documentation in your packaged app.")
val swaggerFileName = SettingKey[String]("swaggerFileName", "the swagger filename the swagger documentation in your packaged app.")
val swaggerRoutesFile = SettingKey[String]("swaggerRoutesFile", "the root routes file with which play-swagger start to parse")
val swaggerOutputTransformers = SettingKey[Seq[String]]("swaggerOutputTransformers", "list of output transformers for processing swagger file")
val envOutputTransformer = "com.iheart.playSwagger.EnvironmentVariablesTransformer"
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ object SwaggerPlugin extends AutoPlugin {
swaggerTarget := target.value / "swagger",
swaggerFileName := "swagger.json",
swaggerRoutesFile := "routes",
swaggerOutputTransformers := Seq(),
swagger <<= Def.task[File] {
(swaggerTarget.value).mkdirs()
val file = swaggerTarget.value / swaggerFileName.value
IO.delete(file)
val args: Seq[String] = file.absolutePath +: swaggerRoutesFile.value +: swaggerDomainNameSpaces.value
val args: Seq[String] = file.absolutePath :: swaggerRoutesFile.value ::
swaggerDomainNameSpaces.value.mkString(",") :: swaggerOutputTransformers.value.mkString(",") :: Nil
val swaggerClasspath = data((fullClasspath in Runtime).value) ++ update.value.select(configurationFilter(swaggerConfig.name))
toError(runner.value.run("com.iheart.playSwagger.SwaggerSpecRunner", swaggerClasspath, args, streams.value.log))
file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ swaggerDomainNameSpaces := Seq("namespace1", "namespace2")

swaggerRoutesFile := "my-routes"

swaggerOutputTransformers := Seq(envOutputTransformer)

val pathVal = System.getenv("PATH")

TaskKey[Unit]("check") := {

def uniform(jsString: String): String = pretty(render(parse(jsString)))
Expand All @@ -30,6 +34,7 @@ TaskKey[Unit]("check") := {
| "summary":"Get the track metadata",
| "responses":{
| "200":{
| "summary": "$pathVal",
| "schema":{
| "$$ref":"#/definitions/namespace2.Track"
| }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# summary: Get the track metadata
# responses:
# 200:
# summary: ${PATH}
# schema:
# $ref: '#/definitions/namespace2.Track'
###
Expand Down

0 comments on commit a01def3

Please sign in to comment.