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) + } + }, + ) +}