Skip to content

Commit 8155395

Browse files
committed
SwaggerUI utility for creating routes that serve openapi (#2494)
1 parent af9fad6 commit 8155395

File tree

6 files changed

+206
-32
lines changed

6 files changed

+206
-32
lines changed

zio-http-example/src/main/scala/example/EndpointExamples.scala

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ package example
33
import zio._
44

55
import zio.http.Header.Authorization
6+
import zio.http._
67
import zio.http.codec.{HttpCodec, PathCodec}
8+
import zio.http.endpoint.openapi.{OpenAPIGen, SwaggerUI}
79
import zio.http.endpoint.{Endpoint, EndpointExecutor, EndpointLocator, EndpointMiddleware}
8-
import zio.http.{int => _, _}
910

1011
object EndpointExamples extends ZIOAppDefault {
11-
import HttpCodec._
12+
import HttpCodec.query
1213
import PathCodec._
1314

1415
val auth = EndpointMiddleware.auth
@@ -36,7 +37,9 @@ object EndpointExamples extends ZIOAppDefault {
3637
}
3738
}
3839

39-
val routes = Routes(getUserRoute, getUserPostsRoute)
40+
val openAPI = OpenAPIGen.fromEndpoints(title = "Endpoint Example", version = "1.0", getUser, getUserPosts)
41+
42+
val routes = Routes(getUserRoute, getUserPostsRoute) ++ SwaggerUI.routes("docs" / "openapi", openAPI)
4043

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

zio-http/src/main/scala/zio/http/Middleware.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
package zio.http
1717

1818
import java.io.File
19+
import java.net.URLEncoder
1920

2021
import zio._
2122
import zio.metrics._
22-
import zio.stacktracer.TracingImplicits.disableAutoTrace
2323

2424
import zio.http.codec.{PathCodec, SegmentCodec}
25+
import zio.http.endpoint.openapi.OpenAPI
2526

2627
trait Middleware[-UpperEnv] { self =>
2728
def apply[Env1 <: UpperEnv, Err](

zio-http/src/main/scala/zio/http/codec/PathCodec.scala

+7-8
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,6 @@ sealed trait PathCodec[A] { self =>
4848
final def /[B](that: PathCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] =
4949
self ++ that
5050

51-
/**
52-
* Returns a new pattern that is extended with the specified segment pattern.
53-
*/
54-
final def /[B](segment: SegmentCodec[B])(implicit combiner: Combiner[A, B]): PathCodec[combiner.Out] =
55-
self ++ Segment[B](segment)
56-
5751
final def asType[B](implicit ev: A =:= B): PathCodec[B] = self.asInstanceOf[PathCodec[B]]
5852

5953
/**
@@ -358,9 +352,14 @@ object PathCodec {
358352
def apply(value: String): PathCodec[Unit] = {
359353
val path = Path(value)
360354

361-
path.segments.foldLeft[PathCodec[Unit]](PathCodec.empty) { (pathSpec, segment) =>
362-
pathSpec./[Unit](SegmentCodec.literal(segment))
355+
path.segments match {
356+
case Chunk() => PathCodec.empty
357+
case Chunk(first, rest @ _*) =>
358+
rest.foldLeft[PathCodec[Unit]](Segment(SegmentCodec.literal(first))) { (pathSpec, segment) =>
359+
pathSpec / Segment(SegmentCodec.literal(segment))
360+
}
363361
}
362+
364363
}
365364

366365
def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.bool(name))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package zio.http.endpoint.openapi
2+
3+
import java.net.URLEncoder
4+
5+
import zio.http._
6+
import zio.http.codec.PathCodec
7+
8+
object SwaggerUI {
9+
10+
val DefaultSwaggerUIVersion: String = "5.10.3"
11+
12+
//format: off
13+
/**
14+
* Creates routes for serving the Swagger UI at the given path.
15+
*
16+
* Example:
17+
* {{{
18+
* val routes: Routes[Any, Response] = ???
19+
* val openAPIv1: OpenAPI = ???
20+
* val openAPIv2: OpenAPI = ???
21+
* val swaggerUIRoutes = SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)
22+
* val routesWithSwagger = routes ++ swaggerUIRoutes
23+
* }}}
24+
*
25+
* With this middleware in place, a request to `https://www.domain.com/[path]`
26+
* would serve the Swagger UI. The different OpenAPI specifications are served
27+
* at `https://www.domain.com/[path]/[title].json`. Where `title` is the title
28+
* of the OpenAPI specification and is url encoded.
29+
*/
30+
//format: on
31+
def routes(path: PathCodec[Unit], api: OpenAPI, apis: OpenAPI*): Routes[Any, Response] = {
32+
routes(path, DefaultSwaggerUIVersion, api, apis: _*)
33+
}
34+
35+
//format: off
36+
/**
37+
* Creates a middleware for serving the Swagger UI at the given path and with
38+
* the given swagger ui version.
39+
*
40+
* Example:
41+
* {{{
42+
* val routes: Routes[Any, Response] = ???
43+
* val openAPIv1: OpenAPI = ???
44+
* val openAPIv2: OpenAPI = ???
45+
* val swaggerUIRoutes = SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)
46+
* val routesWithSwagger = routes ++ swaggerUIRoutes
47+
* }}}
48+
*
49+
* With this middleware in place, a request to `https://www.domain.com/[path]`
50+
* would serve the Swagger UI. The different OpenAPI specifications are served
51+
* at `https://www.domain.com/[path]/[title].json`. Where `title` is the title
52+
* of the OpenAPI specification and is url encoded.
53+
*/
54+
//format: on
55+
def routes(path: PathCodec[Unit], version: String, api: OpenAPI, apis: OpenAPI*): Routes[Any, Response] = {
56+
import zio.http.template._
57+
val basePath = Method.GET / path
58+
val jsonRoutes = (api +: apis).map { api =>
59+
basePath / s"${URLEncoder.encode(api.info.title, Charsets.Utf8.name())}.json" -> handler { (_: Request) =>
60+
Response.json(api.toJson)
61+
}
62+
}
63+
val jsonPaths = jsonRoutes.map(_.routePattern.pathCodec.render)
64+
val jsonTitles = (api +: apis).map(_.info.title)
65+
val jsonUrls = jsonTitles.zip(jsonPaths).map { case (title, path) => s"""{url: "$path", name: "$title"}""" }
66+
val uiRoute = basePath -> handler { (_: Request) =>
67+
Response.html(
68+
html(
69+
head(
70+
meta(charsetAttr := "utf-8"),
71+
meta(nameAttr := "viewport", contentAttr := "width=device-width, initial-scale=1"),
72+
meta(nameAttr := "description", contentAttr := "SwaggerUI"),
73+
title("SwaggerUI"),
74+
link(relAttr := "stylesheet", href := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui.css"),
75+
link(
76+
relAttr := "icon",
77+
typeAttr := "image/png",
78+
href := s"https://unpkg.com/swagger-ui-dist@$version/favicon-32x32.png",
79+
),
80+
),
81+
body(
82+
div(id := "swagger-ui"),
83+
script(srcAttr := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui-bundle.js"),
84+
script(srcAttr := s"https://unpkg.com/swagger-ui-dist@$version/swagger-ui-standalone-preset.js"),
85+
Dom.raw(s"""<script>
86+
|window.onload = () => {
87+
| window.ui = SwaggerUIBundle({
88+
| urls: ${jsonUrls.mkString("[\n", ",\n", "\n]")},
89+
| dom_id: '#swagger-ui',
90+
| presets: [
91+
| SwaggerUIBundle.presets.apis,
92+
| SwaggerUIStandalonePreset
93+
| ],
94+
| layout: "StandaloneLayout",
95+
| });
96+
|};
97+
|</script>""".stripMargin),
98+
),
99+
),
100+
)
101+
}
102+
Routes.fromIterable(jsonRoutes) :+ uiRoute
103+
}
104+
}

zio-http/src/test/scala/zio/http/codec/PathCodecSpec.scala

+20-20
Original file line numberDiff line numberDiff line change
@@ -41,28 +41,28 @@ object PathCodecSpec extends ZIOHttpSpec {
4141
test("/users") {
4242
val codec = PathCodec.path("/users")
4343

44-
assertTrue(codec.segments.length == 2)
44+
assertTrue(codec.segments.length == 1)
4545
},
4646
test("/users/{user-id}/posts/{post-id}") {
4747
val codec =
48-
PathCodec.path("/users") / SegmentCodec.int("user-id") / SegmentCodec.literal("posts") / SegmentCodec
48+
PathCodec.path("/users") / PathCodec.int("user-id") / PathCodec.literal("posts") / PathCodec
4949
.string(
5050
"post-id",
5151
)
5252

53-
assertTrue(codec.segments.length == 5)
53+
assertTrue(codec.segments.length == 4)
5454
},
5555
test("transformed") {
5656
val codec =
5757
PathCodec.path("/users") /
58-
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
59-
SegmentCodec.literal("posts") /
60-
SegmentCodec
58+
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
59+
PathCodec.literal("posts") /
60+
PathCodec
6161
.string("post-id")
6262
.transformOrFailLeft(s =>
6363
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
6464
)(_.value)
65-
assertTrue(codec.segments.length == 5)
65+
assertTrue(codec.segments.length == 4)
6666
},
6767
),
6868
suite("decoding")(
@@ -86,14 +86,14 @@ object PathCodecSpec extends ZIOHttpSpec {
8686
assertTrue(codec.decode(Path("/users")) == Right(Path("/users")))
8787
},
8888
test("/users") {
89-
val codec = PathCodec.empty / SegmentCodec.literal("users")
89+
val codec = PathCodec.empty / PathCodec.literal("users")
9090

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

9898
val codec = codec1 ++ codec2
9999

@@ -102,9 +102,9 @@ object PathCodecSpec extends ZIOHttpSpec {
102102
test("transformed") {
103103
val codec =
104104
PathCodec.path("/users") /
105-
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
106-
SegmentCodec.literal("posts") /
107-
SegmentCodec
105+
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
106+
PathCodec.literal("posts") /
107+
PathCodec
108108
.string("post-id")
109109
.transformOrFailLeft(s =>
110110
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
@@ -122,7 +122,7 @@ object PathCodecSpec extends ZIOHttpSpec {
122122
assertTrue(codec.segments == Chunk(SegmentCodec.empty))
123123
},
124124
test("/users") {
125-
val codec = PathCodec.empty / SegmentCodec.literal("users")
125+
val codec = PathCodec.empty / PathCodec.literal("users")
126126

127127
assertTrue(
128128
codec.segments ==
@@ -137,24 +137,24 @@ object PathCodecSpec extends ZIOHttpSpec {
137137
assertTrue(codec.render == "")
138138
},
139139
test("/users") {
140-
val codec = PathCodec.empty / SegmentCodec.literal("users")
140+
val codec = PathCodec.empty / PathCodec.literal("users")
141141

142142
assertTrue(codec.render == "/users")
143143
},
144144
test("/users/{user-id}/posts/{post-id}") {
145145
val codec =
146-
PathCodec.empty / SegmentCodec.literal("users") / SegmentCodec.int("user-id") / SegmentCodec.literal(
146+
PathCodec.empty / PathCodec.literal("users") / PathCodec.int("user-id") / PathCodec.literal(
147147
"posts",
148-
) / SegmentCodec.string("post-id")
148+
) / PathCodec.string("post-id")
149149

150150
assertTrue(codec.render == "/users/{user-id}/posts/{post-id}")
151151
},
152152
test("transformed") {
153153
val codec =
154154
PathCodec.path("/users") /
155-
SegmentCodec.int("user-id").transform(UserId.apply)(_.value) /
156-
SegmentCodec.literal("posts") /
157-
SegmentCodec
155+
PathCodec.int("user-id").transform(UserId.apply)(_.value) /
156+
PathCodec.literal("posts") /
157+
PathCodec
158158
.string("post-id")
159159
.transformOrFailLeft(s =>
160160
Try(s.toInt).toEither.left.map(_ => "Not a number").map(n => PostId(n.toString)),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package zio.http.endpoint.openapi
2+
3+
import zio._
4+
import zio.test._
5+
6+
import zio.http._
7+
import zio.http.codec.HttpCodec.query
8+
import zio.http.codec.PathCodec.path
9+
import zio.http.endpoint.Endpoint
10+
11+
object SwaggerUISpec extends ZIOSpecDefault {
12+
13+
override def spec: Spec[TestEnvironment with Scope, Any] =
14+
suite("SwaggerUI")(
15+
test("should return the swagger ui page") {
16+
val getUser = Endpoint(Method.GET / "users" / int("userId")).out[Int]
17+
18+
val getUserRoute = getUser.implement { Handler.fromFunction[Int] { id => id } }
19+
20+
val getUserPosts =
21+
Endpoint(Method.GET / "users" / int("userId") / "posts" / int("postId"))
22+
.query(query("name"))
23+
.out[List[String]]
24+
25+
val getUserPostsRoute =
26+
getUserPosts.implement[Any] {
27+
Handler.fromFunctionZIO[(Int, Int, String)] { case (id1: Int, id2: Int, query: String) =>
28+
ZIO.succeed(List(s"API2 RESULT parsed: users/$id1/posts/$id2?name=$query"))
29+
}
30+
}
31+
32+
val openAPIv1 = OpenAPIGen.fromEndpoints(title = "Endpoint Example", version = "1.0", getUser, getUserPosts)
33+
val openAPIv2 =
34+
OpenAPIGen.fromEndpoints(title = "Another Endpoint Example", version = "2.0", getUser, getUserPosts)
35+
36+
val routes =
37+
Routes(getUserRoute, getUserPostsRoute) ++ SwaggerUI.routes("docs" / "openapi", openAPIv1, openAPIv2)
38+
39+
val response = routes.apply(Request(method = Method.GET, url = url"/docs/openapi"))
40+
41+
val expectedHtml =
42+
"""<!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>
43+
|window.onload = () => {
44+
| window.ui = SwaggerUIBundle({
45+
| urls: [
46+
|{url: "/docs/openapi/Endpoint+Example.json", name: "Endpoint Example"},
47+
|{url: "/docs/openapi/Another+Endpoint+Example.json", name: "Another Endpoint Example"}
48+
|],
49+
| dom_id: '#swagger-ui',
50+
| presets: [
51+
| SwaggerUIBundle.presets.apis,
52+
| SwaggerUIStandalonePreset
53+
| ],
54+
| layout: "StandaloneLayout",
55+
| });
56+
|};
57+
|</script></body></html>""".stripMargin
58+
59+
for {
60+
res <- response
61+
body <- res.body.asString
62+
} yield {
63+
assertTrue(body == expectedHtml)
64+
}
65+
},
66+
)
67+
}

0 commit comments

Comments
 (0)