Skip to content

Commit

Permalink
Middleware for adding swagger ui endpoint (#2494) (#2556)
Browse files Browse the repository at this point in the history
* Html template script rendering fix (#2521)

* SwaggerUI utility for creating routes that serve openapi (#2494)
  • Loading branch information
987Nabil authored Dec 13, 2023
1 parent 328729d commit 61fa20b
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ package example
import zio._

import zio.http.Header.Authorization
import zio.http._
import zio.http.codec.{HttpCodec, PathCodec}
import zio.http.endpoint.openapi.{OpenAPIGen, SwaggerUI}
import zio.http.endpoint.{Endpoint, EndpointExecutor, EndpointLocator, EndpointMiddleware}
import zio.http.{int => _, _}

object EndpointExamples extends ZIOAppDefault {
import HttpCodec._
import HttpCodec.query
import PathCodec._

val auth = EndpointMiddleware.auth
Expand Down Expand Up @@ -36,7 +37,9 @@ object EndpointExamples extends ZIOAppDefault {
}
}

val routes = Routes(getUserRoute, getUserPostsRoute)
val openAPI = OpenAPIGen.fromEndpoints(title = "Endpoint Example", version = "1.0", getUser, getUserPosts)

val routes = Routes(getUserRoute, getUserPostsRoute) ++ SwaggerUI.routes("docs" / "openapi", openAPI)

val app = routes.toHttpApp // (auth.implement(_ => ZIO.unit)(_ => ZIO.unit))

Expand Down
3 changes: 2 additions & 1 deletion zio-http/src/main/scala/zio/http/Middleware.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
package zio.http

import java.io.File
import java.net.URLEncoder

import zio._
import zio.metrics._
import zio.stacktracer.TracingImplicits.disableAutoTrace

import zio.http.codec.{PathCodec, SegmentCodec}
import zio.http.endpoint.openapi.OpenAPI

trait Middleware[-UpperEnv] { self =>
def apply[Env1 <: UpperEnv, Err](
Expand Down
15 changes: 7 additions & 8 deletions zio-http/src/main/scala/zio/http/codec/PathCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ sealed trait PathCodec[A] { self =>
final def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] =
self ++ that

/**
* Returns a new pattern that is extended with the specified segment pattern.
*/
final def /[B](segment: SegmentCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] =
self ++ Segment[B](segment)

final def asType[B](implicit ev: A =:= B): PathCodec[B] = self.asInstanceOf[PathCodec[B]]

/**
Expand Down Expand Up @@ -358,9 +352,14 @@ object PathCodec {
def apply(value: String): PathCodec[Unit] = {
val path = Path(value)

path.segments.foldLeft[PathCodec[Unit]](PathCodec.empty) { (pathSpec, segment) =>
pathSpec./[Unit](SegmentCodec.literal(segment))
path.segments match {
case Chunk() => PathCodec.empty
case Chunk(first, rest @ _*) =>
rest.foldLeft[PathCodec[Unit]](Segment(SegmentCodec.literal(first))) { (pathSpec, segment) =>
pathSpec / Segment(SegmentCodec.literal(segment))
}
}

}

def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.bool(name))
Expand Down
104 changes: 104 additions & 0 deletions zio-http/src/main/scala/zio/http/endpoint/openapi/SwaggerUI.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package zio.http.endpoint.openapi

import java.net.URLEncoder

import zio.http._
import zio.http.codec.PathCodec

object SwaggerUI {

val DefaultSwaggerUIVersion: String = "5.10.3"

//format: off
/**
* Creates routes for serving the Swagger UI at the given path.
*
* Example:
* {{{
* val routes: Routes[Any, Response] = ???
* val openAPIv1: OpenAPI = ???
* val openAPIv2: OpenAPI = ???
* val swaggerUIRoutes = SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)
* val routesWithSwagger = routes ++ swaggerUIRoutes
* }}}
*
* With this middleware in place, a request to `https://www.domain.com/[path]`
* would serve the Swagger UI. The different OpenAPI specifications are served
* at `https://www.domain.com/[path]/[title].json`. Where `title` is the title
* of the OpenAPI specification and is url encoded.
*/
//format: on
def routes(path: PathCodec[Unit], api: OpenAPI, apis: OpenAPI*): Routes[Any, Response] = {
routes(path, DefaultSwaggerUIVersion, api, apis: _*)
}

//format: off
/**
* Creates a middleware for serving the Swagger UI at the given path and with
* the given swagger ui version.
*
* Example:
* {{{
* val routes: Routes[Any, Response] = ???
* val openAPIv1: OpenAPI = ???
* val openAPIv2: OpenAPI = ???
* val swaggerUIRoutes = SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)
* val routesWithSwagger = routes ++ swaggerUIRoutes
* }}}
*
* With this middleware in place, a request to `https://www.domain.com/[path]`
* would serve the Swagger UI. The different OpenAPI specifications are served
* at `https://www.domain.com/[path]/[title].json`. Where `title` is the title
* of the OpenAPI specification and is url encoded.
*/
//format: on
def routes(path: PathCodec[Unit], version: String, api: OpenAPI, apis: OpenAPI*): Routes[Any, Response] = {
import zio.http.template._
val basePath = Method.GET / path
val jsonRoutes = (api +: apis).map { api =>
basePath / s"${URLEncoder.encode(api.info.title, Charsets.Utf8.name())}.json" -> handler { (_: Request) =>
Response.json(api.toJson)
}
}
val jsonPaths = jsonRoutes.map(_.routePattern.pathCodec.render)
val jsonTitles = (api +: apis).map(_.info.title)
val jsonUrls = jsonTitles.zip(jsonPaths).map { case (title, path) => s"""{url: "$path", name: "$title"}""" }
val uiRoute = basePath -> handler { (_: Request) =>
Response.html(
html(
head(
meta(charsetAttr := "utf-8"),
meta(nameAttr := "viewport", contentAttr := "width=device-width, initial-scale=1"),
meta(nameAttr := "description", contentAttr := "SwaggerUI"),
title("SwaggerUI"),
link(relAttr := "stylesheet", href := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui.css"),
link(
relAttr := "icon",
typeAttr := "image/png",
href := s"https://unpkg.com/swagger-ui-dist@$version/favicon-32x32.png",
),
),
body(
div(id := "swagger-ui"),
script(srcAttr := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui-bundle.js"),
script(srcAttr := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui-standalone-preset.js"),
Dom.raw(s"""<script>
|window.onload = () => {
| window.ui = SwaggerUIBundle({
| urls: ${jsonUrls.mkString("[\n", ",\n", "\n]")},
| dom_id: '#swagger-ui',
| presets: [
| SwaggerUIBundle.presets.apis,
| SwaggerUIStandalonePreset
| ],
| layout: "StandaloneLayout",
| });
|};
|</script>""".stripMargin),
),
),
)
}
Routes.fromIterable(jsonRoutes) :+ uiRoute
}
}
17 changes: 9 additions & 8 deletions zio-http/src/main/scala/zio/http/template/Dom.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ sealed trait Dom { self =>
def encode(spaces: Int): CharSequence =
encode(EncodingState.Indentation(0, spaces))

private[template] def encode(state: EncodingState): CharSequence = self match {
private[template] def encode(state: EncodingState, encodeHtml: Boolean = true): CharSequence = self match {
case Dom.Element(name, children) =>
val encode = if (name == "script" || name == "style") false else encodeHtml
val attributes = children.collect { case self: Dom.Attribute => self.encode }

val innerState = state.inner
Expand All @@ -51,9 +52,9 @@ sealed trait Dom { self =>

def inner: CharSequence =
elements match {
case Seq(singleText: Dom.Text) => singleText.encode(innerState)
case Seq(singleText: Dom.Text) => singleText.encode(innerState, encode)
case _ =>
s"${innerState.nextElemSeparator}${elements.map(_.encode(innerState)).mkString(innerState.nextElemSeparator)}${state.nextElemSeparator}"
s"${innerState.nextElemSeparator}${elements.map(_.encode(innerState, encode)).mkString(innerState.nextElemSeparator)}${state.nextElemSeparator}"
}

if (noElements && noAttributes && isVoid) s"<$name/>"
Expand All @@ -64,11 +65,11 @@ sealed trait Dom { self =>
else
s"<$name ${attributes.mkString(" ")}>$inner</$name>"

case Dom.Text(data) => OutputEncoder.encodeHtml(data.toString)
case Dom.Attribute(name, value) =>
s"""$name="${OutputEncoder.encodeHtml(value.toString)}""""
case Dom.Empty => ""
case Dom.Raw(raw) => raw
case Dom.Text(data) if encodeHtml => OutputEncoder.encodeHtml(data.toString)
case Dom.Text(data) => data
case Dom.Attribute(name, value) => s"""$name="${OutputEncoder.encodeHtml(value.toString)}""""
case Dom.Empty => ""
case Dom.Raw(raw) => raw
}
}

Expand Down
40 changes: 20 additions & 20 deletions zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,28 @@ object PathCodecSpec extends ZIOHttpSpec {
test("/users") {
val codec = PathCodec.path("/users")

assertTrue(codec.segments.length == 2)
assertTrue(codec.segments.length == 1)
},
test("/users/{user-id}/posts/{post-id}") {
val codec =
PathCodec.path("/users") / SegmentCodec.int("user-id") / SegmentCodec.literal("posts") / SegmentCodec
PathCodec.path("/users") / PathCodec.int("user-id") / PathCodec.literal("posts") / PathCodec
.string(
"post-id",
)

assertTrue(codec.segments.length == 5)
assertTrue(codec.segments.length == 4)
},
test("transformed") {
val codec =
PathCodec.path("/users") /
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
SegmentCodec.literal("posts") /
SegmentCodec
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
PathCodec.literal("posts") /
PathCodec
.string("post-id")
.transformOrFailLeft(s =>
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
)(_.value)
assertTrue(codec.segments.length == 5)
assertTrue(codec.segments.length == 4)
},
),
suite("decoding")(
Expand All @@ -86,14 +86,14 @@ object PathCodecSpec extends ZIOHttpSpec {
assertTrue(codec.decode(Path("/users")) == Right(Path("/users")))
},
test("/users") {
val codec = PathCodec.empty / SegmentCodec.literal("users")
val codec = PathCodec.empty / PathCodec.literal("users")

assertTrue(codec.decode(Path("/users")) == Right(())) &&
assertTrue(codec.decode(Path("/users/")) == Right(()))
},
test("concat") {
val codec1 = PathCodec.empty / SegmentCodec.literal("users") / SegmentCodec.int("user-id")
val codec2 = PathCodec.empty / SegmentCodec.literal("posts") / SegmentCodec.string("post-id")
val codec1 = PathCodec.empty / PathCodec.literal("users") / PathCodec.int("user-id")
val codec2 = PathCodec.empty / PathCodec.literal("posts") / PathCodec.string("post-id")

val codec = codec1 ++ codec2

Expand All @@ -102,9 +102,9 @@ object PathCodecSpec extends ZIOHttpSpec {
test("transformed") {
val codec =
PathCodec.path("/users") /
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
SegmentCodec.literal("posts") /
SegmentCodec
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
PathCodec.literal("posts") /
PathCodec
.string("post-id")
.transformOrFailLeft(s =>
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
Expand All @@ -122,7 +122,7 @@ object PathCodecSpec extends ZIOHttpSpec {
assertTrue(codec.segments == Chunk(SegmentCodec.empty))
},
test("/users") {
val codec = PathCodec.empty / SegmentCodec.literal("users")
val codec = PathCodec.empty / PathCodec.literal("users")

assertTrue(
codec.segments ==
Expand All @@ -137,24 +137,24 @@ object PathCodecSpec extends ZIOHttpSpec {
assertTrue(codec.render == "")
},
test("/users") {
val codec = PathCodec.empty / SegmentCodec.literal("users")
val codec = PathCodec.empty / PathCodec.literal("users")

assertTrue(codec.render == "/users")
},
test("/users/{user-id}/posts/{post-id}") {
val codec =
PathCodec.empty / SegmentCodec.literal("users") / SegmentCodec.int("user-id") / SegmentCodec.literal(
PathCodec.empty / PathCodec.literal("users") / PathCodec.int("user-id") / PathCodec.literal(
"posts",
) / SegmentCodec.string("post-id")
) / PathCodec.string("post-id")

assertTrue(codec.render == "/users/{user-id}/posts/{post-id}")
},
test("transformed") {
val codec =
PathCodec.path("/users") /
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
SegmentCodec.literal("posts") /
SegmentCodec
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
PathCodec.literal("posts") /
PathCodec
.string("post-id")
.transformOrFailLeft(s =>
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package zio.http.endpoint.openapi

import zio._
import zio.test._

import zio.http._
import zio.http.codec.HttpCodec.query
import zio.http.codec.PathCodec.path
import zio.http.endpoint.Endpoint

object SwaggerUISpec extends ZIOSpecDefault {

override def spec: Spec[TestEnvironment with Scope, Any] =
suite("SwaggerUI")(
test("should return the swagger ui page") {
val getUser = Endpoint(Method.GET / "users" / int("userId")).out[Int]

val getUserRoute = getUser.implement { Handler.fromFunction[Int] { id => id } }

val getUserPosts =
Endpoint(Method.GET / "users" / int("userId") / "posts" / int("postId"))
.query(query("name"))
.out[List[String]]

val getUserPostsRoute =
getUserPosts.implement[Any] {
Handler.fromFunctionZIO[(Int, Int, String)] { case (id1: Int, id2: Int, query: String) =>
ZIO.succeed(List(s"API2 RESULT parsed: users/$id1/posts/$id2?name=$query"))
}
}

val openAPIv1 = OpenAPIGen.fromEndpoints(title = "Endpoint Example", version = "1.0", getUser, getUserPosts)
val openAPIv2 =
OpenAPIGen.fromEndpoints(title = "Another Endpoint Example", version = "2.0", getUser, getUserPosts)

val routes =
Routes(getUserRoute, getUserPostsRoute) ++ SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)

val response = routes.apply(Request(method = Method.GET, url = url"/docs/openapi"))

val expectedHtml =
"""<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><meta name="description" content="SwaggerUI"/><title>SwaggerUI</title><link rel="stylesheet" href="https://unpkg.com/[email protected]/swagger-ui.css"/><link rel="icon" type="image/png" href="https://unpkg.com/[email protected]/favicon-32x32.png"/></head><body><div id="swagger-ui"></div><script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script><script src="https://unpkg.com/[email protected]/swagger-ui-standalone-preset.js"></script><script>
|window.onload = () => {
| window.ui = SwaggerUIBundle({
| urls: [
|{url: "/docs/openapi/Endpoint+Example.json", name: "Endpoint Example"},
|{url: "/docs/openapi/Another+Endpoint+Example.json", name: "Another Endpoint Example"}
|],
| dom_id: '#swagger-ui',
| presets: [
| SwaggerUIBundle.presets.apis,
| SwaggerUIStandalonePreset
| ],
| layout: "StandaloneLayout",
| });
|};
|</script></body></html>""".stripMargin

for {
res <- response
body <- res.body.asString
} yield {
assertTrue(body == expectedHtml)
}
},
)
}
Loading

0 comments on commit 61fa20b

Please sign in to comment.