From ef83dce72593291d9f67f3afddf7bf8a5be28203 Mon Sep 17 00:00:00 2001 From: David Weiser Date: Fri, 14 Aug 2020 14:49:56 -0700 Subject: [PATCH 1/8] add ability to prefix routes of docs --- options.go | 7 +++++++ router.go | 8 +++++--- router_test.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/options.go b/options.go index dc4eb69..a9e0ab3 100644 --- a/options.go +++ b/options.go @@ -301,6 +301,13 @@ func ContactEmail(name, email string) RouterOption { }} } +// DocsRoutePrefix enables the API documentation to be available from `prefix/{docs, openapi.yaml}` +func DocsRoutePrefix(prefix string) RouterOption { + return &routerOption{func(r *Router) { + r.docsPrefix = prefix + }} +} + // BasicAuth adds a named HTTP Basic Auth security scheme. func BasicAuth(name string) RouterOption { return &routerOption{func(r *Router) { diff --git a/router.go b/router.go index 942577c..b65f591 100644 --- a/router.go +++ b/router.go @@ -319,6 +319,7 @@ type Router struct { root *cobra.Command prestart []func() docsHandler Handler + docsPrefix string corsHandler Handler // Tracks the currently running server for graceful shutdown. @@ -359,6 +360,7 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { engine: g, prestart: []func(){}, docsHandler: RapiDocHandler(title), + docsPrefix: "", corsHandler: cors.Default(), } @@ -380,10 +382,10 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { } // Set up handlers for the auto-generated spec and docs. - r.engine.GET("/openapi.json", openAPIHandlerJSON(r)) - r.engine.GET("/openapi.yaml", openAPIHandlerYAML(r)) + r.engine.GET(fmt.Sprintf("%s/openapi.json", r.docsPrefix), openAPIHandlerJSON(r)) + r.engine.GET(fmt.Sprintf("%s/openapi.yaml", r.docsPrefix), openAPIHandlerYAML(r)) - r.engine.GET("/docs", func(c *gin.Context) { + r.engine.GET(fmt.Sprintf("%s/docs", r.docsPrefix), func(c *gin.Context) { r.docsHandler(c) }) diff --git a/router_test.go b/router_test.go index 7178938..54ec17f 100644 --- a/router_test.go +++ b/router_test.go @@ -266,6 +266,43 @@ func TestRouter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestRouterDocsPrefix(t *testing.T) { + type EchoResponse struct { + Value string `json:"value" description:"The echoed back word"` + } + + r := NewTestRouter(t, DocsRoutePrefix("/prefix")) + + r.Resource("/echo", + PathParam("word", "The word to echo back"), + QueryParam("greet", "Return a greeting", false), + ResponseJSON(http.StatusOK, "Successful echo response"), + ResponseError(http.StatusBadRequest, "Invalid input"), + ).Put("Echo back an input word.", func(word string, greet bool) (*EchoResponse, *ErrorModel) { + if word == "test" { + return nil, &ErrorModel{Detail: "Value not allowed: test"} + } + + v := word + if greet { + v = "Hello, " + word + } + + return &EchoResponse{Value: v}, nil + }) + + // Check spec & docs routes + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/prefix/openapi.json", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/prefix/docs", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + func TestRouterRequestBody(t *testing.T) { type EchoRequest struct { Value string `json:"value"` From 75d43f149d2ee745cc13c9dc21c2d262eeb1a96c Mon Sep 17 00:00:00 2001 From: David Weiser Date: Mon, 17 Aug 2020 14:25:11 -0700 Subject: [PATCH 2/8] ensure the dom contains the prefixed path to the openapi file --- router_test.go | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/router_test.go b/router_test.go index 54ec17f..dbce532 100644 --- a/router_test.go +++ b/router_test.go @@ -267,29 +267,9 @@ func TestRouter(t *testing.T) { } func TestRouterDocsPrefix(t *testing.T) { - type EchoResponse struct { - Value string `json:"value" description:"The echoed back word"` - } - r := NewTestRouter(t, DocsRoutePrefix("/prefix")) - - r.Resource("/echo", - PathParam("word", "The word to echo back"), - QueryParam("greet", "Return a greeting", false), - ResponseJSON(http.StatusOK, "Successful echo response"), - ResponseError(http.StatusBadRequest, "Invalid input"), - ).Put("Echo back an input word.", func(word string, greet bool) (*EchoResponse, *ErrorModel) { - if word == "test" { - return nil, &ErrorModel{Detail: "Value not allowed: test"} - } - - v := word - if greet { - v = "Hello, " + word - } - - return &EchoResponse{Value: v}, nil - }) + r := NewRouter("api", "v", DocsRoutePrefix("/prefix")) + r.Resource("/hello").Get("doc", func() string { return "Hello" }) // Check spec & docs routes w := httptest.NewRecorder() @@ -301,6 +281,7 @@ func TestRouterDocsPrefix(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "/prefix/docs", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, "prefix/openapi", w.Body.String()) } func TestRouterRequestBody(t *testing.T) { From 7f278d5904f54608064933fd8b65c96c862e35a7 Mon Sep 17 00:00:00 2001 From: David Weiser Date: Tue, 18 Aug 2020 11:16:27 -0700 Subject: [PATCH 3/8] ensure that docs ui renders with correct path to openapi --- docs.go | 56 ++++++++++++++++++++++++++++++-------------------- options.go | 11 ++++++++++ router.go | 16 +++++++++++++-- router_test.go | 2 +- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/docs.go b/docs.go index 737d729..f332732 100644 --- a/docs.go +++ b/docs.go @@ -19,10 +19,10 @@ func splitDocs(docs string) (title, desc string) { return } -// RapiDocHandler renders documentation using RapiDoc. -func RapiDocHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(` +// RapiDocTemplate is the template used to generate the RapiDoc. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file +var RapiDocTemplate = ` %s @@ -31,21 +31,19 @@ func RapiDocHandler(pageTitle string) Handler { -`, pageTitle))) - } -} +` -// ReDocHandler renders documentation using ReDoc. -func ReDocHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(` +// ReDocTemplate is the template used to generate the RapiDoc. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file +var ReDocTemplate = ` %s @@ -55,17 +53,12 @@ func ReDocHandler(pageTitle string) Handler { - + -`, pageTitle))) - } -} +` -// SwaggerUIHandler renders documentation using Swagger UI. -func SwaggerUIHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(` +var SwaggerUITemplate = ` @@ -104,7 +97,7 @@ func SwaggerUIHandler(pageTitle string) Handler { window.onload = function() { // Begin Swagger UI call region const ui = SwaggerUIBundle({ - url: "/openapi.json", + url: "%s", dom_id: '#swagger-ui', deepLinking: true, presets: [ @@ -122,6 +115,25 @@ func SwaggerUIHandler(pageTitle string) Handler { } -`, pageTitle))) +` + +// RapiDocHandler renders documentation using RapiDoc. +func RapiDocHandler(pageTitle string) Handler { + return func(c *gin.Context) { + c.Data(200, "text/html", []byte(fmt.Sprintf(RapiDocTemplate, pageTitle, "/openapi.json"))) + } +} + +// ReDocHandler renders documentation using ReDoc. +func ReDocHandler(pageTitle string) Handler { + return func(c *gin.Context) { + c.Data(200, "text/html", []byte(fmt.Sprintf(ReDocTemplate, pageTitle, "/openapi.json"))) + } +} + +// SwaggerUIHandler renders documentation using Swagger UI. +func SwaggerUIHandler(pageTitle string) Handler { + return func(c *gin.Context) { + c.Data(200, "text/html", []byte(fmt.Sprintf(SwaggerUITemplate, pageTitle, "/openapi.json"))) } } diff --git a/options.go b/options.go index a9e0ab3..573e698 100644 --- a/options.go +++ b/options.go @@ -376,12 +376,23 @@ func HTTPServer(server *http.Server) RouterOption { // DocsHandler sets the documentation rendering handler function. You can // use `huma.RapiDocHandler`, `huma.ReDocHandler`, `huma.SwaggerUIHandler`, or // provide your own (e.g. with custom auth or branding). +// +// DEPRECATED! Use `DocsDomType` instead! func DocsHandler(f Handler) RouterOption { + fmt.Println("This option is deprecated, use `DocsDomType` instead") return &routerOption{func(r *Router) { r.docsHandler = f }} } +// DocsDomType sets the presentation for the docs UI. Valid values are: +// "rapi" (default), "redoc", or "swagger" +func DocsDomType(t string) RouterOption { + return &routerOption{func(r *Router) { + r.docsDomType = t + }} +} + // CORSHandler sets the CORS handler function. This can be used to set custom // domains, headers, auth, etc. If not given, then a default CORS handler is // used instead. diff --git a/router.go b/router.go index b65f591..685ec12 100644 --- a/router.go +++ b/router.go @@ -319,6 +319,7 @@ type Router struct { root *cobra.Command prestart []func() docsHandler Handler + docsDomType string docsPrefix string corsHandler Handler @@ -360,6 +361,7 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { engine: g, prestart: []func(){}, docsHandler: RapiDocHandler(title), + docsDomType: "rapi", docsPrefix: "", corsHandler: cors.Default(), } @@ -382,11 +384,21 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { } // Set up handlers for the auto-generated spec and docs. - r.engine.GET(fmt.Sprintf("%s/openapi.json", r.docsPrefix), openAPIHandlerJSON(r)) + openapiJsonPath := fmt.Sprintf("%s/openapi.json", r.docsPrefix) + r.engine.GET(openapiJsonPath, openAPIHandlerJSON(r)) r.engine.GET(fmt.Sprintf("%s/openapi.yaml", r.docsPrefix), openAPIHandlerYAML(r)) r.engine.GET(fmt.Sprintf("%s/docs", r.docsPrefix), func(c *gin.Context) { - r.docsHandler(c) + docsPayload := "" + switch r.docsDomType { + case "rapi": + docsPayload = fmt.Sprintf(RapiDocTemplate, title, openapiJsonPath) + case "swagger": + docsPayload = fmt.Sprintf(SwaggerUITemplate, title, openapiJsonPath) + case "redoc": + docsPayload = fmt.Sprintf(ReDocTemplate, title, openapiJsonPath) + } + c.Data(200, "text/html", []byte(docsPayload)) }) // If downloads like a CLI or SDKs are available, serve them automatically diff --git a/router_test.go b/router_test.go index dbce532..a8b540c 100644 --- a/router_test.go +++ b/router_test.go @@ -281,7 +281,7 @@ func TestRouterDocsPrefix(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "/prefix/docs", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, "prefix/openapi", w.Body.String()) + assert.Contains(t, w.Body.String(), "prefix/openapi") } func TestRouterRequestBody(t *testing.T) { From 30730991927c9850ba0bbc9199410c366ef76d84 Mon Sep 17 00:00:00 2001 From: David Weiser Date: Wed, 19 Aug 2020 11:26:55 -0700 Subject: [PATCH 4/8] instead of templates, use functions --- docs.go | 33 +++++++++++++++++++++++++++------ router.go | 6 +++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs.go b/docs.go index f332732..cc7085b 100644 --- a/docs.go +++ b/docs.go @@ -22,7 +22,7 @@ func splitDocs(docs string) (title, desc string) { // RapiDocTemplate is the template used to generate the RapiDoc. It needs two args to render: // 1. the title // 2. the path to the openapi.yaml file -var RapiDocTemplate = ` +var rapiDocTemplate = ` %s @@ -43,7 +43,7 @@ var RapiDocTemplate = ` // ReDocTemplate is the template used to generate the RapiDoc. It needs two args to render: // 1. the title // 2. the path to the openapi.yaml file -var ReDocTemplate = ` +var reDocTemplate = ` %s @@ -58,7 +58,7 @@ var ReDocTemplate = ` ` -var SwaggerUITemplate = ` +var swaggerUITemplate = ` @@ -117,23 +117,44 @@ var SwaggerUITemplate = ` ` +// RapiDocString generates the RapiDoc. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file +func RapiDocString(pageTitle, openapiPath string) string { + return fmt.Sprintf(rapiDocTemplate, pageTitle, openapiPath) +} + +// ReDocString generates the RapiDoc. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file +func ReDocString(pageTitle, openapiPath string) string { + return fmt.Sprintf(reDocTemplate, pageTitle, openapiPath) +} + +// SwaggerUIDocString generates the RapiDoc. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file +func SwaggerUIDocString(pageTitle, openapiPath string) string { + return fmt.Sprintf(swaggerUITemplate, pageTitle, openapiPath) +} + // RapiDocHandler renders documentation using RapiDoc. func RapiDocHandler(pageTitle string) Handler { return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(RapiDocTemplate, pageTitle, "/openapi.json"))) + c.Data(200, "text/html", []byte(RapiDocString(pageTitle, "/openapi.json"))) } } // ReDocHandler renders documentation using ReDoc. func ReDocHandler(pageTitle string) Handler { return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(ReDocTemplate, pageTitle, "/openapi.json"))) + c.Data(200, "text/html", []byte(ReDocString(pageTitle, "/openapi.json"))) } } // SwaggerUIHandler renders documentation using Swagger UI. func SwaggerUIHandler(pageTitle string) Handler { return func(c *gin.Context) { - c.Data(200, "text/html", []byte(fmt.Sprintf(SwaggerUITemplate, pageTitle, "/openapi.json"))) + c.Data(200, "text/html", []byte(SwaggerUIDocString(pageTitle, "/openapi.json"))) } } diff --git a/router.go b/router.go index 685ec12..81fddf5 100644 --- a/router.go +++ b/router.go @@ -392,11 +392,11 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { docsPayload := "" switch r.docsDomType { case "rapi": - docsPayload = fmt.Sprintf(RapiDocTemplate, title, openapiJsonPath) + docsPayload = RapiDocString(title, openapiJsonPath) case "swagger": - docsPayload = fmt.Sprintf(SwaggerUITemplate, title, openapiJsonPath) + docsPayload = SwaggerUIDocString(title, openapiJsonPath) case "redoc": - docsPayload = fmt.Sprintf(ReDocTemplate, title, openapiJsonPath) + docsPayload = ReDocString(title, openapiJsonPath) } c.Data(200, "text/html", []byte(docsPayload)) }) From 1c060b3855f96f32db748d4ef638cebe7c382a06 Mon Sep 17 00:00:00 2001 From: David Weiser Date: Wed, 19 Aug 2020 11:43:46 -0700 Subject: [PATCH 5/8] use enum instead of magic strings for doc ui type --- options.go | 13 +++++++++++-- router.go | 10 +++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/options.go b/options.go index 573e698..1751c53 100644 --- a/options.go +++ b/options.go @@ -20,6 +20,15 @@ type routerOption struct { handler func(*Router) } +// ApiUIDocType represents the type of UI presentation for the API docs: Rapi, ReDoc, or Swagger +type ApiUIDocType int + +const ( + RAPIDOCTYPE ApiUIDocType = 1 + iota + REDOCTYPE + SWAGGERDOCTYPE +) + func (o *routerOption) ApplyRouter(router *Router) { o.handler(router) } @@ -386,8 +395,8 @@ func DocsHandler(f Handler) RouterOption { } // DocsDomType sets the presentation for the docs UI. Valid values are: -// "rapi" (default), "redoc", or "swagger" -func DocsDomType(t string) RouterOption { +// RAPIDOCTYPE (default), REDOCTYPE, or SWAGGERDOCTYPE +func DocsDomType(t ApiUIDocType) RouterOption { return &routerOption{func(r *Router) { r.docsDomType = t }} diff --git a/router.go b/router.go index 81fddf5..4075b74 100644 --- a/router.go +++ b/router.go @@ -319,7 +319,7 @@ type Router struct { root *cobra.Command prestart []func() docsHandler Handler - docsDomType string + docsDomType ApiUIDocType docsPrefix string corsHandler Handler @@ -361,7 +361,7 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { engine: g, prestart: []func(){}, docsHandler: RapiDocHandler(title), - docsDomType: "rapi", + docsDomType: RAPIDOCTYPE, docsPrefix: "", corsHandler: cors.Default(), } @@ -391,11 +391,11 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { r.engine.GET(fmt.Sprintf("%s/docs", r.docsPrefix), func(c *gin.Context) { docsPayload := "" switch r.docsDomType { - case "rapi": + case RAPIDOCTYPE: docsPayload = RapiDocString(title, openapiJsonPath) - case "swagger": + case SWAGGERDOCTYPE: docsPayload = SwaggerUIDocString(title, openapiJsonPath) - case "redoc": + case REDOCTYPE: docsPayload = ReDocString(title, openapiJsonPath) } c.Data(200, "text/html", []byte(docsPayload)) From a6c9c108bb01eb941049510206c20ab3f737147d Mon Sep 17 00:00:00 2001 From: David Weiser Date: Thu, 20 Aug 2020 10:09:20 -0700 Subject: [PATCH 6/8] ensure that docs prefix gets put into service links --- middleware.go | 11 ++++++++--- middleware_test.go | 20 ++++++++++++++++++++ router.go | 6 ++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/middleware.go b/middleware.go index 4141b70..a4a735c 100644 --- a/middleware.go +++ b/middleware.go @@ -292,15 +292,19 @@ func PreferMinimalMiddleware() Middleware { } } -// AddServiceLinks addds RFC 8631 `service-desc` and `service-doc` link +// AddServiceLinks adds RFC 8631 `service-desc` and `service-doc` link // relations to the response. Safe to call multiple times and after a link // header has already been set (it will append to it). func AddServiceLinks(c *gin.Context) { link := c.Writer.Header().Get("link") + docsPrefix, exists := c.Get("docsPrefix") + if !exists { + docsPrefix = "" + } if link != "" { link += ", " } - link += `; rel="service-desc", ; rel="service-doc"` + link += fmt.Sprintf(`<%s/openapi.json>; rel="service-desc", <%s/docs>; rel="service-doc"`, docsPrefix, docsPrefix) c.Header("link", link) } @@ -308,7 +312,8 @@ func AddServiceLinks(c *gin.Context) { // relations to the root response of the API. func ServiceLinkMiddleware() Middleware { return func(c *gin.Context) { - if c.Request.URL.Path == "/" { + docsPrefix, exists := c.Get("docsPrefix") + if (exists && c.Request.URL.Path == docsPrefix) || c.Request.URL.Path == "/" { AddServiceLinks(c) } c.Next() diff --git a/middleware_test.go b/middleware_test.go index 2a2572b..0605565 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -135,6 +135,26 @@ func TestServiceLinksExists(t *testing.T) { assert.Equal(t, w.Result().StatusCode, http.StatusOK) assert.Contains(t, w.Result().Header.Get("link"), "service-desc") assert.Contains(t, w.Result().Header.Get("link"), "service-doc") + assert.Contains(t, w.Result().Header.Get("link"), "; rel=self`) + AddServiceLinks(c) + c.Data(http.StatusOK, "text/plain", []byte("Hello")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/prefix", nil) + r.ServeHTTP(w, req) + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Contains(t, w.Result().Header.Get("link"), "/prefix/openapi.json") + assert.Contains(t, w.Result().Header.Get("link"), "/prefix/docs") } func TestContentEncodingTooSmall(t *testing.T) { diff --git a/router.go b/router.go index 4075b74..8656ef7 100644 --- a/router.go +++ b/router.go @@ -378,6 +378,12 @@ func NewRouter(docs, version string, options ...RouterOption) *Router { r.corsHandler(c) }) + // We need to ensure that if the docs have a prefixed path, + // that the ServiceLinkMiddleware can reflect the true path to the docs + r.GinEngine().Use(func(c *gin.Context) { + c.Set("docsPrefix", r.docsPrefix) + }) + // Validate the router/API setup. if err := r.api.validate(); err != nil { panic(err) From 2797ef0e30e847bc979ef022e26ab173156c37bf Mon Sep 17 00:00:00 2001 From: David Weiser Date: Fri, 21 Aug 2020 11:26:44 -0700 Subject: [PATCH 7/8] only add service links at root --- middleware.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/middleware.go b/middleware.go index a4a735c..8c86e9d 100644 --- a/middleware.go +++ b/middleware.go @@ -312,8 +312,7 @@ func AddServiceLinks(c *gin.Context) { // relations to the root response of the API. func ServiceLinkMiddleware() Middleware { return func(c *gin.Context) { - docsPrefix, exists := c.Get("docsPrefix") - if (exists && c.Request.URL.Path == docsPrefix) || c.Request.URL.Path == "/" { + if c.Request.URL.Path == "/" { AddServiceLinks(c) } c.Next() From 8ce059a4ea0e5df4045075262293d7d8777ed1fe Mon Sep 17 00:00:00 2001 From: David Weiser Date: Fri, 21 Aug 2020 11:30:18 -0700 Subject: [PATCH 8/8] fix comments --- docs.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs.go b/docs.go index cc7085b..c3cce82 100644 --- a/docs.go +++ b/docs.go @@ -19,7 +19,7 @@ func splitDocs(docs string) (title, desc string) { return } -// RapiDocTemplate is the template used to generate the RapiDoc. It needs two args to render: +// rapiDocTemplate is the template used to generate the RapiDoc. It needs two args to render: // 1. the title // 2. the path to the openapi.yaml file var rapiDocTemplate = ` @@ -40,7 +40,7 @@ var rapiDocTemplate = ` ` -// ReDocTemplate is the template used to generate the RapiDoc. It needs two args to render: +// reDocTemplate is the template used to generate the ReDoc. It needs two args to render: // 1. the title // 2. the path to the openapi.yaml file var reDocTemplate = ` @@ -58,6 +58,9 @@ var reDocTemplate = ` ` +// swaggerUITemplate is the template used to generate the SwaggerUI. It needs two args to render: +// 1. the title +// 2. the path to the openapi.yaml file var swaggerUITemplate = `