这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,15 @@ type api struct {
transformers []Transformer
}

func (r *api) Adapter() Adapter {
return r.adapter
func (a *api) Adapter() Adapter {
return a.adapter
}

func (r *api) OpenAPI() *OpenAPI {
return r.config.OpenAPI
func (a *api) OpenAPI() *OpenAPI {
return a.config.OpenAPI
}

func (r *api) Unmarshal(contentType string, data []byte, v any) error {
func (a *api) Unmarshal(contentType string, data []byte, v any) error {
// Handle e.g. `application/json; charset=utf-8` or `my/format+json`
start := strings.IndexRune(contentType, '+') + 1
end := strings.IndexRune(contentType, ';')
Expand All @@ -163,19 +163,19 @@ func (r *api) Unmarshal(contentType string, data []byte, v any) error {
// Default to assume JSON since this is an API.
ct = "application/json"
}
f, ok := r.formats[ct]
f, ok := a.formats[ct]
if !ok {
return fmt.Errorf("unknown content type: %s", contentType)
}
return f.Unmarshal(data, v)
}

func (r *api) Negotiate(accept string) (string, error) {
ct := negotiation.SelectQValueFast(accept, r.formatKeys)
if ct == "" {
ct = r.formatKeys[0]
func (a *api) Negotiate(accept string) (string, error) {
ct := negotiation.SelectQValueFast(accept, a.formatKeys)
if ct == "" && a.formatKeys != nil {
ct = a.formatKeys[0]
}
if _, ok := r.formats[ct]; !ok {
if _, ok := a.formats[ct]; !ok {
return ct, fmt.Errorf("unknown content type: %s", ct)
}
return ct, nil
Expand Down
46 changes: 44 additions & 2 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ type contextKey string
var optionsKey contextKey = "huma/cli/options"

// WithOptions is a helper for custom commands that need to access the options.
//
// cli.Root().AddCommand(&cobra.Command{
// Use: "my-custom-command",
// Run: huma.WithOptions(func(cmd *cobra.Command, args []string, opts *Options) {
// fmt.Println("Hello " + opts.Name)
// }),
// })
func WithOptions[Options any](f func(cmd *cobra.Command, args []string, options *Options)) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, s []string) {
var options *Options = cmd.Context().Value(optionsKey).(*Options)
Expand Down Expand Up @@ -183,8 +190,43 @@ func (c *cli[O]) setupOptions(flags *pflag.FlagSet, t reflect.Type, path []int)

// NewCLI creates a new CLI. The `onParsed` callback is called after the command
// options have been parsed and the options struct has been populated. You
// should set up a `cli.OnStart` callback to start the server with your chosen
// router.
// should set up a `hooks.OnStart` callback to start the server with your
// chosen router.
//
// // First, define your input options.
// type Options struct {
// Debug bool `doc:"Enable debug logging"`
// Host string `doc:"Hostname to listen on."`
// Port int `doc:"Port to listen on." short:"p" default:"8888"`
// }
//
// // Then, create the CLI.
// cli := huma.NewCLI(func(hooks huma.Hooks, opts *Options) {
// fmt.Printf("Options are debug:%v host:%v port%v\n",
// opts.Debug, opts.Host, opts.Port)
//
// // Set up the router & API
// router := chi.NewRouter()
// api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
// srv := &http.Server{
// Addr: fmt.Sprintf("%s:%d", opts.Host, opts.Port),
// Handler: router,
// // TODO: Set up timeouts!
// }
//
// hooks.OnStart(func() {
// if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// log.Fatalf("listen: %s\n", err)
// }
// })
//
// hooks.OnStop(func() {
// srv.Shutdown(context.Background())
// })
// })
//
// // Run the thing!
// cli.Run()
func NewCLI[O any](onParsed func(Hooks, *O)) CLI {
c := &cli[O]{
root: &cobra.Command{
Expand Down
29 changes: 27 additions & 2 deletions defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import (
)

// DefaultJSONFormat is the default JSON formatter that can be set in the API's
// `Config.Formats` map.
// `Config.Formats` map. This is used by the `DefaultConfig` function.
//
// config := huma.Config{}
// config.Formats = map[string]huma.Format{
// "application/json": huma.DefaultJSONFormat,
// "json": huma.DefaultJSONFormat,
// }
var DefaultJSONFormat = Format{
Marshal: func(w io.Writer, v any) error {
return json.NewEncoder(w).Encode(v)
Expand All @@ -29,14 +35,33 @@ var cborEncMode, _ = cbor.EncOptions{
}.EncMode()

// DefaultCBORFormat is the default CBOR formatter that can be set in the API's
// `Config.Formats` map.
// `Config.Formats` map. This is used by the `DefaultConfig` function.
//
// config := huma.Config{}
// config.Formats = map[string]huma.Format{
// "application/cbor": huma.DefaultCBORFormat,
// "cbor": huma.DefaultCBORFormat,
// }
var DefaultCBORFormat = Format{
Marshal: func(w io.Writer, v any) error {
return cborEncMode.NewEncoder(w).Encode(v)
},
Unmarshal: cbor.Unmarshal,
}

// DefaultConfig returns a default configuration for a new API. It is a good
// starting point for creating your own configuration. It supports JSON and
// CBOR formats out of the box. The registry uses references for structs and
// a link transformer is included to add `$schema` fields and links into
// responses. The `/openapi.[json|yaml]`, `/docs`, and `/schemas` paths are
// set up to serve the OpenAPI spec, docs UI, and schemas respectively.
//
// // Create and customize the config (if desired).
// config := huma.DefaultConfig("My API", "1.0.0")
//
// // Create the API using the config.
// router := chi.NewMux()
// api := humachi.New(router, config)
func DefaultConfig(title, version string) Config {
schemaPrefix := "#/components/schemas/"
schemasPath := "/schemas"
Expand Down
105 changes: 90 additions & 15 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import (
"strconv"
)

// ErrorDetailer returns error details for responses & debugging.
// ErrorDetailer returns error details for responses & debugging. This enables
// the use of custom error types. See `NewError` for more details.
type ErrorDetailer interface {
ErrorDetail() *ErrorDetail
}
Expand All @@ -16,10 +17,10 @@ type ErrorDetail struct {
// Message is a human-readable explanation of the error.
Message string `json:"message,omitempty" doc:"Error message text"`

// Location is a path-like string indicating where the error occured.
// Location is a path-like string indicating where the error occurred.
// It typically begins with `path`, `query`, `header`, or `body`. Example:
// `body.items[3].tags` or `path.thing-id`.
Location string `json:"location,omitempty" doc:"Where the error occured, e.g. 'body.items[3].tags' or 'path.thing-id'"`
Location string `json:"location,omitempty" doc:"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'"`

// Value is the value at the given location, echoed back to the client
// to help with debugging. This can be useful for e.g. validating that
Expand All @@ -28,7 +29,9 @@ type ErrorDetail struct {
Value any `json:"value,omitempty" doc:"The value at the given location"`
}

// Error returns the error message / satisfies the `error` interface.
// Error returns the error message / satisfies the `error` interface. If a
// location and value are set, they will be included in the error message,
// otherwise just the message is returned.
func (e *ErrorDetail) Error() string {
if e.Location == "" && e.Value == nil {
return e.Message
Expand All @@ -41,14 +44,35 @@ func (e *ErrorDetail) ErrorDetail() *ErrorDetail {
return e
}

// ErrorModel defines a basic error message model.
// ErrorModel defines a basic error message model based on RFC 7807 Problem
// Details for HTTP APIs (https://datatracker.ietf.org/doc/html/rfc7807). It
// is augmented with an `errors` field of `huma.ErrorDetail` objects that
// can help provide exhaustive & descriptive errors.
//
// err := &huma.ErrorModel{
// Title: http.StatusText(http.StatusBadRequest),
// Status http.StatusBadRequest,
// Detail: "Validation failed",
// Errors: []*huma.ErrorDetail{
// &huma.ErrorDetail{
// Message: "expected required property id to be present",
// Location: "body.friends[0]",
// Value: nil,
// },
// &huma.ErrorDetail{
// Message: "expected boolean",
// Location: "body.friends[1].active",
// Value: 5,
// },
// },
// }
type ErrorModel struct {
// Type is a URI to get more information about the error type.
Type string `json:"type,omitempty" format:"uri" default:"about:blank" example:"https://example.com/errors/example" doc:"A URI reference to human-readable documentation for the error."`

// Title provides a short static summary of the problem. Huma will default this
// to the HTTP response status code text if not present.
Title string `json:"title,omitempty" example:"Bad Request" doc:"A short, human-readable summary of the problem type. This value should not change between occurances of the error."`
Title string `json:"title,omitempty" example:"Bad Request" doc:"A short, human-readable summary of the problem type. This value should not change between occurrences of the error."`

// Status provides the HTTP status code for client convenience. Huma will
// default this to the response status code if unset. This SHOULD match the
Expand All @@ -58,18 +82,29 @@ type ErrorModel struct {
// Detail is an explanation specific to this error occurrence.
Detail string `json:"detail,omitempty" example:"Property foo is required but is missing." doc:"A human-readable explanation specific to this occurrence of the problem."`

// Instance is a URI to get more info about this error occurence.
Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurence of the problem."`
// Instance is a URI to get more info about this error occurrence.
Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurrence of the problem."`

// Errors provides an optional mechanism of passing additional error details
// as a list.
Errors []*ErrorDetail `json:"errors,omitempty" doc:"Optional list of individual error details"`
}

// Error satisfies the `error` interface. It returns the error's detail field.
func (e *ErrorModel) Error() string {
return e.Detail
}

// Add an error to the `Errors` slice. If passed a struct that satisfies the
// `huma.ErrorDetailer` interface, then it is used, otherwise the error
// string is used as the error detail message.
//
// err := &ErrorModel{ /* ... */ }
// err.Add(&huma.ErrorDetail{
// Message: "expected boolean",
// Location: "body.friends[1].active",
// Value: 5
// })
func (e *ErrorModel) Add(err error) {
if converted, ok := err.(ErrorDetailer); ok {
e.Errors = append(e.Errors, converted.ErrorDetail())
Expand All @@ -79,10 +114,15 @@ func (e *ErrorModel) Add(err error) {
e.Errors = append(e.Errors, &ErrorDetail{Message: err.Error()})
}

// GetStatus returns the HTTP status that should be returned to the client
// for this error.
func (e *ErrorModel) GetStatus() int {
return e.Status
}

// ContentType provides a filter to adjust response content types. This is
// used to ensure e.g. `application/problem+json` content types defined in
// RFC 7807 Problem Details for HTTP APIs are used in responses to clients.
func (e *ErrorModel) ContentType(ct string) string {
if ct == "application/json" {
return "application/problem+json"
Expand All @@ -102,16 +142,47 @@ type ContentTypeFilter interface {
}

// StatusError is an error that has an HTTP status code. When returned from
// an operation handler, this sets the response status code.
// an operation handler, this sets the response status code before sending it
// to the client.
type StatusError interface {
GetStatus() int
Error() string
}

// NewError creates a new instance of an error model with the given status code,
// message, and errors. If the error implements the `ErrorDetailer` interface,
// the error details will be used. Otherwise, the error message will be used.
// Replace this function to use your own error type.
// message, and optional error details. If the error details implement the
// `ErrorDetailer` interface, the error details will be used. Otherwise, the
// error string will be used as the message. This function is used by all the
// error response utility functions, like `huma.Error400BadRequest`.
//
// Replace this function to use your own error type. Example:
//
// type MyDetail struct {
// Message string `json:"message"`
// Location string `json:"location"`
// }
//
// type MyError struct {
// status int
// Message string `json:"message"`
// Errors []error `json:"errors"`
// }
//
// func (e *MyError) Error() string {
// return e.Message
// }
//
// func (e *MyError) GetStatus() int {
// return e.status
// }
//
// huma.NewError = func(status int, msg string, errs ...error) StatusError {
// return &MyError{
// status: status,
// Message: msg,
// Errors: errs,
// }
// }
var NewError = func(status int, msg string, errs ...error) StatusError {
details := make([]*ErrorDetail, len(errs))
for i := 0; i < len(errs); i++ {
Expand All @@ -132,17 +203,21 @@ var NewError = func(status int, msg string, errs ...error) StatusError {
// WriteErr writes an error response with the given context, using the
// configured error type and with the given status code and message. It is
// marshaled using the API's content negotiation methods.
func WriteErr(api API, ctx Context, status int, msg string, errs ...error) {
func WriteErr(api API, ctx Context, status int, msg string, errs ...error) error {
var err any = NewError(status, msg, errs...)

ct, _ := api.Negotiate(ctx.Header("Accept"))
ct, negotiateErr := api.Negotiate(ctx.Header("Accept"))
if negotiateErr != nil {
return negotiateErr
}

if ctf, ok := err.(ContentTypeFilter); ok {
ct = ctf.ContentType(ct)
}

ctx.SetHeader("Content-Type", ct)
ctx.SetStatus(status)
api.Marshal(ctx, strconv.Itoa(status), ct, err)
return api.Marshal(ctx, strconv.Itoa(status), ct, err)
}

// Status304NotModified returns a 304. This is not really an error, but
Expand Down
17 changes: 16 additions & 1 deletion error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package huma

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
)

// Ensure the default error model satisfies these interfaces.
// Ensure the default error models satisfy these interfaces.
var _ StatusError = (*ErrorModel)(nil)
var _ ContentTypeFilter = (*ErrorModel)(nil)
var _ ErrorDetailer = (*ErrorDetail)(nil)

func TestError(t *testing.T) {
err := &ErrorModel{
Expand Down Expand Up @@ -68,3 +72,14 @@ func TestErrorResponses(t *testing.T) {
assert.Equal(t, item.expected, err.GetStatus())
}
}

func TestNegotiateError(t *testing.T) {
r := chi.NewMux()
api := NewTestAdapter(r, Config{})

req, _ := http.NewRequest("GET", "/", nil)
resp := httptest.NewRecorder()
ctx := &testContext{nil, req, resp}

assert.Error(t, WriteErr(api, ctx, 400, "bad request"))
}
Loading