mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 19:31:27 +00:00
337 lines
10 KiB
Go
337 lines
10 KiB
Go
package huma
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Jeffail/gabs/v2"
|
|
"github.com/danielgtaylor/huma/schema"
|
|
)
|
|
|
|
// OperationInfo describes an operation. It contains useful information for
|
|
// logging, metrics, auditing, etc.
|
|
type OperationInfo struct {
|
|
ID string
|
|
URITemplate string
|
|
Summary string
|
|
Tags []string
|
|
}
|
|
|
|
// GetOperationInfo returns information about the current Huma operation. This
|
|
// will only be populated *after* routing has been handled, meaning *after*
|
|
// `next.ServeHTTP(w, r)` has been called in your middleware.
|
|
func GetOperationInfo(ctx context.Context) *OperationInfo {
|
|
if oi := ctx.Value(opIDContextKey); oi != nil {
|
|
return oi.(*OperationInfo)
|
|
}
|
|
|
|
return &OperationInfo{
|
|
ID: "unknown",
|
|
Tags: []string{},
|
|
}
|
|
}
|
|
|
|
// Operation represents an operation (an HTTP verb, e.g. GET / PUT) against
|
|
// a resource attached to a router.
|
|
type Operation struct {
|
|
resource *Resource
|
|
method string
|
|
id string
|
|
summary string
|
|
description string
|
|
params map[string]oaParam
|
|
requestContentType string
|
|
requestSchema *schema.Schema
|
|
requestSchemaOverride bool
|
|
requestModel reflect.Type
|
|
responses []Response
|
|
maxBodyBytes int64
|
|
bodyReadTimeout time.Duration
|
|
}
|
|
|
|
func newOperation(resource *Resource, method, id, docs string, responses []Response) *Operation {
|
|
summary, desc := splitDocs(docs)
|
|
return &Operation{
|
|
resource: resource,
|
|
method: method,
|
|
id: id,
|
|
summary: summary,
|
|
description: desc,
|
|
responses: responses,
|
|
// 1 MiB body limit by default
|
|
maxBodyBytes: 1024 * 1024,
|
|
// 15 second timeout by default
|
|
bodyReadTimeout: resource.router.defaultBodyReadTimeout,
|
|
}
|
|
}
|
|
|
|
func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container {
|
|
doc := gabs.New()
|
|
|
|
doc.Set(o.id, "operationId")
|
|
if o.summary != "" {
|
|
doc.Set(o.summary, "summary")
|
|
}
|
|
if o.description != "" {
|
|
doc.Set(o.description, "description")
|
|
}
|
|
|
|
// Request params
|
|
for _, param := range o.params {
|
|
if param.Internal {
|
|
// Skip documenting internal-only params.
|
|
continue
|
|
}
|
|
|
|
doc.ArrayAppend(param, "parameters")
|
|
}
|
|
|
|
// Request body
|
|
if o.requestSchema != nil {
|
|
ct := o.requestContentType
|
|
if ct == "" {
|
|
ct = "application/json"
|
|
}
|
|
ref := ""
|
|
if o.requestSchemaOverride {
|
|
ref = components.AddExistingSchema(o.requestSchema, o.id+"-request", !o.resource.router.disableSchemaProperty)
|
|
} else {
|
|
// Regenerate with ModeAll so the same model can be used for both the
|
|
// input and output when possible.
|
|
ref = components.AddSchema(o.requestModel, schema.ModeAll, o.id+"-request", !o.resource.router.disableSchemaProperty)
|
|
}
|
|
doc.Set(ref, "requestBody", "content", ct, "schema", "$ref")
|
|
}
|
|
|
|
// responses
|
|
for i, resp := range o.responses {
|
|
status := fmt.Sprintf("%v", resp.status)
|
|
if resp.status == 0 {
|
|
status = "default"
|
|
}
|
|
doc.Set(resp.description, "responses", status, "description")
|
|
|
|
headers := resp.headers
|
|
for _, name := range headers {
|
|
// TODO: get header description from shared registry
|
|
//header := headerMap[name]
|
|
header := name
|
|
doc.Set(header, "responses", status, "headers", name, "description")
|
|
|
|
typ := "string"
|
|
for _, param := range o.params {
|
|
if param.In == inHeader && param.Name == name {
|
|
if param.Schema.Type != "" {
|
|
typ = param.Schema.Type
|
|
}
|
|
break
|
|
}
|
|
}
|
|
doc.Set(typ, "responses", status, "headers", name, "schema", "type")
|
|
}
|
|
|
|
if resp.model != nil {
|
|
ref := components.AddSchema(resp.model, schema.ModeAll, o.id+"-response", !o.resource.router.disableSchemaProperty)
|
|
o.responses[i].modelRef = ref
|
|
doc.Set(ref, "responses", status, "content", resp.contentType, "schema", "$ref")
|
|
}
|
|
}
|
|
|
|
return doc
|
|
}
|
|
|
|
// MaxBodyBytes sets the max number of bytes that the request body size may be
|
|
// before the request is cancelled. The default is 1MiB.
|
|
func (o *Operation) MaxBodyBytes(size int64) {
|
|
o.maxBodyBytes = size
|
|
}
|
|
|
|
// NoMaxBody removes the body byte limit, which is 1MiB by default. Use this
|
|
// if you expect to stream the input request or need to handle very large
|
|
// request bodies.
|
|
func (o *Operation) NoMaxBody() {
|
|
o.maxBodyBytes = 0
|
|
}
|
|
|
|
// BodyReadTimeout sets the amount of time a request can spend reading the
|
|
// body, after which it times out and the request is cancelled. The default
|
|
// is 15 seconds.
|
|
func (o *Operation) BodyReadTimeout(duration time.Duration) {
|
|
o.bodyReadTimeout = duration
|
|
}
|
|
|
|
// NoBodyReadTimeout removes the body read timeout, which is 15 seconds by
|
|
// default. Use this if you expect to stream the input request or need to
|
|
// handle very large request bodies.
|
|
func (o *Operation) NoBodyReadTimeout() {
|
|
o.bodyReadTimeout = 0
|
|
}
|
|
|
|
// RequestSchema allows overriding the generated input body schema, giving you
|
|
// more control over documentation and validation.
|
|
func (o *Operation) RequestSchema(s *schema.Schema) {
|
|
o.requestSchema = s
|
|
o.requestSchemaOverride = true
|
|
}
|
|
|
|
// Run registers the handler function for this operation. It should be of the
|
|
// form: `func (ctx huma.Context)` or `func (ctx huma.Context, input)` where
|
|
// input is your input struct describing the input parameters and/or body.
|
|
func (o *Operation) Run(handler interface{}) {
|
|
if reflect.ValueOf(handler).Kind() != reflect.Func {
|
|
panic(fmt.Errorf("Handler must be a function taking a huma.Context and optionally a user-defined input struct, but got: %s for %s %s", handler, o.method, o.resource.path))
|
|
}
|
|
|
|
var register func(string, http.HandlerFunc)
|
|
|
|
switch o.method {
|
|
case http.MethodPost:
|
|
register = o.resource.mux.Post
|
|
case http.MethodHead:
|
|
register = o.resource.mux.Head
|
|
case http.MethodGet:
|
|
register = o.resource.mux.Get
|
|
case http.MethodPut:
|
|
register = o.resource.mux.Put
|
|
case http.MethodPatch:
|
|
register = o.resource.mux.Patch
|
|
case http.MethodDelete:
|
|
register = o.resource.mux.Delete
|
|
default:
|
|
panic(fmt.Errorf("Unknown HTTP verb: %s", o.method))
|
|
}
|
|
|
|
t := reflect.TypeOf(handler)
|
|
if t.Kind() == reflect.Func && t.NumIn() > 1 {
|
|
var err error
|
|
input := t.In(1)
|
|
|
|
// Get parameters
|
|
o.params = getParamInfo(input)
|
|
for k, v := range o.params {
|
|
if v.In == inPath {
|
|
// Confirm each declared input struct path parameter is actually a part
|
|
// of the declared resource path.
|
|
if !strings.Contains(o.resource.path, "{"+k+"}") {
|
|
panic(fmt.Errorf("Parameter '%s' not in URI path: %s", k, o.resource.path))
|
|
}
|
|
}
|
|
}
|
|
|
|
possible := []int{http.StatusBadRequest}
|
|
|
|
if _, ok := input.FieldByName("Body"); ok || len(o.params) > 0 {
|
|
// Invalid parameter values or body values can cause a 422.
|
|
possible = append(possible, http.StatusUnprocessableEntity)
|
|
}
|
|
|
|
// Get body if present.
|
|
if body, ok := input.FieldByName("Body"); ok {
|
|
o.requestModel = body.Type
|
|
possible = append(possible,
|
|
http.StatusRequestEntityTooLarge,
|
|
http.StatusRequestTimeout,
|
|
)
|
|
|
|
if o.requestSchema == nil {
|
|
o.requestSchema, err = schema.GenerateWithMode(body.Type, schema.ModeWrite, nil)
|
|
if err != nil {
|
|
panic(fmt.Errorf("unable to generate JSON schema: %w", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// It's possible for the inputs to generate a few different errors, so
|
|
// generate them if not already present.
|
|
found := map[int]bool{}
|
|
for _, r := range o.responses {
|
|
found[r.status] = true
|
|
}
|
|
|
|
for _, s := range possible {
|
|
if !found[s] {
|
|
o.responses = append(o.responses, NewResponse(s, http.StatusText(s)).ContentType("application/problem+json").Model(&ErrorModel{}))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Future improvement idea: use a sync.Pool for the input structure to save
|
|
// on allocations if the struct has a Reset() method.
|
|
|
|
register("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Update the operation info for loggers/metrics/etc middlware to use later.
|
|
opInfo := GetOperationInfo(r.Context())
|
|
opInfo.ID = o.id
|
|
opInfo.URITemplate = o.resource.path
|
|
opInfo.Summary = o.summary
|
|
opInfo.Tags = append([]string{}, o.resource.tags...)
|
|
|
|
ctx := &hcontext{
|
|
Context: r.Context(),
|
|
ResponseWriter: w,
|
|
r: r,
|
|
op: o,
|
|
docsPath: o.resource.router.DocsPath(),
|
|
schemasPath: o.resource.router.SchemasPath(),
|
|
specPath: o.resource.router.OpenAPIPath(),
|
|
urlPrefix: o.resource.router.urlPrefix,
|
|
disableSchemaProperty: o.resource.router.disableSchemaProperty,
|
|
errorCode: http.StatusBadRequest,
|
|
}
|
|
|
|
// If there is no input struct (just a context), then the call is simple.
|
|
if simple, ok := handler.(func(Context)); ok {
|
|
simple(ctx)
|
|
return
|
|
}
|
|
|
|
// Otherwise, create a new input struct instance and populate it.
|
|
v := reflect.ValueOf(handler)
|
|
inputType := v.Type().In(1)
|
|
input := reflect.New(inputType)
|
|
|
|
// Limit the request body size.
|
|
if r.Body != nil {
|
|
if o.maxBodyBytes > 0 {
|
|
r.Body = http.MaxBytesReader(w, r.Body, o.maxBodyBytes)
|
|
}
|
|
}
|
|
|
|
// Set a read deadline for reading/parsing the input request body, but
|
|
// only for operations that have a request body model.
|
|
var conn net.Conn
|
|
if o.requestModel != nil && o.bodyReadTimeout > 0 {
|
|
if conn = GetConn(r.Context()); conn != nil {
|
|
conn.SetReadDeadline(time.Now().Add(o.bodyReadTimeout))
|
|
}
|
|
}
|
|
|
|
setFields(ctx, ctx.r, input, inputType)
|
|
if !ctx.HasError() {
|
|
// No errors yet, so any errors that come after should be treated as a
|
|
// semantic rather than structural error.
|
|
ctx.errorCode = http.StatusUnprocessableEntity
|
|
}
|
|
resolveFields(ctx, "", input)
|
|
if ctx.HasError() {
|
|
ctx.WriteError(ctx.errorCode, "Error while processing input parameters")
|
|
return
|
|
}
|
|
|
|
// Clear any body read deadline if one was set as the body has now been
|
|
// read in. The one exception is when the body is streamed in via an
|
|
// `io.Reader` so we don't reset the deadline for that.
|
|
if conn != nil && o.requestModel != readerType {
|
|
conn.SetReadDeadline(time.Time{})
|
|
}
|
|
|
|
// Call the handler with the context and newly populated input struct.
|
|
in := []reflect.Value{reflect.ValueOf(ctx), input.Elem()}
|
|
reflect.ValueOf(handler).Call(in)
|
|
})
|
|
}
|