package contract import ( "encoding/json" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "net/http" "net/http/httptest" "reflect" "strconv" "strings" "testing" ) type flexibleID int64 func (v flexibleID) MarshalJSON() ([]byte, error) { return json.Marshal(strconv.FormatInt(int64(v), 10)) } func (v *flexibleID) UnmarshalJSON(b []byte) error { var s string if len(b) > 0 && b[0] == '"' { if err := json.Unmarshal(b, &s); err != nil { return err } } else { s = string(b) } return v.UnmarshalText([]byte(s)) } func (v *flexibleID) UnmarshalText(b []byte) error { n, err := strconv.ParseInt(string(b), 10, 64) if err == nil { *v = flexibleID(n) } return err } func TestDynamicJSONAndCustomScalars(t *testing.T) { type request struct { ID flexibleID `json:"id"` Dynamic any `json:"dynamic"` IDs []flexibleID `json:"ids,optional"` } scalar := ScalarOf[flexibleID]("string|int64", "string", true) b, err := New[request](scalar) if err != nil { t.Fatal(err) } for _, id := range []string{`"9223372036854775807"`, `9223372036854775807`} { r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":`+id+`,"dynamic":{"n":9223372036854775807},"ids":["1",2]}`)) r.Header.Set("Content-Type", "application/json") var req request if err = b.Bind(r, nil, &req); err != nil { t.Fatal(err) } if req.ID != flexibleID(9223372036854775807) || req.Dynamic.(map[string]any)["n"] != json.Number("9223372036854775807") { t.Fatalf("precision: %+v", req) } encoded, _ := json.Marshal(req) if !strings.Contains(string(encoded), `"id":"9223372036854775807"`) { t.Fatal(string(encoded)) } } for _, body := range []string{`{"id":true,"dynamic":null}`, `{"id":1.1,"dynamic":null}`, `{"id":9223372036854775808,"dynamic":null}`, `{"id":1,"dynamic":{"x":1,"x":2}}`, `{"id":1,"dynamic":null,"unexpected":1}`} { r := httptest.NewRequest("POST", "/", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") req := request{ID: 7} if b.Bind(r, nil, &req) == nil || req.ID != 7 { t.Fatalf("accepted or mutated %s", body) } } for _, value := range []string{`null`, `[1,"x"]`, `false`, `"text"`} { r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":1,"dynamic":`+value+`}`)) r.Header.Set("Content-Type", "application/json") if err := b.Bind(r, nil, &request{}); err != nil { t.Fatal(err) } } if _, err := New[request](); err == nil { t.Fatal("implicit custom type registration") } response, err := b.ResponseSchema() if err != nil { t.Fatal(err) } if response.Properties["id"].Type != "string" || response.Properties["ids"].Items.Type != "string" || len(b.BodySchema().Properties["id"].AnyOf) != 2 { t.Fatal("input/output schemas conflated") } type path struct { ID flexibleID `path:"id"` } p, err := New[path](scalar) if err != nil { t.Fatal(err) } var req path if err := p.Bind(httptest.NewRequest("GET", "/", nil), map[string]string{"id": "9223372036854775807"}, &req); err != nil { t.Fatal(err) } if p.Parameters()[0].Schema.Type != "string" { t.Fatal("text parameters need text schema") } if _, err := New[path](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil { t.Fatal("text support not explicit") } schemaOnly, err := Describe(reflect.TypeOf(request{}), scalar) if err != nil { t.Fatal(err) } if schemaOnly.Bind(httptest.NewRequest("POST", "/", nil), nil, &request{}) == nil { t.Fatal("schema-only binder executed") } } func TestScalarPointersCollectionsAndRegistration(t *testing.T) { scalar := ScalarOf[flexibleID]("string|int64", "string", true) type nullable struct { ID *flexibleID `json:"id,optional"` Values map[string]*flexibleID `json:"values,optional"` } b, err := New[nullable](scalar) if err != nil { t.Fatal(err) } for _, body := range []string{`{}`, `{"id":null}`, `{"id":1,"values":{"a":"2","b":null}}`} { r := httptest.NewRequest("POST", "/", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") if err := b.Bind(r, nil, &nullable{}); err != nil { t.Fatal(err) } } input := b.BodySchema().Properties["id"] if len(input.AnyOf) != 3 || !input.AnyOf[2].Nullable || len(input.AnyOf[2].Enum) != 1 || input.AnyOf[2].Enum[0] != nil { t.Fatal("nullable input union does not describe null") } out, err := b.ResponseSchema() if err != nil { t.Fatal(err) } if out.Properties["id"].Type != "string" || !out.Properties["id"].Nullable { t.Fatal("nullable output type") } type query struct { IDs []flexibleID `form:"ids"` } q, err := New[query](scalar) if err != nil { t.Fatal(err) } var req query if err := q.Bind(httptest.NewRequest("GET", "/?ids=1&ids=2", nil), nil, &req); err != nil || !reflect.DeepEqual(req.IDs, []flexibleID{1, 2}) { t.Fatalf("query scalar collection: %+v %v", req, err) } if q.Parameters()[0].Schema.Items.Type != "string" { t.Fatal("text array schema") } if _, err := New[query](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil { t.Fatal("array bypassed text declaration") } if err := ValidateScalars(ScalarOf[int64]("int64", "string", true)); err == nil { t.Fatal("missing methods accepted") } if err := ValidateScalars(scalar, scalar); err == nil { t.Fatal("duplicate registration") } type strict struct { ID int64 `json:"id"` } strictBinder := MustNew[strict]() r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":"1"}`)) r.Header.Set("Content-Type", "application/json") if strictBinder.Bind(r, nil, &strict{}) == nil { t.Fatal("other binder globally loosened") } } type panicScalar string func (v panicScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) } func (v *panicScalar) UnmarshalJSON([]byte) error { panic("decoder failure") } func TestScalarPanicIsBindingError(t *testing.T) { type req struct { Value panicScalar `json:"value"` } b := MustNew[req](ScalarOf[panicScalar]("string", "string", false)) r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"bad"}`)) r.Header.Set("Content-Type", "application/json") dst := req{Value: "original"} if err := b.Bind(r, nil, &dst); err == nil || dst.Value != "original" { t.Fatalf("decoder panic: %+v %v", dst, err) } } func TestLimitedJSONTrailingWhitespace(t *testing.T) { type req struct { Value any `json:"value"` } b := MustNew[req]() r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":null}`+strings.Repeat(" ", 65))) r.Header.Set("Content-Type", "application/json") r.ContentLength = -1 r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, 64) err := b.Bind(r, nil, &req{}, 64) if apierrors.From(err).Status != 413 { t.Fatalf("tail limit error: %v", err) } } func TestResponseSchemaPreservesOrdinaryPointerConstraints(t *testing.T) { type dto struct { Count *int `json:"count,optional,range=[1:10]"` Choice *string `json:"choice,options=a|b"` Values *[]int `json:"values,optional" binding:"max=3"` } b := MustNew[dto]() response, err := b.ResponseSchema() if err != nil { t.Fatal(err) } if !reflect.DeepEqual(response, b.BodySchema()) { t.Fatalf("changed ordinary response constraints: %#v", response) } }