Files
go-omni/contract/scalar_test.go

223 lines
7.1 KiB
Go

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