From 201448721d839b39cde4abc2a92a245030fd94ce Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Wed, 6 Sep 2023 16:44:58 -0700 Subject: [PATCH] fix: improved tests, fixed a few small issues --- api.go | 8 +- api_test.go | 16 +++ conditional/params.go | 2 +- error.go | 10 +- error_test.go | 70 ++++++++++ huma.go | 21 ++- huma_test.go | 293 ++++++++++++++++++++++++++++++++++++++++-- schema.go | 6 + validate.go | 87 ++++++++----- validate_test.go | 72 ++++++++++- 10 files changed, 525 insertions(+), 60 deletions(-) create mode 100644 api_test.go create mode 100644 error_test.go diff --git a/api.go b/api.go index 01bb51b1..a9404898 100644 --- a/api.go +++ b/api.go @@ -158,7 +158,12 @@ func (r *api) Unmarshal(contentType string, data []byte, v any) error { if end == -1 { end = len(contentType) } - f, ok := r.formats[contentType[start:end]] + ct := contentType[start:end] + if ct == "" { + // Default to assume JSON since this is an API. + ct = "application/json" + } + f, ok := r.formats[ct] if !ok { return fmt.Errorf("unknown content type: %s", contentType) } @@ -177,7 +182,6 @@ func (r *api) Negotiate(accept string) (string, error) { } func (a *api) Marshal(ctx Context, respKey string, ct string, v any) error { - // fmt.Println("marshaling", ct) var err error for _, t := range a.transformers { diff --git a/api_test.go b/api_test.go new file mode 100644 index 00000000..145a59ec --- /dev/null +++ b/api_test.go @@ -0,0 +1,16 @@ +package huma + +import ( + "testing" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" +) + +func TestBlankConfig(t *testing.T) { + adapter := &testAdapter{chi.NewMux()} + + assert.NotPanics(t, func() { + NewAPI(Config{}, adapter) + }) +} diff --git a/conditional/params.go b/conditional/params.go index 1d354633..06a3842c 100644 --- a/conditional/params.go +++ b/conditional/params.go @@ -134,7 +134,7 @@ func (p *Params) PreconditionFailed(etag string, modified time.Time) huma.Status ) } - return huma.Status304NotModied() + return huma.Status304NotModified() } return nil diff --git a/error.go b/error.go index 7a92dbf2..49ddd8fa 100644 --- a/error.go +++ b/error.go @@ -108,10 +108,6 @@ type StatusError interface { Error() string } -// Ensure the default error model satisfies these interfaces. -var _ StatusError = (*ErrorModel)(nil) -var _ ContentTypeFilter = (*ErrorModel)(nil) - // 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. @@ -149,9 +145,9 @@ func WriteErr(api API, ctx Context, status int, msg string, errs ...error) { api.Marshal(ctx, strconv.Itoa(status), ct, err) } -// Status304NotModied returns a 304. This is not really an error, but provides -// a way to send non-default responses. -func Status304NotModied() StatusError { +// Status304NotModified returns a 304. This is not really an error, but +// provides a way to send non-default responses. +func Status304NotModified() StatusError { return NewError(http.StatusNotModified, "") } diff --git a/error_test.go b/error_test.go new file mode 100644 index 00000000..3c1b95e2 --- /dev/null +++ b/error_test.go @@ -0,0 +1,70 @@ +package huma + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Ensure the default error model satisfies these interfaces. +var _ StatusError = (*ErrorModel)(nil) +var _ ContentTypeFilter = (*ErrorModel)(nil) + +func TestError(t *testing.T) { + err := &ErrorModel{ + Status: 400, + Detail: "test err", + } + + // Add some children. + err.Add(&ErrorDetail{ + Message: "test detail", + Location: "body.foo", + Value: "bar", + }) + + err.Add(fmt.Errorf("plain error")) + + // Confirm errors were added. + assert.Equal(t, "test err", err.Error()) + assert.Len(t, err.Errors, 2) + assert.Equal(t, "test detail (body.foo: bar)", err.Errors[0].Error()) + assert.Equal(t, "plain error", err.Errors[1].Error()) + + // Ensure problem content types. + assert.Equal(t, "application/problem+json", err.ContentType("application/json")) + assert.Equal(t, "application/problem+cbor", err.ContentType("application/cbor")) + assert.Equal(t, "other", err.ContentType("other")) +} + +func TestErrorResponses(t *testing.T) { + // NotModified has a slightly different signature. + assert.Equal(t, 304, Status304NotModified().GetStatus()) + + for _, item := range []struct { + constructor func(msg string, errs ...error) StatusError + expected int + }{ + {Error400BadRequest, 400}, + {Error401Unauthorized, 401}, + {Error403Forbidden, 403}, + {Error404NotFound, 404}, + {Error405MethodNotAllowed, 405}, + {Error406NotAcceptable, 406}, + {Error409Conflict, 409}, + {Error410Gone, 410}, + {Error412PreconditionFailed, 412}, + {Error415UnsupportedMediaType, 415}, + {Error422UnprocessableEntity, 422}, + {Error429TooManyRequests, 429}, + {Error500InternalServerError, 500}, + {Error501NotImplemented, 501}, + {Error502BadGateway, 502}, + {Error503ServiceUnavailable, 503}, + {Error504GatewayTimeout, 504}, + } { + err := item.constructor("test") + assert.Equal(t, item.expected, err.GetStatus()) + } +} diff --git a/huma.go b/huma.go index 23ad866a..1ae579a5 100644 --- a/huma.go +++ b/huma.go @@ -24,13 +24,12 @@ var bodyCallbackType = reflect.TypeOf(func(Context) {}) // if possible. If not, it will not incur any allocations (unlike the stdlib // `http.ResponseController`). func SetReadDeadline(w http.ResponseWriter, deadline time.Time) error { - rw := w for { - switch t := rw.(type) { + switch t := w.(type) { case interface{ SetReadDeadline(time.Time) error }: return t.SetReadDeadline(deadline) case interface{ Unwrap() http.ResponseWriter }: - rw = t.Unwrap() + w = t.Unwrap() default: return errDeadlineUnsupported } @@ -170,12 +169,13 @@ type findResult[T comparable] struct { } func (r *findResult[T]) every(current reflect.Value, path []int, v T, f func(reflect.Value, T)) { + if len(path) == 0 { + f(current, v) + return + } + switch current.Kind() { case reflect.Struct: - if len(path) == 0 { - f(current, v) - return - } r.every(reflect.Indirect(current.Field(path[0])), path[1:], v, f) case reflect.Slice: for j := 0; j < current.Len(); j++ { @@ -186,10 +186,6 @@ func (r *findResult[T]) every(current reflect.Value, path []int, v T, f func(ref r.every(reflect.Indirect(current.MapIndex(k)), path, v, f) } default: - if len(path) == 0 { - f(current, v) - return - } panic("unsupported") } } @@ -619,6 +615,9 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) buf := bufPool.Get().(*bytes.Buffer) reader := ctx.BodyReader() + if reader == nil { + reader = bytes.NewReader(nil) + } if closer, ok := reader.(io.Closer); ok { defer closer.Close() } diff --git a/huma_test.go b/huma_test.go index 54c40b08..adc7460c 100644 --- a/huma_test.go +++ b/huma_test.go @@ -129,6 +129,7 @@ func TestFeatures(t *testing.T) { Method string URL string Headers map[string]string + Body string Assert func(t *testing.T, resp *httptest.ResponseRecorder) }{ { @@ -145,30 +146,207 @@ func TestFeatures(t *testing.T) { QueryDefault float32 `query:"def" default:"135" example:"5"` QueryBefore time.Time `query:"before"` QueryDate time.Time `query:"date" timeFormat:"2006-01-02"` + QueryUint uint32 `query:"uint"` + QueryBool bool `query:"bool"` + QueryStrings []string `query:"strings"` HeaderString string `header:"String"` HeaderInt int `header:"Int"` + HeaderDate time.Time `header:"Date"` }) (*struct{}, error) { assert.Equal(t, "foo", input.PathString) assert.Equal(t, 123, input.PathInt) assert.Equal(t, "bar", input.QueryString) assert.Equal(t, 456, input.QueryInt) - assert.Equal(t, float32(135), input.QueryDefault) + assert.EqualValues(t, 135, input.QueryDefault) assert.True(t, input.QueryBefore.Equal(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC))) assert.True(t, input.QueryDate.Equal(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))) + assert.EqualValues(t, 1, input.QueryUint) + assert.Equal(t, true, input.QueryBool) + assert.Equal(t, []string{"foo", "bar"}, input.QueryStrings) assert.Equal(t, "baz", input.HeaderString) assert.Equal(t, 789, input.HeaderInt) return nil, nil }) }, - Method: http.MethodGet, - URL: "/test-params/foo/123?string=bar&int=456&before=2023-01-01T12:00:00Z&date=2023-01-01", - Headers: map[string]string{"string": "baz", "int": "789"}, + 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", + Headers: map[string]string{ + "string": "baz", + "int": "789", + "date": "Mon, 01 Jan 2023 12:00:00 GMT", + }, + }, + { + Name: "params-error", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodGet, + Path: "/test-params/{int}", + }, func(ctx context.Context, input *struct { + PathInt string `path:"int"` + QueryInt int `query:"int"` + QueryFloat float32 `query:"float"` + QueryBefore time.Time `query:"before"` + QueryDate time.Time `query:"date" timeFormat:"2006-01-02"` + QueryUint uint32 `query:"uint"` + QueryBool bool `query:"bool"` + }) (*struct{}, error) { + return nil, nil + }) + }, + Method: http.MethodGet, + URL: "/test-params/bad?int=bad&float=bad&before=bad&date=bad&uint=bad&bool=bad", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + assert.Contains(t, resp.Body.String(), "invalid integer") + assert.Contains(t, resp.Body.String(), "invalid float") + assert.Contains(t, resp.Body.String(), "invalid date/time") + assert.Contains(t, resp.Body.String(), "invalid bool") + }, + }, + { + Name: "request-body", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/body", + }, func(ctx context.Context, input *struct { + RawBody []byte + Body struct { + Name string `json:"name"` + } + }) (*struct{}, error) { + assert.Equal(t, `{"name":"foo"}`, string(input.RawBody)) + assert.Equal(t, "foo", input.Body.Name) + return nil, nil + }) + }, + Method: http.MethodPut, + URL: "/body", + // Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"name":"foo"}`, + }, + { + Name: "request-body-required", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/body", + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name"` + } + }) (*struct{}, error) { + return nil, nil + }) + }, + Method: http.MethodPut, + URL: "/body", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, resp.Code) + }, + }, + { + Name: "request-body-too-large", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/body", + MaxBodyBytes: 1, + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name"` + } + }) (*struct{}, error) { + return nil, nil + }) + }, + Method: http.MethodPut, + URL: "/body", + Body: "foobarbaz", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusRequestEntityTooLarge, resp.Code) + }, + }, + { + Name: "request-body-bad-json", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodPut, + Path: "/body", + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name"` + } + }) (*struct{}, error) { + return nil, nil + }) + }, + Method: http.MethodPut, + URL: "/body", + Body: "{{{", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusBadRequest, resp.Code) + }, + }, + { + Name: "handler-error", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodGet, + Path: "/error", + }, func(ctx context.Context, input *struct{}) (*struct{}, error) { + return nil, Error403Forbidden("nope") + }) + }, + Method: http.MethodGet, + URL: "/error", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusForbidden, resp.Code) + }, + }, + { + Name: "response-headers", + Register: func(t *testing.T, api API) { + type Resp struct { + Str string `header:"str"` + Int int `header:"int"` + Uint uint `header:"uint"` + Float float64 `header:"float"` + Bool bool `header:"bool"` + Date time.Time `header:"date"` + } + + Register(api, Operation{ + Method: http.MethodGet, + Path: "/response-headers", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.Str = "str" + resp.Int = 1 + resp.Uint = 2 + resp.Float = 3.45 + resp.Bool = true + resp.Date = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + return resp, nil + }) + }, + Method: http.MethodGet, + URL: "/response-headers", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Equal(t, "str", resp.Header().Get("Str")) + assert.Equal(t, "1", resp.Header().Get("Int")) + assert.Equal(t, "2", resp.Header().Get("Uint")) + assert.Equal(t, "3.45", resp.Header().Get("Float")) + assert.Equal(t, "true", resp.Header().Get("Bool")) + assert.Equal(t, "Sun, 01 Jan 2023 12:00:00 GMT", resp.Header().Get("Date")) + }, }, { Name: "response", Register: func(t *testing.T, api API) { type Resp struct { - Foo string `header:"foo"` Body struct { Greeting string `json:"greeting"` } @@ -179,7 +357,6 @@ func TestFeatures(t *testing.T) { Path: "/response", }, func(ctx context.Context, input *struct{}) (*Resp, error) { resp := &Resp{} - resp.Foo = "foo" resp.Body.Greeting = "Hello, world!" return resp, nil }) @@ -188,17 +365,86 @@ func TestFeatures(t *testing.T) { URL: "/response", Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { assert.Equal(t, http.StatusOK, resp.Code) - assert.Equal(t, "foo", resp.Header().Get("Foo")) assert.JSONEq(t, `{"$schema": "https:///schemas/RespBody.json", "greeting":"Hello, world!"}`, resp.Body.String()) }, }, + { + Name: "response-raw", + Register: func(t *testing.T, api API) { + type Resp struct { + Body []byte + } + + Register(api, Operation{ + Method: http.MethodGet, + Path: "/response-raw", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + return &Resp{Body: []byte("hello")}, nil + }) + }, + Method: http.MethodGet, + URL: "/response-raw", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `hello`, resp.Body.String()) + }, + }, + { + Name: "response-stream", + Register: func(t *testing.T, api API) { + Register(api, Operation{ + Method: http.MethodGet, + Path: "/stream", + }, func(ctx context.Context, input *struct{}) (*StreamResponse, error) { + return &StreamResponse{ + Body: func(ctx Context) { + writer := ctx.BodyWriter() + writer.Write([]byte("hel")) + writer.Write([]byte("lo")) + }, + }, nil + }) + }, + Method: http.MethodGet, + URL: "/stream", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `hello`, resp.Body.String()) + }, + }, + { + Name: "dynamic-status", + Register: func(t *testing.T, api API) { + type Resp struct { + Status int + } + + Register(api, Operation{ + Method: http.MethodGet, + Path: "/status", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.Status = 256 + return resp, nil + }) + }, + Method: http.MethodGet, + URL: "/status", + Assert: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, 256, resp.Code) + }, + }, } { t.Run(feature.Name, func(t *testing.T) { r := chi.NewRouter() api := NewTestAdapter(r, DefaultConfig("Features Test API", "1.0.0")) feature.Register(t, api) - req, _ := http.NewRequest(feature.Method, feature.URL, nil) + var body io.Reader = nil + if feature.Body != "" { + body = strings.NewReader(feature.Body) + } + req, _ := http.NewRequest(feature.Method, feature.URL, body) for k, v := range feature.Headers { req.Header.Set(k, v) } @@ -206,14 +452,43 @@ func TestFeatures(t *testing.T) { r.ServeHTTP(w, req) b, _ := yaml.Marshal(api.OpenAPI()) t.Log(string(b)) - assert.Less(t, w.Code, 300, w.Body.String()) if feature.Assert != nil { feature.Assert(t, w) + } else { + assert.Less(t, w.Code, 300, w.Body.String()) } }) } } +func TestOpenAPI(t *testing.T) { + r := chi.NewRouter() + api := NewTestAdapter(r, DefaultConfig("Features Test API", "1.0.0")) + + type Resp struct { + Body struct { + Greeting string `json:"greeting"` + } + } + + Register(api, Operation{ + Method: http.MethodGet, + Path: "/test", + }, func(ctx context.Context, input *struct{}) (*Resp, error) { + resp := &Resp{} + resp.Body.Greeting = "Hello, world" + return resp, nil + }) + + for _, url := range []string{"/openapi.json", "/openapi.yaml", "/docs", "/schemas/Resp.json"} { + req, _ := http.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, w.Code, 200, w.Body.String()) + } +} + type ExhaustiveErrorsInputBody struct { Name string `json:"name" maxLength:"10"` Count int `json:"count" minimum:"1"` diff --git a/schema.go b/schema.go index 41ad78d4..41d0f97c 100644 --- a/schema.go +++ b/schema.go @@ -257,6 +257,12 @@ func SchemaFromField(registry Registry, parent reflect.Type, f reflect.StructFie return fs } fs.Description = f.Tag.Get("doc") + if fs.Format == "date-time" && f.Tag.Get("header") != "" { + // Special case: this is a header and uses a different date/time format. + // Note that it can still be overridden by the `format` or `timeFormat` + // tags later. + fs.Format = "date-time-http" + } if fmt := f.Tag.Get("format"); fmt != "" { fs.Format = fmt } diff --git a/validate.go b/validate.go index 7503bd01..6798fc56 100644 --- a/validate.go +++ b/validate.go @@ -185,6 +185,10 @@ func validateFormat(path *PathBuffer, str string, s *Schema, res *ValidateResult if !found { res.Add(path, str, "expected string to be RFC 3339 date-time") } + case "date-time-http": + if _, err := time.Parse(time.RFC1123, str); err != nil { + res.Add(path, str, "expected string to be RFC 1123 date-time") + } case "date": if _, err := time.Parse("2006-01-02", str); err != nil { res.Add(path, str, "expected string to be RFC 3339 date") @@ -271,10 +275,28 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, switch v := v.(type) { case float64: num = v + case float32: + num = float64(v) case int: num = float64(v) + case int8: + num = float64(v) + case int16: + num = float64(v) + case int32: + num = float64(v) case int64: num = float64(v) + case uint: + num = float64(v) + case uint8: + num = float64(v) + case uint16: + num = float64(v) + case uint32: + num = float64(v) + case uint64: + num = float64(v) default: res.Add(path, v, "expected number") return @@ -342,38 +364,16 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, } } case TypeArray: - arr, ok := v.([]any) - if !ok { + switch arr := v.(type) { + case []any: + handleArray(r, s, path, mode, res, arr) + case []string: + // Special case for params which are lists. + handleArray(r, s, path, mode, res, arr) + default: res.Add(path, v, "expected array") return } - - if s.MinItems != nil { - if len(arr) < *s.MinItems { - res.Addf(path, v, s.msgMinItems) - } - } - if s.MaxItems != nil { - if len(arr) > *s.MaxItems { - res.Addf(path, v, s.msgMaxItems) - } - } - - if s.UniqueItems { - seen := make(map[any]struct{}, len(arr)) - for _, item := range arr { - if _, ok := seen[item]; ok { - res.Add(path, v, "expected array items to be unique") - } - seen[item] = struct{}{} - } - } - - for i, item := range arr { - path.PushIndex(i) - Validate(r, s.Items, path, mode, item, res) - path.Pop() - } case TypeObject: if vv, ok := v.(map[string]any); ok { handleMapString(r, s, path, mode, vv, res) @@ -398,6 +398,35 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any, } } +func handleArray[T any](r Registry, s *Schema, path *PathBuffer, mode ValidateMode, res *ValidateResult, arr []T) { + if s.MinItems != nil { + if len(arr) < *s.MinItems { + res.Addf(path, arr, s.msgMinItems) + } + } + if s.MaxItems != nil { + if len(arr) > *s.MaxItems { + res.Addf(path, arr, s.msgMaxItems) + } + } + + if s.UniqueItems { + seen := make(map[any]struct{}, len(arr)) + for _, item := range arr { + if _, ok := seen[item]; ok { + res.Add(path, arr, "expected array items to be unique") + } + seen[item] = struct{}{} + } + } + + for i, item := range arr { + path.PushIndex(i) + Validate(r, s.Items, path, mode, item, res) + path.Pop() + } +} + func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m map[string]any, res *ValidateResult) { if s.MinProperties != nil { if len(m) < *s.MinProperties { diff --git a/validate_test.go b/validate_test.go index f9ad670e..05170207 100644 --- a/validate_test.go +++ b/validate_test.go @@ -35,10 +35,65 @@ var validateTests = []struct { input: 0, }, { - name: "float64 success", + name: "int from float64 success", typ: reflect.TypeOf(0), input: float64(0), }, + { + name: "int from int8 success", + typ: reflect.TypeOf(0), + input: int8(0), + }, + { + name: "int from int16 success", + typ: reflect.TypeOf(0), + input: int16(0), + }, + { + name: "int from int32 success", + typ: reflect.TypeOf(0), + input: int32(0), + }, + { + name: "int from int64 success", + typ: reflect.TypeOf(0), + input: int64(0), + }, + { + name: "int from uint success", + typ: reflect.TypeOf(0), + input: uint(0), + }, + { + name: "int from uint8 success", + typ: reflect.TypeOf(0), + input: uint8(0), + }, + { + name: "int from uint16 success", + typ: reflect.TypeOf(0), + input: uint16(0), + }, + { + name: "int from uint32 success", + typ: reflect.TypeOf(0), + input: uint32(0), + }, + { + name: "int from uint64 success", + typ: reflect.TypeOf(0), + input: uint64(0), + }, + { + name: "float64 from int success", + typ: reflect.TypeOf(0.0), + input: 0, + }, + { + name: "float64 from float32 success", + typ: reflect.TypeOf(0.0), + input: float32(0), + }, { name: "int64 success", typ: reflect.TypeOf(0), @@ -217,6 +272,21 @@ var validateTests = []struct { input: map[string]any{"value": "bad"}, errs: []string{"expected string to be RFC 3339 date-time"}, }, + { + name: "date-time-http success", + typ: reflect.TypeOf(struct { + Value string `json:"value" format:"date-time-http"` + }{}), + input: map[string]any{"value": []byte("Mon, 01 Jan 2023 12:00:00 GMT")}, + }, + { + name: "expected date-time-http", + typ: reflect.TypeOf(struct { + Value time.Time `json:"value" format:"date-time-http"` + }{}), + input: map[string]any{"value": "bad"}, + errs: []string{"expected string to be RFC 1123 date-time"}, + }, { name: "date success", typ: reflect.TypeOf(struct {