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) }) }