huma/router.go

510 lines
14 KiB
Go
Raw Permalink Normal View History

2020-03-07 22:22:06 -08:00
package huma
import (
2020-04-04 15:10:13 -07:00
"context"
"encoding/json"
2020-09-02 21:39:26 -07:00
"fmt"
"net"
2020-03-07 22:22:06 -08:00
"net/http"
"strings"
2020-04-04 15:10:13 -07:00
"sync"
2020-04-03 23:34:36 -07:00
"time"
2020-03-07 22:22:06 -08:00
2020-08-26 22:25:47 -07:00
"github.com/Jeffail/gabs/v2"
"github.com/danielgtaylor/huma/schema"
2020-08-26 22:25:47 -07:00
"github.com/go-chi/chi"
2020-03-07 22:22:06 -08:00
)
2020-08-26 22:25:47 -07:00
type contextKey string
2020-03-07 22:22:06 -08:00
// connContextKey is used to get/set the underlying `net.Conn` from a request
// context value.
2020-08-26 22:25:47 -07:00
var connContextKey contextKey = "huma-request-conn"
2021-05-03 22:31:01 +02:00
// opIDContextKey is used to get the operation name after request routing
// has finished.
var opIDContextKey contextKey = "huma-operation-id"
2020-08-26 22:25:47 -07:00
// GetConn gets the underlying `net.Conn` from a context.
func GetConn(ctx context.Context) net.Conn {
conn := ctx.Value(connContextKey)
if conn != nil {
return conn.(net.Conn)
}
return nil
}
2020-08-26 22:25:47 -07:00
// Router is the entrypoint to your API.
type Router struct {
mux *chi.Mux
resources []*Resource
2020-05-02 22:17:44 -07:00
2020-12-22 10:00:43 -08:00
title string
version string
description string
contact oaContact
servers []oaServer
securitySchemes map[string]oaSecurityScheme
2020-12-23 15:38:44 -08:00
security []map[string][]string
2020-12-22 10:00:43 -08:00
autoConfig *AutoConfig
2020-04-03 23:17:47 -07:00
// Documentation, OpenAPI spec, and schemas routing handlers
docsPrefix string
docsSuffix string
schemasSuffix string
specSuffix string
docsHandler http.Handler
docsAreSetup bool
2020-08-26 22:25:47 -07:00
// Tracks the currently running server for graceful shutdown.
server *http.Server
serverLock sync.Mutex
2020-08-26 22:25:47 -07:00
// Allows modification of the generated OpenAPI.
openapiHook func(*gabs.Container)
2021-08-03 22:04:46 +02:00
// Router-global defaults
defaultBodyReadTimeout time.Duration
defaultServerIdleTimeout time.Duration
// Information for creating non-relative links & schema refs.
urlPrefix string
disableSchemaProperty bool
}
2020-08-26 22:25:47 -07:00
// OpenAPI returns an OpenAPI 3 representation of the API, which can be
// modified as needed and rendered to JSON via `.String()`.
func (r *Router) OpenAPI() *gabs.Container {
doc := gabs.New()
2020-03-07 22:22:06 -08:00
2020-08-26 22:25:47 -07:00
doc.Set("3.0.3", "openapi")
doc.Set(r.title, "info", "title")
doc.Set(r.version, "info", "version")
2020-03-07 22:22:06 -08:00
2020-08-26 22:25:47 -07:00
if r.contact.Name != "" || r.contact.Email != "" || r.contact.URL != "" {
doc.Set(r.contact, "info", "contact")
}
2020-04-26 22:18:37 -07:00
2020-08-26 22:25:47 -07:00
if r.description != "" {
doc.Set(r.description, "info", "description")
2020-04-03 23:17:47 -07:00
}
if len(r.servers) > 0 {
doc.Set(r.servers, "servers")
}
components := &oaComponents{
2020-12-22 10:00:43 -08:00
Schemas: map[string]*schema.Schema{},
SecuritySchemes: r.securitySchemes,
}
2020-08-26 22:25:47 -07:00
paths, _ := doc.Object("paths")
for _, res := range r.resources {
paths.Merge(res.toOpenAPI(components))
2020-04-03 23:17:47 -07:00
}
doc.Set(components, "components")
2020-12-22 10:00:43 -08:00
if len(r.security) > 0 {
doc.Set(r.security, "security")
}
if r.autoConfig != nil {
doc.Set(r.autoConfig, "x-cli-config")
}
2020-08-26 22:25:47 -07:00
if r.openapiHook != nil {
r.openapiHook(doc)
2020-04-03 23:17:47 -07:00
}
2020-08-26 22:25:47 -07:00
return doc
2020-04-03 23:17:47 -07:00
}
2020-08-26 22:25:47 -07:00
// Contact sets the API's contact information.
func (r *Router) Contact(name, email, url string) {
r.contact.Name = name
r.contact.Email = email
r.contact.URL = url
}
2020-04-25 21:51:29 -07:00
2020-09-04 21:46:53 -07:00
// ServerLink adds a new server link to this router for documentation.
func (r *Router) ServerLink(description, uri string) {
2020-08-26 22:25:47 -07:00
r.servers = append(r.servers, oaServer{
Description: description,
URL: uri,
})
}
2020-04-25 21:51:29 -07:00
2020-12-22 10:00:43 -08:00
// GatewayBasicAuth documents that the API gateway handles auth using HTTP Basic.
func (r *Router) GatewayBasicAuth(name string) {
r.securitySchemes[name] = oaSecurityScheme{
Type: "http",
Scheme: "basic",
}
}
// GatewayClientCredentials documents that the API gateway handles auth using
// OAuth2 client credentials (pre-shared secret).
func (r *Router) GatewayClientCredentials(name, tokenURL string, scopes map[string]string) {
r.securitySchemes[name] = oaSecurityScheme{
Type: "oauth2",
Flows: oaFlows{
ClientCredentials: &oaFlow{
TokenURL: tokenURL,
Scopes: scopes,
},
},
}
}
// GatewayAuthCode documents that the API gateway handles auth using
// OAuth2 authorization code (user login).
func (r *Router) GatewayAuthCode(name, authorizeURL, tokenURL string, scopes map[string]string) {
r.securitySchemes[name] = oaSecurityScheme{
Type: "oauth2",
Flows: oaFlows{
AuthorizationCode: &oaFlow{
AuthorizationURL: authorizeURL,
TokenURL: tokenURL,
Scopes: scopes,
},
},
}
}
// AutoConfig sets up CLI autoconfiguration via `x-cli-config` for use by CLI
// clients, e.g. using a tool like Restish (https://rest.sh/).
func (r *Router) AutoConfig(autoConfig AutoConfig) {
r.autoConfig = &autoConfig
}
// SecurityRequirement sets up a security requirement for the entire API by
// name and with the given scopes. Use together with the other auth options
2020-12-23 15:38:44 -08:00
// like GatewayAuthCode. Calling multiple times results in requiring one OR
// the other schemes but not both.
2020-12-22 10:00:43 -08:00
func (r *Router) SecurityRequirement(name string, scopes ...string) {
2020-12-23 15:38:44 -08:00
if scopes == nil {
scopes = []string{}
}
r.security = append(r.security, map[string][]string{
name: scopes,
})
2020-12-22 10:00:43 -08:00
}
2020-09-10 08:52:46 -07:00
// Resource creates a new resource attached to this router at the given path.
// The path can include parameters, e.g. `/things/{thing-id}`. Each resource
// path must be unique.
func (r *Router) Resource(path string) *Resource {
2020-08-26 22:25:47 -07:00
res := &Resource{
2020-09-10 08:52:46 -07:00
path: path,
mux: r.mux.Route(path, nil),
2020-08-26 22:25:47 -07:00
subResources: []*Resource{},
operations: []*Operation{},
tags: []string{},
router: r,
2020-03-07 22:22:06 -08:00
}
2020-08-26 22:25:47 -07:00
r.resources = append(r.resources, res)
2020-04-04 15:10:13 -07:00
2020-08-26 22:25:47 -07:00
return res
2020-03-07 22:22:06 -08:00
}
2020-08-26 22:25:47 -07:00
// Middleware adds a new standard middleware to this router at the root,
// so it will apply to all requests. Middleware can also be applied at the
// resource level.
func (r *Router) Middleware(middlewares ...func(next http.Handler) http.Handler) {
r.mux.Use(middlewares...)
2020-03-07 22:22:06 -08:00
}
// DocsPath returns the server path to the OpenAPI docs.
func (r *Router) DocsPath() string {
return fmt.Sprintf("%s/%s", r.docsPrefix, r.docsSuffix)
}
// SchemasPath returns the server path to the OpenAPI Schemas.
func (r *Router) SchemasPath() string {
return fmt.Sprintf("%s/%s", r.docsPrefix, r.schemasSuffix)
}
2020-08-26 22:25:47 -07:00
// OpenAPIPath returns the server path to the OpenAPI JSON.
func (r *Router) OpenAPIPath() string {
return fmt.Sprintf("%s/%s.json", r.docsPrefix, r.specSuffix)
2020-03-07 22:22:06 -08:00
}
// DocsPrefix sets the path prefix for where the OpenAPI JSON, schemas,
// and documentation are hosted.
2020-08-26 22:25:47 -07:00
func (r *Router) DocsPrefix(path string) {
r.docsPrefix = path
2020-03-07 22:22:06 -08:00
}
// DocsSuffix sets the final path suffix for where the OpenAPI documentation
// is hosted. When not specified, the default value of `docs` is appended to the
// DocsPrefix.
func (r *Router) DocsSuffix(suffix string) {
r.docsSuffix = suffix
}
// SchemasSuffix sets the final path suffix for where the OpenAPI schemas
// are hosted. When not specified, the default value of `schemas` is appended
// to the DocsPrefix.
func (r *Router) SchemasSuffix(suffix string) {
2022-06-01 11:34:50 -07:00
r.schemasSuffix = suffix
}
// SpecSuffix sets the final path suffix for where the OpenAPI spec is hosted.
// When not specified, the default value of `openapi` is appended to the
// DocsPrefix.
func (r *Router) SpecSuffix(suffix string) {
r.specSuffix = suffix
}
2020-08-26 22:25:47 -07:00
// DocsHandler sets the http.Handler to render documentation. It defaults to
// using RapiDoc.
func (r *Router) DocsHandler(handler http.Handler) {
r.docsHandler = handler
2020-03-31 23:02:16 -07:00
}
2020-08-26 22:25:47 -07:00
// OpenAPIHook provides a function to run after generating the OpenAPI document
// allowing you to modify it as needed.
func (r *Router) OpenAPIHook(hook func(*gabs.Container)) {
r.openapiHook = hook
}
2020-03-07 22:22:06 -08:00
// replaceRef recursively replaces refs in a JSON Schema to point to a new
// location.
func replaceRef(schema map[string]interface{}, from, to string) {
if schema["$ref"] != nil {
schema["$ref"] = strings.Replace(schema["$ref"].(string), from, to, -1) + ".json"
}
for _, v := range schema {
if m, ok := v.(map[string]interface{}); ok {
replaceRef(m, from, to)
} else if s, ok := v.([]interface{}); ok {
for _, item := range s {
if m, ok := item.(map[string]interface{}); ok {
replaceRef(m, from, to)
}
}
}
}
}
2020-08-30 23:58:12 -07:00
// Set up the docs & OpenAPI routes.
func (r *Router) setupDocs() {
// Precompute the OpenAPI document once on startup and then serve the cached
// version of it.
spec := r.OpenAPI()
var schemas map[string]interface{}
b, _ := json.Marshal(spec.Search("components").Data().(*oaComponents).Schemas)
json.Unmarshal(b, &schemas)
for _, v := range schemas {
// Convert $ref links for standalone JSON files.
// #/components/schemas/MyType -> ./MyType.json
replaceRef(v.(map[string]interface{}), "#/components/schemas", ".")
}
2020-08-26 22:25:47 -07:00
// Register the docs handlers if needed.
if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.OpenAPIPath()) {
r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oai.openapi+json")
w.Write(spec.Bytes())
})
2020-03-31 23:02:16 -07:00
}
if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.SchemasPath()+"/{schema-id}.json") {
r.mux.Get(r.SchemasPath()+"/{schema-id}.json", func(w http.ResponseWriter, req *http.Request) {
id := chi.URLParam(req, "schema-id")
schema := schemas[id]
if schema == nil {
w.WriteHeader(http.StatusNotFound)
return
}
b, _ := json.Marshal(schema)
w.Header().Set("Content-Type", "application/schema+json")
w.Write(b)
})
}
if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.DocsPath()) {
r.mux.Get(r.DocsPath(), r.docsHandler.ServeHTTP)
2020-03-07 22:22:06 -08:00
}
2020-08-30 23:58:12 -07:00
r.docsAreSetup = true
}
func (r *Router) listen(addr, certFile, keyFile string) error {
// Setup docs on startup so we can fail fast if the handler is broken in
// some way.
r.setupDocs()
2020-08-26 22:25:47 -07:00
// Start the server.
2020-04-04 15:10:13 -07:00
r.serverLock.Lock()
if r.server == nil {
r.server = &http.Server{
Addr: addr,
ReadHeaderTimeout: 10 * time.Second,
2021-08-03 22:04:46 +02:00
IdleTimeout: r.defaultServerIdleTimeout,
2020-04-04 15:10:13 -07:00
Handler: r,
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, connContextKey, c)
},
2020-04-04 15:10:13 -07:00
}
} else {
r.server.Addr = addr
// Wrap the ConnContext method to inject the current connection into the
// request context. This is useful to e.g. set deadlines.
orig := r.server.ConnContext
r.server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
if orig != nil {
ctx = orig(ctx, c)
}
return context.WithValue(ctx, connContextKey, c)
}
2020-04-04 15:10:13 -07:00
}
r.serverLock.Unlock()
if certFile != "" {
return r.server.ListenAndServeTLS(certFile, keyFile)
}
return r.server.ListenAndServe()
}
2020-08-26 22:25:47 -07:00
// Listen starts the server listening on the specified `host:port` address.
2020-03-13 11:01:16 -07:00
func (r *Router) Listen(addr string) error {
2020-04-04 15:10:13 -07:00
return r.listen(addr, "", "")
2020-03-07 22:22:06 -08:00
}
// ListenTLS listens for new connections using HTTPS & HTTP2
func (r *Router) ListenTLS(addr, certFile, keyFile string) error {
2020-04-04 15:10:13 -07:00
return r.listen(addr, certFile, keyFile)
}
2020-08-26 22:25:47 -07:00
// ServeHTTP handles an incoming request and is compatible with the standard
// library `http` package.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
2020-08-30 23:58:12 -07:00
if !r.docsAreSetup {
r.setupDocs()
}
2020-08-26 22:25:47 -07:00
r.mux.ServeHTTP(w, req)
}
2020-04-04 15:10:13 -07:00
// Shutdown gracefully shuts down the server.
func (r *Router) Shutdown(ctx context.Context) error {
r.serverLock.Lock()
defer r.serverLock.Unlock()
if r.server == nil {
panic("no server started")
}
return r.server.Shutdown(ctx)
}
2020-09-02 21:21:48 -07:00
// GetTitle returns the server API title.
func (r *Router) GetTitle() string {
return r.title
}
2020-08-26 22:25:47 -07:00
// GetVersion returns the server version.
func (r *Router) GetVersion() string {
return r.version
}
2021-08-03 22:04:46 +02:00
// DefaultBodyReadTimeout sets the amount of time an operation has to read
// the body of the incoming request before it is aborted. Defaults to 15
// seconds if not set.
func (r *Router) DefaultBodyReadTimeout(timeout time.Duration) {
r.defaultBodyReadTimeout = timeout
}
// DefaultServerIdleTimeout sets the server's `IdleTimeout` value on startup.
// Defaults to 15 seconds if not set.
func (r *Router) DefaultServerIdleTimeout(timeout time.Duration) {
r.defaultServerIdleTimeout = timeout
}
// URLPrefix sets the prefix to use when crafting non-relative links. If unset,
// then the incoming requests `Host` header is used and the scheme defaults to
// `https` unless the host starts with `localhost`. Do not include a
// trailing slash in the prefix. Examples:
// - https://example.com/v1
// - http://localhost
func (r *Router) URLPrefix(value string) {
r.urlPrefix = value
}
// DisableSchemaProperty disables the creation of a `$schema` property in
// returned object response models.
func (r *Router) DisableSchemaProperty() {
r.disableSchemaProperty = true
}
const (
DefaultDocsSuffix = "docs"
DefaultSchemasSuffix = "schemas"
DefaultSpecSuffix = "openapi"
)
2020-08-26 22:25:47 -07:00
// New creates a new Huma router to which you can attach resources,
// operations, middleware, etc.
func New(docs, version string) *Router {
title, desc := splitDocs(docs)
r := &Router{
2021-08-03 22:04:46 +02:00
mux: chi.NewRouter(),
resources: []*Resource{},
title: title,
description: desc,
version: version,
servers: []oaServer{},
securitySchemes: map[string]oaSecurityScheme{},
security: []map[string][]string{},
defaultBodyReadTimeout: 15 * time.Second,
defaultServerIdleTimeout: 15 * time.Second,
docsSuffix: DefaultDocsSuffix,
schemasSuffix: DefaultSchemasSuffix,
specSuffix: DefaultSpecSuffix,
}
2020-08-26 22:25:47 -07:00
2020-09-10 22:28:29 -07:00
r.docsHandler = RapiDocHandler(r)
2020-09-02 21:39:26 -07:00
// Error handlers
r.mux.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := ContextFromRequest(w, r)
ctx.WriteError(http.StatusNotFound, fmt.Sprintf("Cannot find %s", r.URL.String()))
}))
r.mux.MethodNotAllowed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := ContextFromRequest(w, r)
ctx.WriteError(http.StatusMethodNotAllowed, fmt.Sprintf("No handler for method %s", r.Method))
}))
2020-08-26 22:25:47 -07:00
r.Middleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
2021-05-03 22:31:01 +02:00
// Inject the operation info before other middleware so that the later
// middleware will have access to it.
req = req.WithContext(context.WithValue(req.Context(), opIDContextKey, &OperationInfo{}))
2020-08-26 22:25:47 -07:00
next.ServeHTTP(w, req)
2021-05-03 22:31:01 +02:00
// Automatically add links to OpenAPI and docs.
2020-08-26 22:25:47 -07:00
if req.URL.Path == "/" {
link := w.Header().Get("link")
if link != "" {
link += ", "
}
link += `<` + r.OpenAPIPath() + `>; rel="service-desc", <` + r.DocsPath() + `>; rel="service-doc"`
2020-08-26 22:25:47 -07:00
w.Header().Set("link", link)
}
})
})
return r
}