From 81553957a11310497a40b673d1af5b79386bceea Mon Sep 17 00:00:00 2001
From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com>
Date: Tue, 12 Dec 2023 18:18:58 +0100
Subject: [PATCH] SwaggerUI utility for creating routes that serve openapi
 (#2494)

---
 .../main/scala/example/EndpointExamples.scala |   9 +-
 .../src/main/scala/zio/http/Middleware.scala  |   3 +-
 .../main/scala/zio/http/codec/PathCodec.scala |  15 ++-
 .../zio/http/endpoint/openapi/SwaggerUI.scala | 104 ++++++++++++++++++
 .../scala/zio/http/codec/PathCodecSpec.scala  |  40 +++----
 .../http/endpoint/openapi/SwaggerUISpec.scala |  67 +++++++++++
 6 files changed, 206 insertions(+), 32 deletions(-)
 create mode 100644 zio-http/src/main/scala/zio/http/endpoint/openapi/SwaggerUI.scala
 create mode 100644 zio-http/src/test/scala/zio/http/endpoint/openapi/SwaggerUISpec.scala

diff --git a/zio-http-example/src/main/scala/example/EndpointExamples.scala b/zio-http-example/src/main/scala/example/EndpointExamples.scala
index b76df76617..0d40c12427 100644
--- a/zio-http-example/src/main/scala/example/EndpointExamples.scala
+++ b/zio-http-example/src/main/scala/example/EndpointExamples.scala
@@ -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
@@ -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))
 
diff --git a/zio-http/src/main/scala/zio/http/Middleware.scala b/zio-http/src/main/scala/zio/http/Middleware.scala
index cffd24c4dc..c28fc879ef 100644
--- a/zio-http/src/main/scala/zio/http/Middleware.scala
+++ b/zio-http/src/main/scala/zio/http/Middleware.scala
@@ -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](
diff --git a/zio-http/src/main/scala/zio/http/codec/PathCodec.scala b/zio-http/src/main/scala/zio/http/codec/PathCodec.scala
index 85e74f8a15..4cea864b53 100644
--- a/zio-http/src/main/scala/zio/http/codec/PathCodec.scala
+++ b/zio-http/src/main/scala/zio/http/codec/PathCodec.scala
@@ -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]]
 
   /**
@@ -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))
diff --git a/zio-http/src/main/scala/zio/http/endpoint/openapi/SwaggerUI.scala b/zio-http/src/main/scala/zio/http/endpoint/openapi/SwaggerUI.scala
new file mode 100644
index 0000000000..215b1786d8
--- /dev/null
+++ b/zio-http/src/main/scala/zio/http/endpoint/openapi/SwaggerUI.scala
@@ -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
+  }
+}
diff --git a/zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala b/zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala
index 8773bf109a..68216f7d82 100644
--- a/zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala
+++ b/zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala
@@ -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")(
@@ -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
 
@@ -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)),
@@ -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 ==
@@ -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)),
diff --git a/zio-http/src/test/scala/zio/http/endpoint/openapi/SwaggerUISpec.scala b/zio-http/src/test/scala/zio/http/endpoint/openapi/SwaggerUISpec.scala
new file mode 100644
index 0000000000..a7bfbb7f61
--- /dev/null
+++ b/zio-http/src/test/scala/zio/http/endpoint/openapi/SwaggerUISpec.scala
@@ -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/swagger-ui-dist@5.10.3/swagger-ui.css"/><link rel="icon" type="image/png" href="https://unpkg.com/swagger-ui-dist@5.10.3/favicon-32x32.png"/></head><body><div id="swagger-ui"></div><script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script><script src="https://unpkg.com/swagger-ui-dist@5.10.3/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)
+        }
+      },
+    )
+}