Skip to content
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
1 change: 1 addition & 0 deletions docs/doc-site/usage/server/deprecated-shims.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Field-to-option mapping for the `*Opts` structs:
| `Title` | `WithUITitle(s)` |
| `Template` | `WithUITemplate(s)` |
| `RapiDocURL` / `RedocURL` / `SwaggerURL` | `WithUIAssetsURL(s)` |
| `RedocV2` | `WithRedocV2()` |
| Swagger-UI-specific knobs (`OAuthCallbackURL`, presets, favicons) | `WithSwaggerUIOptions(docui.SwaggerUIOptions{…})` |

Migration example:
Expand Down
10 changes: 9 additions & 1 deletion docs/doc-site/usage/standalone/doc-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ your OAuth provider.
| `WithUIBasePath(string)` | Base path the UI is served from. Slash is prepended if missing. | `/` |
| `WithUIPath(string)` | Sub-path under the base path (final URL: `{base}/{path}`). | `docs` |
| `WithUITitle(string)` | HTML `<title>` of the rendered page. | `API documentation` |
| `WithUIAssetsURL(string)` | URL of the JS bundle for the UI. | Redoc → `https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js`<br/>RapiDoc → `https://unpkg.com/rapidoc/dist/rapidoc-min.js`<br/>Swagger UI → `https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js` |
| `WithUIAssetsURL(string)` | URL of the JS bundle for the UI. | Redoc → `https://cdn.jsdelivr.net/npm/redoc@3/bundles/redoc.standalone.js`<br/>RapiDoc → `https://unpkg.com/rapidoc/dist/rapidoc-min.js`<br/>Swagger UI → `https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js` |
| `WithUITemplate(tpl)` | Replace the bundled HTML template entirely (`~string` or `~[]byte`). | bundled minimal template |
| `WithSpecURL(string)` | URL the UI fetches the spec from. | `/swagger.json` |
| `WithSwaggerUIOptions(opts)` | Pass-through for Swagger-UI-specific knobs (OAuth2 client id, layout, …). | zero value |
Expand All @@ -68,6 +68,14 @@ templates each UI accepts:
- RapiDoc: <https://github.com/rapi-doc/RapiDoc>
- Swagger UI: <https://github.com/swagger-api/swagger-ui>

### Redoc v2

`docui.Redoc` loads Redoc v3, which ships as an ES module: the default
template uses `<script type="module">`. The Redoc v2 bundle does not load
as a module. If you pin a Redoc v2 bundle with `WithUIAssetsURL`, add
`WithRedocV2()`: it switches to a classic `<script>` template and, if you
set no URL, to `https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js`.

## Serving the spec document — `ServeSpec` / `UseSpec`

The UIs only render — they do not host the spec document themselves.
Expand Down
13 changes: 11 additions & 2 deletions middleware/seam.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,17 @@ type RedocOpts struct {

// RedocURL points to the js that generates the redoc site.
//
// Defaults to: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js
// Defaults to: https://cdn.jsdelivr.net/npm/redoc@3/bundles/redoc.standalone.js
RedocURL string

// RedocV2 serves Redoc v2 instead of v3. Set it if RedocURL points to a Redoc v2 bundle.
//
// See [docui.WithRedocV2].
RedocV2 bool
}

func (o RedocOpts) toFuncOptions() []docui.Option {
const structMembers = 6
const structMembers = 7
opts := make([]docui.Option, 0, structMembers)

if o.BasePath != "" {
Expand All @@ -321,6 +326,10 @@ func (o RedocOpts) toFuncOptions() []docui.Option {
opts = append(opts, docui.WithUIAssetsURL(o.RedocURL))
}

if o.RedocV2 {
opts = append(opts, docui.WithRedocV2())
}

return opts
}

Expand Down
11 changes: 11 additions & 0 deletions middleware/seam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ func TestDeprecatedDocUIForwarders(t *testing.T) {
assert.EqualT(t, http.StatusOK, rec.Code)
})

t.Run("middleware.Redoc serves Redoc v2 with RedocV2", func(t *testing.T) {
h := middleware.Redoc(middleware.RedocOpts{RedocV2: true}, nil)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil)
require.NoError(t, err)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.EqualT(t, http.StatusOK, rec.Code)
assert.StringContainsT(t, rec.Body.String(), "/redoc@2/")
assert.StringNotContainsT(t, rec.Body.String(), `type="module"`)
})

t.Run("middleware.RapiDoc still serves the docs page", func(t *testing.T) {
h := middleware.RapiDoc(middleware.RapiDocOpts{}, nil)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil)
Expand Down
28 changes: 27 additions & 1 deletion server-middleware/docui/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ type (

// AssetsURL points to the js asset that generates the documentation page.
AssetsURL string

// redocV2 selects the Redoc v2 template and assets.
redocV2 bool
}

specOptions struct {
Expand Down Expand Up @@ -138,7 +141,8 @@ func WithUITitle(title string) Option {
//
// Defaults:
//
// - for Redoc: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js
// - for Redoc: https://cdn.jsdelivr.net/npm/redoc@3/bundles/redoc.standalone.js
// (https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js with [WithRedocV2])
// - for RapiDoc, this defaults to: https://unpkg.com/rapidoc/dist/rapidoc-min.js
// - for SwaggerUI: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js
func WithUIAssetsURL(assets string) Option {
Expand Down Expand Up @@ -184,6 +188,28 @@ func WithSwaggerUIOptions(opts SwaggerUIOptions) Option {
}
}

////////////////////////////////////////////////////////////
// Redoc UI options
////////////////////////////////////////////////////////////

// WithRedocV2 makes [Redoc] and [UseRedoc] serve Redoc v2 instead of v3.
//
// The default template loads the Redoc v3 bundle as an ES module (<script type="module">).
// The Redoc v2 bundle does not load as a module.
//
// With this option, the default template loads the bundle as a classic script and
// the default assets URL points to Redoc v2. Use this option if you set
// [WithUIAssetsURL] to a Redoc v2 bundle.
//
// [WithUITemplate] and [WithUIAssetsURL] still override these defaults.
//
// Other UIs ignore this option.
func WithRedocV2() Option {
return func(o *options) {
o.redocV2 = true
}
}

////////////////////////////////////////////////////////////
// Spec options
////////////////////////////////////////////////////////////
Expand Down
59 changes: 53 additions & 6 deletions server-middleware/docui/redoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (

// UseRedoc creates a middleware to serve a documentation site for a swagger spec using [Redoc].
//
// By default, the page loads Redoc v3. Use [WithRedocV2] to keep Redoc v2.
//
// [Redoc]: https://redocly.com/docs/redoc
func UseRedoc(opts ...Option) func(next http.Handler) http.Handler {
pth, assets := redocSetup(opts)
Expand All @@ -28,6 +30,8 @@ func UseRedoc(opts ...Option) func(next http.Handler) http.Handler {
//
// This allows for altering the spec before starting the [http] listener.
//
// By default, the page loads Redoc v3. Use [WithRedocV2] to keep Redoc v2.
//
// [Redoc]: https://redocly.com/docs/redoc
func Redoc(next http.Handler, opts ...Option) http.Handler {
pth, assets := redocSetup(opts)
Expand All @@ -36,11 +40,21 @@ func Redoc(next http.Handler, opts ...Option) http.Handler {
}

func redocSetup(opts []Option) (pth string, assets []byte) {
o := optionsWithDefaults(opts,
// defaults for redoc
WithUITemplate(redocTemplate),
WithUIAssetsURL(redocLatest),
)
o := optionsWithDefaults(opts)

// defaults for redoc, resolved after all options so that WithRedocV2 applies in any order
if o.Template == "" {
o.Template = redocTemplate
if o.redocV2 {
o.Template = redocV2Template
}
}
if o.AssetsURL == "" {
o.AssetsURL = redocV3URL
if o.redocV2 {
o.AssetsURL = redocV2URL
}
}

pth = path.Join(o.BasePath, o.Path)
tmpl := template.Must(template.New("redoc").Parse(o.Template))
Expand All @@ -53,7 +67,10 @@ func redocSetup(opts []Option) (pth string, assets []byte) {
}

const (
redocLatest = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" // "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"
redocV3URL = "https://cdn.jsdelivr.net/npm/redoc@3/bundles/redoc.standalone.js"
redocV2URL = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"

// redocTemplate loads the Redoc v3 bundle, which is an ES module.
redocTemplate = `<!DOCTYPE html>
<html>
<head>
Expand All @@ -73,6 +90,36 @@ const (
}
</style>
</head>
<body>
<redoc spec-url='{{ .SpecURL }}'></redoc>
<script type="module" src="{{ .AssetsURL }}"> </script>
</body>
</html>
`

// redocV2Template loads the Redoc v2 bundle, which is a classic UMD script.
//
// The v2 bundle fails when loaded as a module: its wrapper reads properties of the
// top-level "this", which is undefined in a module.
redocV2Template = `<!DOCTYPE html>
<html>
<head>
<title>{{ .Title }}</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">

<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='{{ .SpecURL }}'></redoc>
<script src="{{ .AssetsURL }}"> </script>
Expand Down
45 changes: 43 additions & 2 deletions server-middleware/docui/redoc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,48 @@ func TestRedocMiddleware(t *testing.T) {
body := recorder.Body.String()
assert.StringContainsT(t, body, fmt.Sprintf("<title>%s</title>", defaultDocsTitle))
assert.StringContainsT(t, body, fmt.Sprintf("<redoc spec-url='%s'></redoc>", defaultDocsURL))
assert.StringContainsT(t, body, redocLatest)
assert.StringContainsT(t, body, fmt.Sprintf(`<script type="module" src="%s">`, redocV3URL))
})

t.Run("with Redoc v2", func(t *testing.T) {
const customURL = "https://example.com/redoc@2.5.1/redoc.standalone.js"

for _, tc := range []struct {
name string
opts []Option
wantURL string
}{
{name: "default v2 URL", opts: []Option{WithRedocV2()}, wantURL: redocV2URL},
{name: "v2 option before URL", opts: []Option{WithRedocV2(), WithUIAssetsURL(customURL)}, wantURL: customURL},
{name: "v2 option after URL", opts: []Option{WithUIAssetsURL(customURL), WithRedocV2()}, wantURL: customURL},
} {
t.Run(tc.name, func(t *testing.T) {
h := Redoc(nil, tc.opts...)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

h.ServeHTTP(recorder, req)
assert.EqualT(t, http.StatusOK, recorder.Code)

body := recorder.Body.String()
assert.StringContainsT(t, body, fmt.Sprintf(`<script src="%s">`, tc.wantURL))
assert.StringNotContainsT(t, body, `type="module"`)
})
}
})

t.Run("with Redoc v2 and custom template", func(t *testing.T) {
const tpl = `<redoc spec-url='{{ .SpecURL }}'></redoc><script defer src="{{ .AssetsURL }}"></script>`

h := Redoc(nil, WithRedocV2(), WithUITemplate(tpl))
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()

h.ServeHTTP(recorder, req)
assert.EqualT(t, http.StatusOK, recorder.Code)
assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf(`<script defer src="%s">`, redocV2URL))
})

t.Run("with alternate path and spec URL", func(t *testing.T) {
Expand All @@ -54,7 +95,7 @@ func TestRedocMiddleware(t *testing.T) {

h.ServeHTTP(recorder, req)
assert.EqualT(t, http.StatusOK, recorder.Code)
assert.StringContainsT(t, recorder.Body.String(), `<script src="https://example.com/redoc.js">`)
assert.StringContainsT(t, recorder.Body.String(), `<script type="module" src="https://example.com/redoc.js">`)
})

t.Run("with custom template", func(t *testing.T) {
Expand Down
Loading