-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocscore.go
More file actions
185 lines (177 loc) · 6.51 KB
/
Copy pathdocscore.go
File metadata and controls
185 lines (177 loc) · 6.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
package stdocs
import (
"bytes"
"encoding/json"
"html"
"html/template"
"net/http"
"path"
"sort"
"strings"
)
// docsCore is the shared HTTP handler logic for the docs UI
// returned by Mux.Docs and DocsHandler. It is parameterised on the
// spec source: Tier-1 (DocsHandler) serves a static document,
// Tier-2 (Mux.Docs) serves the dynamically-built spec. Both share
// the same HTML page, prefix stripping, and routing logic.
//
// The HTML is parsed with html/template and rendered ONCE at
// construction time: both template inputs (the title and the
// relative spec URL) are fixed when the handler is built, and
// html/template escapes them per context — raw string substitution
// would not be safe against a title containing markup.
type docsCore struct {
cfg *Config
page []byte
jsonFn func() ([]byte, error)
yamlFn func() ([]byte, error)
}
type docsHTML struct {
Title string
SpecURL string
// ConfigJSON is the marshaled UIConfig for use as the body of a
// <script type="application/json"> data block (Swagger UI, Redoc).
// Empty when no config was supplied.
ConfigJSON template.JS
// ConfigAttr is the marshaled UIConfig (raw JSON) for use as the
// value of an HTML attribute (Scalar data-configuration). It is a
// plain string so html/template applies attribute escaping; the
// browser decodes it back to JSON when reading the attribute. Empty
// when no config was supplied.
ConfigAttr string
// ConfigAttrs is the UIConfig rendered as space-separated element
// attributes (Stoplight). Empty when no config was supplied.
ConfigAttrs template.HTMLAttr
}
func newDocsCore(cfg *Config, jsonFn, yamlFn func() ([]byte, error)) (*docsCore, error) {
// Parse and execute the template at construction time so a
// malformed UI constant is reported eagerly (the callers turn the
// error into a handler that responds 500 to every request).
t, err := template.New("ui").Parse(cfg.UIDoc)
if err != nil {
return nil, err
}
// The spec URL is relative: the browser is already at
// <prefix>/, so "openapi.json" resolves to
// <prefix>/openapi.json. This works under any reverse
// proxy path prefix without further configuration.
data := docsHTML{Title: cfg.Info.Title, SpecURL: "openapi.json"}
// UI-native configuration (from a sub-package's WithConfiguration)
// is marshaled once here and exposed to the template in the carrier
// each UI understands. Marshaling failure is reported eagerly, like
// a malformed template. When no config is supplied the fields stay
// empty and the templates render byte-identically to the no-config
// page.
if len(cfg.UIConfig) > 0 {
b, err := json.Marshal(cfg.UIConfig)
if err != nil {
return nil, err
}
//nolint:gosec // G203: b is encoding/json output (HTML-escaped, so no </script> breakout) emitted into a non-executable <script type="application/json"> block under a CSP with no script unsafe-inline; the parity test guards it.
data.ConfigJSON = template.JS(b)
data.ConfigAttr = string(b)
data.ConfigAttrs = uiConfigElementAttrs(cfg.UIConfig)
}
var page bytes.Buffer
if err := t.Execute(&page, data); err != nil {
return nil, err
}
return &docsCore{cfg: cfg, page: page.Bytes(), jsonFn: jsonFn, yamlFn: yamlFn}, nil
}
// uiConfigElementAttrs renders a UIConfig map as space-separated HTML
// element attributes, for UIs configured through attributes rather than
// a JSON object (Stoplight). Keys are used verbatim as attribute names
// (skipped when not a valid attribute name); string values are emitted
// as-is and any other value as its JSON encoding, each escaped for a
// double-quoted attribute. Keys are sorted so the output is
// deterministic.
func uiConfigElementAttrs(m map[string]any) template.HTMLAttr {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if !validAttrName(k) {
continue
}
val, ok := m[k].(string)
if !ok {
j, err := json.Marshal(m[k])
if err != nil {
continue
}
val = string(j)
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(k)
b.WriteString(`="`)
b.WriteString(html.EscapeString(val))
b.WriteString(`"`)
}
//nolint:gosec // G203: attribute names are validated by validAttrName and values are html.EscapeString-escaped above, so the string cannot break out of the tag.
return template.HTMLAttr(b.String())
}
// validAttrName reports whether s is safe to emit as an HTML attribute
// name (letters, digits, hyphen, underscore) — a guard against a config
// key breaking out of the tag.
func validAttrName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
func (d *docsCore) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if d.cfg.DocsSecurityHeaders {
setDocsBaselineHeaders(w.Header())
}
rest := strings.TrimPrefix(r.URL.Path, d.cfg.DocsPrefix)
switch rest {
case "":
// Request for the bare prefix ("/docs", no trailing slash).
// Redirect to the canonical slash-terminated form so the
// page's relative spec and asset URLs resolve inside the
// prefix. (Mount-registered handlers never see this case —
// ServeMux issues the redirect itself — but manual mounts at
// exact patterns do.) The target is relative and derived from
// the config, never from request data: from ".../docs" the
// browser resolves "docs/" to ".../docs/", which also works
// behind path-rewriting proxies.
http.Redirect(w, r, path.Base(d.cfg.DocsPrefix)+"/", http.StatusMovedPermanently)
case "/":
if d.cfg.DocsSecurityHeaders && d.cfg.UICSP != "" {
w.Header().Set("Content-Security-Policy", d.cfg.UICSP)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(d.page)
case "/openapi.json":
d.serveSpec(w, d.jsonFn, "application/json; charset=utf-8", "openapi.json")
case "/openapi.yaml":
d.serveSpec(w, d.yamlFn, "application/yaml", "openapi.yaml")
default:
http.NotFound(w, r)
}
}
func (d *docsCore) serveSpec(w http.ResponseWriter, fn func() ([]byte, error), contentType, filename string) {
b, err := fn()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", contentType)
// Suggest a filename for "Save as" without forcing a download:
// inline keeps the spec viewable in a browser tab, which is the
// common case for openapi.json/.yaml.
w.Header().Set("Content-Disposition", `inline; filename="`+filename+`"`)
_, _ = w.Write(b)
}