diff --git a/codecov.yml b/codecov.yml index c0a86056..44e8b0b5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,3 +2,4 @@ ignore: - benchmark - examples - adapters + - cookie.go diff --git a/cookie.go b/cookie.go new file mode 100644 index 00000000..7dbdd5e5 --- /dev/null +++ b/cookie.go @@ -0,0 +1,189 @@ +package huma + +import ( + "fmt" + "net/http" + "net/textproto" + "strings" +) + +// ReadCookie reads a single cookie from the request headers by name. If +// multiple cookies with the same name exist, the first is returned. +func ReadCookie(ctx Context, name string) (*http.Cookie, error) { + headers := []string{} + ctx.EachHeader(func(name, value string) { + if strings.EqualFold(name, "cookie") { + headers = append(headers, value) + } + }) + for _, c := range readCookies(headers, name) { + return c, nil + } + return nil, fmt.Errorf("%w: %s", http.ErrNoCookie, name) +} + +// ReadCookies reads all cookies from the request headers. +func ReadCookies(ctx Context) []*http.Cookie { + headers := []string{} + ctx.EachHeader(func(name, value string) { + if strings.EqualFold(name, "cookie") { + headers = append(headers, value) + } + }) + return readCookies(headers, "") +} + +// Everything below this line is copied from the Go standard library. None of +// it is exported, so we have to copy it here to use it. 😭 + +var isTokenTable = [127]bool{ + '!': true, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '*': true, + '+': true, + '-': true, + '.': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'W': true, + 'V': true, + 'X': true, + 'Y': true, + 'Z': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '|': true, + '~': true, +} + +func isTokenRune(r rune) bool { + i := int(r) + return i < len(isTokenTable) && isTokenTable[i] +} + +func isNotToken(r rune) bool { + return !isTokenRune(r) +} + +// readCookies parses all "Cookie" values from the header h and +// returns the successfully parsed Cookies. +// +// if filter isn't empty, only cookies of that name are returned. +func readCookies(lines []string, filter string) []*http.Cookie { + // lines := h["Cookie"] + if len(lines) == 0 { + return []*http.Cookie{} + } + + cookies := make([]*http.Cookie, 0, len(lines)+strings.Count(lines[0], ";")) + for _, line := range lines { + line = textproto.TrimString(line) + + var part string + for len(line) > 0 { // continue since we have rest + part, line, _ = strings.Cut(line, ";") + part = textproto.TrimString(part) + if part == "" { + continue + } + name, val, _ := strings.Cut(part, "=") + name = textproto.TrimString(name) + if !isCookieNameValid(name) { + continue + } + if filter != "" && filter != name { + continue + } + val, ok := parseCookieValue(val, true) + if !ok { + continue + } + cookies = append(cookies, &http.Cookie{Name: name, Value: val}) + } + } + return cookies +} + +func validCookieValueByte(b byte) bool { + return 0x20 <= b && b < 0x7f && b != '"' && b != ';' && b != '\\' +} + +func parseCookieValue(raw string, allowDoubleQuote bool) (string, bool) { + // Strip the quotes, if present. + if allowDoubleQuote && len(raw) > 1 && raw[0] == '"' && raw[len(raw)-1] == '"' { + raw = raw[1 : len(raw)-1] + } + for i := 0; i < len(raw); i++ { + if !validCookieValueByte(raw[i]) { + return "", false + } + } + return raw, true +} + +func isCookieNameValid(raw string) bool { + if raw == "" { + return false + } + return strings.IndexFunc(raw, isNotToken) < 0 +} diff --git a/docs/docs/features/middleware.md b/docs/docs/features/middleware.md index fc81abf8..3790d880 100644 --- a/docs/docs/features/middleware.md +++ b/docs/docs/features/middleware.md @@ -73,6 +73,37 @@ func NewHumaAPI() huma.API { } ``` +### Cookies + +You can use the `huma.Context` interface along with [`huma.ReadCookie`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookie) or [`huma.ReadCookies`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookies) to access cookies from middleware, and can also write cookies by adding `Set-Cookie` headers in the response: + +```go +func MyMiddleware(ctx huma.Context, next func(huma.Context)) { + // Read a cookie by name. + sessionCookie := huma.ReadCookie(ctx, "session") + fmt.Println(sessionCookie) + + // Read all the cookies from the request. + cookies := huma.ReadCookies(ctx) + fmt.Println(cookies) + + // Set a cookie in the response. Using `ctx.AppendHeader` won't overwrite + // any existing headers, for example if other middleware might also set + // headers or if this code were moved after the `next` call and the operation + // might set the same header. You can also call `ctx.AppendHeader` multiple + // times to write more than one cookie. + cookie := http.Cookie{ + Name: "session", + Value: "123", + } + ctx.AppendHeader("Set-Cookie", cookie.String()) + + // Call the next middleware in the chain. This eventually calls the + // operation handler as well. + next(ctx) +} +``` + ### Errors If your middleware encounters an error, you can stop the processing of the next middleware or operation handler by skipping the call to `next` and writing an error response. @@ -103,5 +134,7 @@ func MyMiddleware(ctx huma.Context, next func(ctx huma.Context)) { - Reference - [`huma.Context`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Context) a router-agnostic request/response context - [`huma.Middlewares`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Middlewares) the API instance + - [`huma.ReadCookie`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookie) reads a named cookie from a request + - [`huma.ReadCookies`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#ReadCookies) reads cookies from a request - [`huma.WriteErr`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#WriteErr) function to write error responses - [`huma.API`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#API) the API instance diff --git a/docs/docs/features/request-inputs.md b/docs/docs/features/request-inputs.md index d407e48b..cb8e0c97 100644 --- a/docs/docs/features/request-inputs.md +++ b/docs/docs/features/request-inputs.md @@ -13,6 +13,7 @@ Requests can have parameters and/or a body as input to the handler function. Inp | `path` | Name of the path parameter | `path:"thing-id"` | | `query` | Name of the query string parameter | `query:"q"` | | `header` | Name of the header parameter | `header:"Authorization"` | +| `cookie` | Name of the cookie parameter | `cookie:"session"` | | `required` | Mark a query/header param as required | `required:"true"` | !!! info "Required" @@ -34,6 +35,16 @@ The following parameter types are supported out of the box: For example, if the parameter is a query param and the type is `[]string` it might look like `?tags=tag1,tag2` in the URI. +For cookies, the default behavior is to read the cookie _value_ from the request and convert it to one of the types above. If you want to access the entire cookie, you can use `http.Cookie` as the type instead: + +```go title="code.go" +type MyInput struct { + Session http.Cookie `cookie:"session"` +} +``` + +Then you can access e.g. `input.Session.Name` or `input.Session.Value`. + ## Request Body The special struct field `Body` will be treated as the input request body and can refer to any other type or you can embed a struct or slice inline. If the body is a pointer, then it is optional. All doc & validation tags are allowed on the body in addition to these tags: diff --git a/docs/docs/features/response-outputs.md b/docs/docs/features/response-outputs.md index bca1b3da..d0df9bcb 100644 --- a/docs/docs/features/response-outputs.md +++ b/docs/docs/features/response-outputs.md @@ -62,9 +62,10 @@ huma.Register(api, huma.Operation{ Headers are set by fields on the response struct. Here are the available tags: -| Tag | Description | Example | -| -------- | --------------------------- | ------------------------ | -| `header` | Name of the response header | `header:"Authorization"` | +| Tag | Description | Example | +| ------------ | --------------------------- | -------------------------------------------- | +| `header` | Name of the response header | `header:"Authorization"` | +| `timeFormat` | Format of a `time.Time` | `timeFormat:"Mon, 02 Jan 2006 15:04:05 GMT"` | Here's an example of a response with several headers of different types: @@ -77,6 +78,48 @@ type MyOutput struct { } ``` +If the field type implements the [`fmt.Stringer`](https://pkg.go.dev/fmt#Stringer) interface then that will be used to convert the value to a string. + +### Set vs. Append + +By default, headers are set on the response, which overwrites any existing header of the same name. If you want to append to an existing header, you can use an array of values instead of a single value. + +```go title="code.go" +type MyOutput struct { + MyHeader []string `header:"My-Header"` +} +``` + +If you want to append just one header, you can use a slice with a single value. + +### Cookies + +You can set cookies in the response by using the [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header. The [`http.Cookie`](https://pkg.go.dev/net/http#Cookie) type can be used to represent the cookie without needing to manually convert it to a string. + +```go title="code.go" +type MyOutput struct { + SetCookie http.Cookie `header:"Set-Cookie"` +} + +huma.Register(api, huma.Operation{ + OperationID: "set-cookie", + Method: http.MethodGet, + Path: "/set-cookie", + Summary: "Set a cookie", +}, func(ctx context.Context, *struct{}) (*MyOutput, error) { + // Create a response and set the cookie + resp := &MyOutput{ + SetCookie: http.Cookie{ + Name: "session", + Value: "123", + }, + } + return resp, nil +} +``` + +You can set multiple cookies by using a slice like `[]http.Cookie` instead. + ## Body The special struct field `Body` will be treated as the response body and can refer to any other type or you can embed a struct or slice inline. A default `Content-Type` header will be set if none is present, selected via client-driven content negotiation with the server based on the registered serialization types. diff --git a/examples/cookies/main.go b/examples/cookies/main.go new file mode 100644 index 00000000..ae80897b --- /dev/null +++ b/examples/cookies/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" +) + +// Options for the CLI. +type Options struct { + Port int `help:"Port to listen on" short:"p" default:"8888"` +} + +// GreetingInput represents the greeting operation request. +type GreetingInput struct { + Name string `path:"name" maxLength:"30" example:"world" doc:"Name to greet"` + Foo http.Cookie `cookie:"foo"` +} + +// GreetingOutput represents the greeting operation response. +type GreetingOutput struct { + SetCookie []*http.Cookie `header:"Set-Cookie"` + Body struct { + Message string `json:"message" example:"Hello, world!" doc:"Greeting message"` + } +} + +func main() { + // Create a CLI app which takes a port option. + cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) { + // Create a new router & API + router := chi.NewMux() + api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0")) + + // Register GET /greeting/{name} + huma.Register(api, huma.Operation{ + OperationID: "get-greeting", + Summary: "Get a greeting", + Method: http.MethodGet, + Path: "/greeting/{name}", + }, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) { + fmt.Println("cookie foo is", input.Foo.Value) + + resp := &GreetingOutput{} + resp.Body.Message = fmt.Sprintf("Hello, %s!", input.Name) + + // Set some cookies: + resp.SetCookie = []*http.Cookie{ + { + Domain: "example.com", + Name: "foo", + Value: "bar", + Expires: time.Now().Add(24 * time.Hour), + }, + { + Name: "baz", + Value: "123", + }, + } + + return resp, nil + }) + + // Tell the CLI how to start your router. + hooks.OnStart(func() { + http.ListenAndServe(fmt.Sprintf(":%d", options.Port), router) + }) + }) + + // Run the CLI. When passed no commands, it starts the server. + cli.Run() +} diff --git a/huma.go b/huma.go index dd277b99..46184f04 100644 --- a/huma.go +++ b/huma.go @@ -25,6 +25,9 @@ import ( var errDeadlineUnsupported = fmt.Errorf("%w", http.ErrNotSupported) var bodyCallbackType = reflect.TypeOf(func(Context) {}) +var cookieType = reflect.TypeOf((*http.Cookie)(nil)).Elem() +var fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() +var stringType = reflect.TypeOf("") // slicesIndex returns the index of the first occurrence of v in s, // or -1 if not present. @@ -114,13 +117,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p } pfi := ¶mFieldInfo{ - Type: f.Type, - Schema: SchemaFromField(registry, f, ""), - } - - var example any - if e := f.Tag.Get("example"); e != "" { - example = jsonTagValue(registry, f.Type.Name(), pfi.Schema, f.Tag.Get("example")) + Type: f.Type, } if def := f.Tag.Get("default"); def != "" { @@ -143,10 +140,26 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p } else if h := f.Tag.Get("header"); h != "" { pfi.Loc = "header" name = h + } else if c := f.Tag.Get("cookie"); c != "" { + pfi.Loc = "cookie" + name = c + + if f.Type == cookieType { + // Special case: this will be parsed from a string input to a + // `http.Cookie` struct. + f.Type = stringType + } } else { return nil } + pfi.Schema = SchemaFromField(registry, f, "") + + var example any + if e := f.Tag.Get("example"); e != "" { + example = jsonTagValue(registry, f.Type.Name(), pfi.Schema, f.Tag.Get("example")) + } + // While discouraged, make it possible to make query/header params required. if r := f.Tag.Get("required"); r == "true" { pfi.Required = true @@ -177,7 +190,7 @@ func findParams(registry Registry, op *Operation, t reflect.Type) *findResult[*p }) } return pfi - }, "Body") + }, false, "Body") } func findResolvers(resolverType, t reflect.Type) *findResult[bool] { @@ -187,7 +200,7 @@ func findResolvers(resolverType, t reflect.Type) *findResult[bool] { return true } return false - }, nil) + }, nil, true) } func findDefaults(registry Registry, t reflect.Type) *findResult[any] { @@ -200,7 +213,7 @@ func findDefaults(registry Registry, t reflect.Type) *findResult[any] { return convertType(sf.Type.Name(), sf.Type, jsonTagValue(registry, sf.Name, s, d)) } return nil - }) + }, true) } type headerInfo struct { @@ -223,7 +236,7 @@ func findHeaders(t reflect.Type) *findResult[*headerInfo] { } } return &headerInfo{sf, header, timeFormat} - }, "Status", "Body") + }, false, "Status", "Body") } type findResultPath[T comparable] struct { @@ -351,13 +364,13 @@ func (r *findResult[T]) EveryPB(pb *PathBuffer, v reflect.Value, f func(reflect. } } -func findInType[T comparable](t reflect.Type, onType func(reflect.Type, []int) T, onField func(reflect.StructField, []int) T, ignore ...string) *findResult[T] { +func findInType[T comparable](t reflect.Type, onType func(reflect.Type, []int) T, onField func(reflect.StructField, []int) T, recurseFields bool, ignore ...string) *findResult[T] { result := &findResult[T]{} - _findInType(t, []int{}, result, onType, onField, ignore...) + _findInType(t, []int{}, result, onType, onField, recurseFields, ignore...) return result } -func _findInType[T comparable](t reflect.Type, path []int, result *findResult[T], onType func(reflect.Type, []int) T, onField func(reflect.StructField, []int) T, ignore ...string) { +func _findInType[T comparable](t reflect.Type, path []int, result *findResult[T], onType func(reflect.Type, []int) T, onField func(reflect.StructField, []int) T, recurseFields bool, ignore ...string) { t = deref(t) zero := reflect.Zero(reflect.TypeOf((*T)(nil)).Elem()).Interface() @@ -393,12 +406,17 @@ func _findInType[T comparable](t reflect.Type, path []int, result *findResult[T] result.Paths = append(result.Paths, findResultPath[T]{fi, v}) } } - _findInType(f.Type, fi, result, onType, onField, ignore...) + if f.Anonymous || recurseFields || deref(f.Type).Kind() != reflect.Struct { + // Always process embedded structs and named fields which are not + // structs. If `recurseFields` is true then we also process named + // struct fields recursively. + _findInType(f.Type, fi, result, onType, onField, recurseFields, ignore...) + } } case reflect.Slice: - _findInType(t.Elem(), path, result, onType, onField, ignore...) + _findInType(t.Elem(), path, result, onType, onField, recurseFields, ignore...) case reflect.Map: - _findInType(t.Elem(), path, result, onType, onField, ignore...) + _findInType(t.Elem(), path, result, onType, onField, recurseFields, ignore...) } } @@ -462,6 +480,42 @@ func parseArrElement[T any](values []string, parse func(string) (T, error)) ([]T return result, nil } +// writeHeader is a utility function to write a header value to the response. +// the `write` function should be either `ctx.SetHeader` or `ctx.AppendHeader`. +func writeHeader(write func(string, string), info *headerInfo, f reflect.Value) { + switch f.Kind() { + case reflect.String: + if f.String() == "" { + // Don't set empty headers. + return + } + write(info.Name, f.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + write(info.Name, strconv.FormatInt(f.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + write(info.Name, strconv.FormatUint(f.Uint(), 10)) + case reflect.Float32, reflect.Float64: + write(info.Name, strconv.FormatFloat(f.Float(), 'f', -1, 64)) + case reflect.Bool: + write(info.Name, strconv.FormatBool(f.Bool())) + default: + if f.Type() == timeType && !f.Interface().(time.Time).IsZero() { + write(info.Name, f.Interface().(time.Time).Format(info.TimeFormat)) + return + } + + // If the field value has a `String() string` method, use it. + if f.CanAddr() { + if s, ok := f.Addr().Interface().(fmt.Stringer); ok { + write(info.Name, s.String()) + return + } + } + + write(info.Name, fmt.Sprintf("%v", f.Interface())) + } +} + // Register an operation handler for an API. The handler must be a function that // takes a context and a pointer to the input struct and returns a pointer to the // output struct and an error. The input struct must be a struct with fields @@ -639,10 +693,19 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) op.Responses[defaultStatusStr].Headers = map[string]*Param{} } v := entry.Value + f := v.Field + if f.Type.Kind() == reflect.Slice { + f.Type = deref(f.Type.Elem()) + } + if reflect.PointerTo(f.Type).Implements(fmtStringerType) { + // Special case: this field will be written as a string by calling + // `.String()` on the value. + f.Type = stringType + } op.Responses[defaultStatusStr].Headers[v.Name] = &Header{ // We need to generate the schema from the field to get validation info // like min/max and enums. Useful to let the client know possible values. - Schema: SchemaFromField(registry, v.Field, getHint(outputType, v.Field.Name, op.OperationID+defaultStatusStr+v.Name)), + Schema: SchemaFromField(registry, f, getHint(outputType, f.Name, op.OperationID+defaultStatusStr+v.Name)), } } @@ -703,6 +766,8 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) errStatus := http.StatusUnprocessableEntity + var cookies map[string]*http.Cookie + v := reflect.ValueOf(&input).Elem() inputParams.Every(v, func(f reflect.Value, p *paramFieldInfo) { var value string @@ -713,6 +778,24 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) value = ctx.Query(p.Name) case "header": value = ctx.Header(p.Name) + case "cookie": + if cookies == nil { + // Only parse the cookie headers once, on-demand. + cookies = map[string]*http.Cookie{} + for _, c := range ReadCookies(ctx) { + cookies[c.Name] = c + } + } + if c, ok := cookies[p.Name]; ok { + // Special case: http.Cookie type, meaning we want the entire parsed + // cookie struct, not just the value. + if f.Type() == cookieType { + f.Set(reflect.ValueOf(cookies[p.Name]).Elem()) + return + } + + value = c.Value + } } pb.Reset() @@ -1144,31 +1227,16 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) ct := "" vo := reflect.ValueOf(output).Elem() outHeaders.Every(vo, func(f reflect.Value, info *headerInfo) { - switch f.Kind() { - case reflect.String: - if f.String() == "" { - // Don't set empty headers. - return + if f.Kind() == reflect.Slice { + for i := 0; i < f.Len(); i++ { + writeHeader(ctx.AppendHeader, info, f.Index(i)) } - ctx.SetHeader(info.Name, f.String()) - if info.Name == "Content-Type" { + } else { + if f.Kind() == reflect.String && info.Name == "Content-Type" { + // Track custom content type. ct = f.String() } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - ctx.SetHeader(info.Name, strconv.FormatInt(f.Int(), 10)) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - ctx.SetHeader(info.Name, strconv.FormatUint(f.Uint(), 10)) - case reflect.Float32, reflect.Float64: - ctx.SetHeader(info.Name, strconv.FormatFloat(f.Float(), 'f', -1, 64)) - case reflect.Bool: - ctx.SetHeader(info.Name, strconv.FormatBool(f.Bool())) - default: - if f.Type() == timeType && !f.Interface().(time.Time).IsZero() { - ctx.SetHeader(info.Name, f.Interface().(time.Time).Format(info.TimeFormat)) - return - } - - ctx.SetHeader(info.Name, fmt.Sprintf("%v", f.Interface())) + writeHeader(ctx.SetHeader, info, f) } }) diff --git a/huma_test.go b/huma_test.go index 0892b379..df9e90cf 100644 --- a/huma_test.go +++ b/huma_test.go @@ -74,6 +74,30 @@ func TestFeatures(t *testing.T) { assert.Equal(t, 299, resp.Code) }, }, + { + Name: "middleware-cookie", + Register: func(t *testing.T, api huma.API) { + api.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) { + cookie, err := huma.ReadCookie(ctx, "foo") + require.NoError(t, err) + assert.Equal(t, "bar", cookie.Value) + + next(ctx) + }) + huma.Register(api, huma.Operation{ + Method: http.MethodGet, + Path: "/middleware", + }, func(ctx context.Context, input *struct{}) (*struct{}, error) { + // This should never be called because of the middleware. + return nil, nil + }) + }, + Method: http.MethodGet, + URL: "/middleware", + Headers: map[string]string{ + "Cookie": "foo=bar", + }, + }, { Name: "params", Register: func(t *testing.T, api huma.API) { @@ -98,14 +122,17 @@ func TestFeatures(t *testing.T) { QueryInts64 []int64 `query:"ints64"` QueryUints []uint `query:"uints"` // QueryUints8 []uint8 `query:"uints8"` - QueryUints16 []uint16 `query:"uints16"` - QueryUints32 []uint32 `query:"uints32"` - QueryUints64 []uint64 `query:"uints64"` - QueryFloats32 []float32 `query:"floats32"` - QueryFloats64 []float64 `query:"floats64"` - HeaderString string `header:"String"` - HeaderInt int `header:"Int"` - HeaderDate time.Time `header:"Date"` + QueryUints16 []uint16 `query:"uints16"` + QueryUints32 []uint32 `query:"uints32"` + QueryUints64 []uint64 `query:"uints64"` + QueryFloats32 []float32 `query:"floats32"` + QueryFloats64 []float64 `query:"floats64"` + HeaderString string `header:"String"` + HeaderInt int `header:"Int"` + HeaderDate time.Time `header:"Date"` + CookieValue string `cookie:"one"` + CookieInt int `cookie:"two"` + CookieFull http.Cookie `cookie:"three"` }) (*struct{}, error) { assert.Equal(t, "foo", input.PathString) assert.Equal(t, 123, input.PathInt) @@ -130,8 +157,14 @@ func TestFeatures(t *testing.T) { assert.Equal(t, []uint64{10, 15}, input.QueryUints64) assert.Equal(t, []float32{2.2, 2.3}, input.QueryFloats32) assert.Equal(t, []float64{3.2, 3.3}, input.QueryFloats64) + assert.Equal(t, "foo", input.CookieValue) + assert.Equal(t, 123, input.CookieInt) + assert.Equal(t, "bar", input.CookieFull.Value) return nil, nil }) + + // `http.Cookie` should be treated as a string. + assert.Equal(t, "string", api.OpenAPI().Paths["/test-params/{string}/{int}"].Get.Parameters[26].Schema.Type) }, Method: http.MethodGet, URL: "/test-params/foo/123?string=bar&int=456&before=2023-01-01T12:00:00Z&date=2023-01-01&uint=1&bool=true&strings=foo,bar&ints=2,3&ints8=4,5&ints16=4,5&ints32=4,5&ints64=4,5&uints=1,2&uints16=10,15&uints32=10,15&uints64=10,15&floats32=2.2,2.3&floats64=3.2,3.3", @@ -139,6 +172,7 @@ func TestFeatures(t *testing.T) { "string": "baz", "int": "789", "date": "Mon, 01 Jan 2023 12:00:00 GMT", + "cookie": "one=foo; two=123; three=bar", }, }, { @@ -439,6 +473,71 @@ func TestFeatures(t *testing.T) { assert.Empty(t, resp.Header().Values("Empty")) }, }, + { + Name: "response-cookie", + Register: func(t *testing.T, api huma.API) { + type Resp struct { + SetCookie http.Cookie `header:"Set-Cookie"` + } + + huma.Register(api, huma.Operation{ + Method: http.MethodGet, + Path: "/response-cookie", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.SetCookie = http.Cookie{ + Name: "foo", + Value: "bar", + } + return resp, nil + }) + + // `http.Cookie` should be treated as a string. + assert.Equal(t, "string", api.OpenAPI().Paths["/response-cookie"].Get.Responses["204"].Headers["Set-Cookie"].Schema.Type) + }, + Method: http.MethodGet, + URL: "/response-cookie", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Equal(t, "foo=bar", resp.Header().Get("Set-Cookie")) + }, + }, + { + Name: "response-cookies", + Register: func(t *testing.T, api huma.API) { + type Resp struct { + SetCookie []http.Cookie `header:"Set-Cookie"` + } + + huma.Register(api, huma.Operation{ + Method: http.MethodGet, + Path: "/response-cookies", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.SetCookie = []http.Cookie{ + { + Name: "foo", + Value: "bar", + }, + { + Name: "baz", + Value: "123", + }, + } + return resp, nil + }) + + // `[]http.Cookie` should be treated as a string. + assert.Equal(t, "string", api.OpenAPI().Paths["/response-cookies"].Get.Responses["204"].Headers["Set-Cookie"].Schema.Type) + }, + Method: http.MethodGet, + URL: "/response-cookies", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Equal(t, "foo=bar", resp.Header()["Set-Cookie"][0]) + assert.Equal(t, "baz=123", resp.Header()["Set-Cookie"][1]) + }, + }, { Name: "response-custom-content-type", Register: func(t *testing.T, api huma.API) {