Skip to content

Commit

Permalink
feat: more options for UI middleware
Browse files Browse the repository at this point in the history
- refactored UI middleware
  * factorized chore middleware to remove duplicated code
  * factorized UI middleware options: to avoid breaking changes in the
    options types, there is a decode/encode to a common structure
  * added more options:
    * allows to fully customize the UI template
  * added more unit tests

- Spec middleware: added support for optional SpecOption argument
  * allows to serve the spec from a custom path / document name

- serving with or without trailing "/" (cf. issue #238)
  * replaced path.Join() by path.Clean(), which is the intended behavior
    (i.e. serve the path, irrespective of the presence of a trailing
    slash)
  * generalized this behavior to all UI and Spec middleware, not just
    swaggerUI

- API Context:
  * exposed middleware to serve RapiDoc UI
  * allowed new UIOption (...UIOption) to the APIHandler, etc middleware
  * coordinated UI / Spec middleware to be consistent when non-default
    path/document URL is served

* fixes #192
* fixes #226

Signed-off-by: Frederic BIDON <[email protected]>
  • Loading branch information
fredbi committed Dec 12, 2023
1 parent e9d312a commit fe71d27
Show file tree
Hide file tree
Showing 15 changed files with 1,009 additions and 269 deletions.
81 changes: 65 additions & 16 deletions middleware/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
stdContext "context"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"sync"

Expand Down Expand Up @@ -584,45 +586,92 @@ func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []st
c.api.ServeErrorFor(route.Operation.ID)(rw, r, errors.New(http.StatusInternalServerError, "can't produce response"))
}

func (c *Context) APIHandlerSwaggerUI(builder Builder) http.Handler {
// APIHandlerSwaggerUI returns a handler to serve the API.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI (SwaggerUI) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}

var title string
sp := c.spec.Spec()
if sp != nil && sp.Info != nil && sp.Info.Title != "" {
title = sp.Info.Title
}
specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var swaggerUIOpts SwaggerUIOpts
fromCommonToAnyOptions(uiOpts, &swaggerUIOpts)

return Spec(specPath, c.spec.Raw(), SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...)
}

swaggerUIOpts := SwaggerUIOpts{
BasePath: c.BasePath(),
Title: title,
// APIHandlerRapiDoc returns a handler to serve the API.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI (RapiDoc) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}

return Spec("", c.spec.Raw(), SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)))
specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var rapidocUIOpts RapiDocOpts
fromCommonToAnyOptions(uiOpts, &rapidocUIOpts)

return Spec(specPath, c.spec.Raw(), RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...)
}

// APIHandler returns a handler to serve the API, this includes a swagger spec, router and the contract defined in the swagger spec
func (c *Context) APIHandler(builder Builder) http.Handler {
// APIHandler returns a handler to serve the API.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI (Redoc) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
func (c *Context) APIHandler(builder Builder, opts ...UIOption) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}

specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var redocOpts RedocOpts
fromCommonToAnyOptions(uiOpts, &redocOpts)

return Spec(specPath, c.spec.Raw(), Redoc(redocOpts, c.RoutesHandler(b)), specOpts...)
}

func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []SpecOption) {
var title string
sp := c.spec.Spec()
if sp != nil && sp.Info != nil && sp.Info.Title != "" {
title = sp.Info.Title
}

redocOpts := RedocOpts{
BasePath: c.BasePath(),
Title: title,
// default options (may be overridden)
optsForContext := []UIOption{
WithUIBasePath(c.BasePath()),
WithUITitle(title),
}
optsForContext = append(optsForContext, opts...)
uiOpts := uiOptionsWithDefaults(optsForContext)

// If spec URL is provided, there is a non-default path to serve the spec.
// This makes sure that the UI middleware is aligned with the Spec middleware.
u, _ := url.Parse(uiOpts.SpecURL)
var specPath string
if u != nil {
specPath = u.Path
}

pth, doc := path.Split(specPath)
if pth == "." {
pth = ""
}

return Spec("", c.spec.Raw(), Redoc(redocOpts, c.RoutesHandler(b)))
return pth, uiOpts, []SpecOption{WithSpecDocument(doc)}
}

// RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec
Expand Down
227 changes: 212 additions & 15 deletions middleware/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package middleware
import (
stdcontext "context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -32,8 +33,6 @@ import (
"github.com/stretchr/testify/require"
)

const applicationJSON = "application/json"

type stubBindRequester struct {
}

Expand Down Expand Up @@ -131,28 +130,226 @@ func TestContentType_Issue174(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
}

const (
testHost = "https://localhost:8080"

// how to get the spec document?
defaultSpecPath = "/swagger.json"
defaultSpecURL = testHost + defaultSpecPath
// how to get the UI asset?
defaultUIURL = testHost + "/api/docs"
)

func TestServe(t *testing.T) {
spec, api := petstore.NewAPI(t)
handler := Serve(spec, api)

// serve spec document
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, "https://localhost:8080/swagger.json", nil)
require.NoError(t, err)
t.Run("serve spec document", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultSpecURL, nil)
require.NoError(t, err)

request.Header.Add("Content-Type", runtime.JSONMime)
request.Header.Add("Accept", runtime.JSONMime)
recorder := httptest.NewRecorder()
request.Header.Add("Content-Type", runtime.JSONMime)
request.Header.Add("Accept", runtime.JSONMime)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})

request, err = http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, "https://localhost:8080/swagger-ui", nil)
require.NoError(t, err)
t.Run("should not find UI there", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, testHost+"/swagger-ui", nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
})

handler.ServeHTTP(recorder, request)
assert.Equal(t, 404, recorder.Code)
t.Run("should find UI here", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Containsf(t, htmlResponse, "<title>Swagger Petstore</title>", "should default to the API's title")
assert.Containsf(t, htmlResponse, "<redoc", "should default to Redoc UI")
assert.Containsf(t, htmlResponse, "spec-url='/swagger.json'>", "should default to /swagger.json spec document")
})
}

func TestServeWithUIs(t *testing.T) {
spec, api := petstore.NewAPI(t)
ctx := NewContext(spec, api, nil)

const (
alternateSpecURL = testHost + "/specs/petstore.json"
alternateSpecPath = "/specs/petstore.json"
alternateUIURL = testHost + "/ui/docs"
)

uiOpts := []UIOption{
WithUIBasePath("ui"), // override the base path from the spec, implies /ui
WithUIPath("docs"),
WithUISpecURL("/specs/petstore.json"),
}

t.Run("with APIHandler", func(t *testing.T) {
t.Run("with defaults", func(t *testing.T) {
handler := ctx.APIHandler(nil)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Containsf(t, htmlResponse, "<redoc", "should default to Redoc UI")
})

t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})

t.Run("with options", func(t *testing.T) {
handler := ctx.APIHandler(nil, uiOpts...)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Contains(t, htmlResponse, fmt.Sprintf("<redoc spec-url='%s'></redoc>", alternateSpecPath))
})

t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})
})

t.Run("with APIHandlerSwaggerUI", func(t *testing.T) {
t.Run("with defaults", func(t *testing.T) {
handler := ctx.APIHandlerSwaggerUI(nil)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Contains(t, htmlResponse, fmt.Sprintf(`url: '%s',`, strings.ReplaceAll(defaultSpecPath, `/`, `\/`)))
})

t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})

t.Run("with options", func(t *testing.T) {
handler := ctx.APIHandlerSwaggerUI(nil, uiOpts...)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Contains(t, htmlResponse, fmt.Sprintf(`url: '%s',`, strings.ReplaceAll(alternateSpecPath, `/`, `\/`)))
})

t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})
})

t.Run("with APIHandlerRapiDoc", func(t *testing.T) {
t.Run("with defaults", func(t *testing.T) {
handler := ctx.APIHandlerRapiDoc(nil)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Contains(t, htmlResponse, fmt.Sprintf("<rapi-doc spec-url=%q></rapi-doc>", defaultSpecPath))
})

t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, defaultSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})

t.Run("with options", func(t *testing.T) {
handler := ctx.APIHandlerRapiDoc(nil, uiOpts...)

t.Run("should find UI", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateUIURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)

htmlResponse := recorder.Body.String()
assert.Contains(t, htmlResponse, fmt.Sprintf("<rapi-doc spec-url=%q></rapi-doc>", alternateSpecPath))
})
t.Run("should find spec", func(t *testing.T) {
request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, alternateSpecURL, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
})
})
}

func TestContextAuthorize(t *testing.T) {
Expand Down
Loading

0 comments on commit fe71d27

Please sign in to comment.