Skip to content

Commit ffc1eb5

Browse files
authored
feat: support customizing the docs renderer config (#1024)
* feat: support customizing the Scalar docs renderer config * feat: support customizing the SwaggerUI docs renderer config * docs: demonstrate DocsRendererConfig usage * docs: dedupe custom renderer guidance
1 parent 5b36089 commit ffc1eb5

3 files changed

Lines changed: 134 additions & 48 deletions

File tree

api.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"html"
910
"io"
1011
"mime/multipart"
1112
"net/http"
@@ -205,6 +206,20 @@ type Config struct {
205206
// route altogether.
206207
DocsRenderer string
207208

209+
// DocsRendererConfig is an optional renderer-specific config. When set, it is
210+
// JSON-marshaled into the docs HTML. Scalar and SwaggerUI use it, Stoplight
211+
// Elements ignores it.
212+
//
213+
// Scalar reads it from the `data-configuration` attribute. See
214+
// https://github.com/scalar/scalar/blob/main/documentation/configuration.md
215+
// for the options.
216+
//
217+
// SwaggerUI merges its fields into the SwaggerUIBundle config object. See
218+
// https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
219+
// for the options. Huma owns the `url` and `dom_id` fields, so setting
220+
// them here does nothing.
221+
DocsRendererConfig any
222+
208223
// SchemasPath is the path to the API schemas. If set to `/schemas` it will
209224
// allow clients to get `/schemas/{schema}` to view the schema in a browser
210225
// or for use in editors like VSCode to provide autocomplete & validation.
@@ -640,6 +655,15 @@ func (a *api) registerDocsRoute() {
640655
"style-src 'unsafe-inline'", // TODO: Somehow drop 'unsafe-inline'
641656
}
642657

658+
var configAttr string
659+
if a.config.DocsRendererConfig != nil {
660+
b, err := json.Marshal(a.config.DocsRendererConfig)
661+
if err != nil {
662+
panic("failed to marshal DocsRendererConfig: " + err.Error())
663+
}
664+
configAttr = ` data-configuration="` + html.EscapeString(string(b)) + `"`
665+
}
666+
643667
body = []byte(`<!doctype html>
644668
<html lang="en">
645669
<head>
@@ -649,7 +673,7 @@ func (a *api) registerDocsRoute() {
649673
<title>` + title + `</title>
650674
</head>
651675
<body>
652-
<script id="api-reference" data-url="` + openAPIPath + `.json"></script>
676+
<script id="api-reference" data-url="` + openAPIPath + `.json"` + configAttr + `></script>
653677
<script src="https://unpkg.com/@scalar/api-reference@1.44.20/dist/browser/standalone.js" crossorigin integrity="sha384-tMz7GAo6dMy55x9tLFtH+sHtogji6Scmb+feBR31TAHmvSPRUTboK9H3M5NFaP4R"></script>
654678
</body>
655679
</html>`)
@@ -700,10 +724,19 @@ func (a *api) registerDocsRoute() {
700724
"form-action 'none'",
701725
"frame-ancestors 'none'",
702726
"sandbox allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox",
703-
"script-src https://unpkg.com/swagger-ui-dist@5.31.1/swagger-ui-bundle.js 'sha256-loGQL86SKUDRkBgfqt+XGmcml9Plihleifquht4CLYE='",
727+
"script-src https://unpkg.com/swagger-ui-dist@5.31.1/swagger-ui-bundle.js 'sha256-gRya58TMnKTH/Tne/zBInjBwFUxL66aMDYvPuAX0lNY='",
704728
"style-src https://unpkg.com/swagger-ui-dist@5.31.1/swagger-ui.css",
705729
}
706730

731+
var configAttr string
732+
if a.config.DocsRendererConfig != nil {
733+
b, err := json.Marshal(a.config.DocsRendererConfig)
734+
if err != nil {
735+
panic("failed to marshal DocsRendererConfig: " + err.Error())
736+
}
737+
configAttr = ` data-config="` + html.EscapeString(string(b)) + `"`
738+
}
739+
707740
body = []byte(`<!doctype html>
708741
<html lang="en">
709742
<head>
@@ -716,10 +749,13 @@ func (a *api) registerDocsRoute() {
716749
<body>
717750
<div id="swagger-ui"></div>
718751
<script src="https://unpkg.com/swagger-ui-dist@5.31.1/swagger-ui-bundle.js" crossorigin integrity="sha384-o9idN8HE6/V6SAewgnr6/5nz7+Npt5J0Cb4tNyXK8pycsVmgl1ZNbRS7tlEGxd+J"></script>
719-
<script data-url="` + openAPIPath + `.json">
720-
const url = document.currentScript.dataset.url;
752+
<script data-url="` + openAPIPath + `.json"` + configAttr + `>
753+
const script = document.currentScript;
754+
const url = script.dataset.url;
755+
const config = script.dataset.config ? JSON.parse(script.dataset.config) : {};
721756
window.onload = () => {
722757
window.ui = SwaggerUIBundle({
758+
...config,
723759
url: url,
724760
dom_id: '#swagger-ui',
725761
});

api_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,43 @@ func TestDocsRenderers(t *testing.T) {
216216
assert.Contains(t, resp.Body.String(), "@scalar/api-reference")
217217
})
218218

219+
t.Run("ScalarRendererConfig", func(t *testing.T) {
220+
_, api := humatest.New(t, huma.Config{
221+
OpenAPI: &huma.OpenAPI{
222+
Info: &huma.Info{Title: "Test API", Version: "1.0.0"},
223+
},
224+
DocsPath: "/docs",
225+
DocsRenderer: huma.DocsRendererScalar,
226+
DocsRendererConfig: map[string]any{
227+
"theme": "mars",
228+
"hideModels": true,
229+
},
230+
OpenAPIPath: "/openapi",
231+
Formats: huma.DefaultFormats,
232+
})
233+
234+
resp := api.Get("/docs")
235+
assert.Equal(t, http.StatusOK, resp.Code)
236+
assert.Contains(t, resp.Body.String(), `data-configuration="`)
237+
assert.Contains(t, resp.Body.String(), `&#34;theme&#34;:&#34;mars&#34;`)
238+
assert.Contains(t, resp.Body.String(), `&#34;hideModels&#34;:true`)
239+
})
240+
241+
t.Run("ScalarRendererConfigInvalid", func(t *testing.T) {
242+
assert.Panics(t, func() {
243+
humatest.New(t, huma.Config{
244+
OpenAPI: &huma.OpenAPI{
245+
Info: &huma.Info{Title: "Test API", Version: "1.0.0"},
246+
},
247+
DocsPath: "/docs",
248+
DocsRenderer: huma.DocsRendererScalar,
249+
DocsRendererConfig: make(chan int),
250+
OpenAPIPath: "/openapi",
251+
Formats: huma.DefaultFormats,
252+
})
253+
})
254+
})
255+
219256
t.Run("SwaggerUIRenderer", func(t *testing.T) {
220257
_, api := humatest.New(t, huma.Config{
221258
OpenAPI: &huma.OpenAPI{
@@ -232,6 +269,43 @@ func TestDocsRenderers(t *testing.T) {
232269
assert.Contains(t, resp.Body.String(), "swagger-ui-dist")
233270
})
234271

272+
t.Run("SwaggerUIRendererConfig", func(t *testing.T) {
273+
_, api := humatest.New(t, huma.Config{
274+
OpenAPI: &huma.OpenAPI{
275+
Info: &huma.Info{Title: "Test API", Version: "1.0.0"},
276+
},
277+
DocsPath: "/docs",
278+
DocsRenderer: huma.DocsRendererSwaggerUI,
279+
DocsRendererConfig: map[string]any{
280+
"defaultModelsExpandDepth": -1,
281+
"tryItOutEnabled": true,
282+
},
283+
OpenAPIPath: "/openapi",
284+
Formats: huma.DefaultFormats,
285+
})
286+
287+
resp := api.Get("/docs")
288+
assert.Equal(t, http.StatusOK, resp.Code)
289+
assert.Contains(t, resp.Body.String(), `data-config="`)
290+
assert.Contains(t, resp.Body.String(), `&#34;defaultModelsExpandDepth&#34;:-1`)
291+
assert.Contains(t, resp.Body.String(), `&#34;tryItOutEnabled&#34;:true`)
292+
})
293+
294+
t.Run("SwaggerUIRendererConfigInvalid", func(t *testing.T) {
295+
assert.Panics(t, func() {
296+
humatest.New(t, huma.Config{
297+
OpenAPI: &huma.OpenAPI{
298+
Info: &huma.Info{Title: "Test API", Version: "1.0.0"},
299+
},
300+
DocsPath: "/docs",
301+
DocsRenderer: huma.DocsRendererSwaggerUI,
302+
DocsRendererConfig: make(chan int),
303+
OpenAPIPath: "/openapi",
304+
Formats: huma.DefaultFormats,
305+
})
306+
})
307+
})
308+
235309
t.Run("APIPrefix", func(t *testing.T) {
236310
_, api := humatest.New(t, huma.Config{
237311
OpenAPI: &huma.OpenAPI{

docs/docs/features/api-docs.md

Lines changed: 20 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,73 +18,46 @@ You can switch to other documentation renderers using `config.DocsRenderer`. The
1818

1919
!!! info "Disabling the Docs"
2020

21-
You can disable the built-in documentation by setting `config.DocsPath` to an empty string. This allows you to provide your own documentation renderer if you wish.
21+
You can disable the built-in documentation by setting `config.DocsPath` to an empty string, then register your own route on the underlying router. The `DocsRenderer*` functions in [`api.go`](https://github.com/danielgtaylor/huma/blob/main/api.go) show what to return.
2222

2323
!!! warning "Middleware Conflicts"
2424

2525
Some middleware can interfere with the documentation renderer's ability to fetch the OpenAPI spec. For example, [go-chi/chi](https://github.com/go-chi/chi)'s `middleware.URLFormat` will rewrite URLs that end in `.json` or `.yaml` (e.g. `/openapi.json` -> `/openapi`), which can lead to 404 errors for the spec. If you encounter this, consider disabling that middleware or configuring it to skip the OpenAPI and documentation paths.
2626

2727
## Customizing Documentation
2828

29-
You can customize the generated documentation by providing your own renderer function to the API adapter or by using the underlying router directly.
29+
Each renderer accepts its own options through `config.DocsRendererConfig`. Set it to any value that marshals to JSON (a `map[string]any` is easiest), and Huma writes it into the docs HTML. Scalar and SwaggerUI support it; Stoplight Elements ignores it.
3030

3131
### Scalar Docs
3232

33-
[Scalar Docs](https://github.com/scalar/scalar?tab=readme-ov-file#readme) provide a featureful and customizable API documentation experience that feels similar to Postman in your browser.
33+
[Scalar Docs](https://github.com/scalar/scalar#readme) provide a featureful and customizable API documentation experience that feels similar to Postman in your browser.
3434

3535
```go title="code.go"
3636
router := chi.NewRouter()
3737
config := huma.DefaultConfig("Docs Example", "1.0.0")
38-
config.DocsPath = ""
38+
config.DocsRenderer = huma.DocsRendererScalar
3939

40-
api := humachi.New(router, config)
40+
// Optional. Scalar reads these from the `data-configuration` attribute. See
41+
// https://github.com/scalar/scalar/blob/main/documentation/configuration.md
42+
config.DocsRendererConfig = map[string]any{
43+
"theme": "mars", // one of: default, alternate, moon, purple, solarized, ...
44+
"hideModels": true, // hide the models section
45+
}
4146

42-
router.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
43-
// Please also refer to the "DocsRendererScalar" renderer code inside api.go on what to return here
44-
csp := []string{
45-
"default-src 'none'",
46-
"base-uri 'none'",
47-
"connect-src 'self'",
48-
"form-action 'none'",
49-
"frame-ancestors 'none'",
50-
"sandbox allow-same-origin allow-scripts",
51-
"script-src 'unsafe-eval' https://unpkg.com/@scalar/api-reference@1.44.20/dist/browser/standalone.js", // TODO: Somehow drop 'unsafe-eval'
52-
"style-src 'unsafe-inline'", // TODO: Somehow drop 'unsafe-inline'
53-
}
54-
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; "))
55-
w.Header().Set("Content-Type", "text/html")
56-
w.Write([]byte(`<!doctype html>
57-
<html lang="en">
58-
<head>
59-
<meta charset="utf-8">
60-
<meta name="referrer" content="no-referrer">
61-
<meta name="viewport" content="width=device-width, initial-scale=1">
62-
<title>API Reference</title>
63-
</head>
64-
<body>
65-
<script id="api-reference" data-url="/openapi.json"></script>
66-
<script src="https://unpkg.com/@scalar/api-reference@1.44.20/dist/browser/standalone.js" crossorigin integrity="sha384-tMz7GAo6dMy55x9tLFtH+sHtogji6Scmb+feBR31TAHmvSPRUTboK9H3M5NFaP4R"></script>
67-
</body>
68-
</html>`))
69-
})
47+
api := humachi.New(router, config)
7048
```
7149

7250
![Scalar Docs](./scalar.png)
7351

7452
### Stoplight Elements
7553

76-
You can customize the default docs by providing your own HTML so you can set the layout, styles, colors, etc as needed.
54+
[Stoplight Elements](https://stoplight.io/open-source/elements) is the default renderer, so you get it without setting `config.DocsRenderer` at all. It doesn't read `config.DocsRendererConfig`.
7755

7856
```go title="code.go"
7957
router := chi.NewRouter()
8058
config := huma.DefaultConfig("Docs Example", "1.0.0")
81-
config.DocsPath = ""
8259

8360
api := humachi.New(router, config)
84-
85-
router.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
86-
// Please refer to the "DocsRendererStoplightElements" renderer code inside api.go on what to return here
87-
})
8861
```
8962

9063
![Stoplight Elements Stacked](./elements-stacked.png)
@@ -96,13 +69,16 @@ router.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
9669
```go title="code.go"
9770
router := chi.NewRouter()
9871
config := huma.DefaultConfig("Docs Example", "1.0.0")
99-
config.DocsPath = ""
72+
config.DocsRenderer = huma.DocsRendererSwaggerUI
10073

101-
api := humachi.New(router, config)
74+
// Optional. These fields are merged into the SwaggerUIBundle config object. See
75+
// https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
76+
config.DocsRendererConfig = map[string]any{
77+
"defaultModelsExpandDepth": -1, // hide the models section
78+
"tryItOutEnabled": true, // enable "Try it out" by default
79+
}
10280

103-
router.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
104-
// Please refer to the "DocsRendererSwaggerUI" renderer code inside api.go on what to return here
105-
})
81+
api := humachi.New(router, config)
10682
```
10783

10884
![SwaggerUI](./swaggerui.png)

0 commit comments

Comments
 (0)