Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Middleware for adding swagger ui endpoint (#2494) #2556

Merged
merged 2 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/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)
}
},
)
}
Loading
Loading