package contract import ( "encoding" "encoding/json" "errors" "fmt" "reflect" "strconv" "strings" "git.yuntuo.hk/k2safe/go-omni/openapi" ) // Scalar explicitly opts one application Go type into custom wire conversion. // Input is a | separated union of scalar wire types; Output is one wire type. // The value must implement json.Marshaler, its pointer json.Unmarshaler, and // Text=true additionally requires encoding.TextUnmarshaler on the pointer. // No process-global registry or mutation of other binders is involved. type Scalar struct { Type reflect.Type JSONInput, JSONOutput string Text bool } func ScalarOf[T any](input, output string, text bool) Scalar { return Scalar{Type: reflect.TypeOf((*T)(nil)).Elem(), JSONInput: input, JSONOutput: output, Text: text} } type compiler struct { scalars map[reflect.Type]Scalar } type scalarText string func (s Scalar) validate(schemaOnly bool) error { if s.Type == nil || s.Type.Kind() == reflect.Pointer || s.Type.Kind() == reflect.Interface { return errors.New("contract: scalar requires a concrete non-pointer type") } seen := map[string]bool{} for _, kind := range strings.Split(s.JSONInput, "|") { kind = strings.TrimSpace(kind) if wireSchema(kind) == nil || seen[kind] { return fmt.Errorf("contract: invalid/duplicate scalar input %q", kind) } seen[kind] = true } if wireSchema(s.JSONOutput) == nil { return fmt.Errorf("contract: invalid scalar output %q", s.JSONOutput) } if !schemaOnly { if !s.Type.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) || !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) { return fmt.Errorf("contract: %s needs value MarshalJSON and pointer UnmarshalJSON", s.Type) } if s.Text && !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()) { return fmt.Errorf("contract: %s needs UnmarshalText", s.Type) } } return nil } func wireSchema(kind string) *openapi.Schema { switch kind { case "string": return &openapi.Schema{Type: "string"} case "bool": return &openapi.Schema{Type: "boolean"} case "int32", "int64": return &openapi.Schema{Type: "integer", Format: kind} case "float64": return &openapi.Schema{Type: "number", Format: "double"} } return nil } func (s Scalar) inputSchema() *openapi.Schema { var alternatives []*openapi.Schema for _, kind := range strings.Split(s.JSONInput, "|") { alternatives = append(alternatives, wireSchema(strings.TrimSpace(kind))) } if len(alternatives) == 1 { return alternatives[0] } return &openapi.Schema{AnyOf: alternatives} } func (s Scalar) decode(dst reflect.Value, value any) (err error) { // User decoders may fail; do not allow their panic to escape a binder. defer func() { if r := recover(); r != nil { err = errors.New("custom scalar decoder panicked") } }() if text, ok := value.(scalarText); ok { decoder, ok := dst.Addr().Interface().(encoding.TextUnmarshaler) if !s.Text || !ok { return errors.New("text decoder not available") } return decoder.UnmarshalText([]byte(text)) } allowed := false for _, kind := range strings.Split(s.JSONInput, "|") { if scalarAccepts(strings.TrimSpace(kind), value) { allowed = true break } } if !allowed { return errors.New("value does not match declared scalar input") } decoder, ok := dst.Addr().Interface().(json.Unmarshaler) if !ok { return errors.New("JSON decoder not available") } raw, err := json.Marshal(value) // json.Number preserves exact integer tokens. if err != nil { return err } return decoder.UnmarshalJSON(raw) } func scalarAccepts(kind string, value any) bool { switch kind { case "string": _, ok := value.(string) return ok case "bool": _, ok := value.(bool) return ok case "int32", "int64": n, ok := value.(json.Number) if !ok { return false } bits := 64 if kind == "int32" { bits = 32 } _, err := strconv.ParseInt(string(n), 10, bits) return err == nil case "float64": n, ok := value.(json.Number) if !ok { return false } _, err := strconv.ParseFloat(string(n), 64) return err == nil } return false } func responseSchema(n *node) *openapi.Schema { b := base(n) if b.scalar != nil { s := wireSchema(b.scalar.JSONOutput) if n.typ.Kind() == reflect.Pointer { s = nullableSchema(s) } return s } // Preserve the field's own constraints (including pointer range/enum and // collection length) while changing only nested custom scalar wire types. s := copySchema(n.schema) if b.typ.Kind() == reflect.Slice { s.Items = responseSchema(b.elem) } if b.typ.Kind() == reflect.Map { s.AdditionalProperties = responseSchema(b.elem) } for _, f := range b.fields { if f.source == "json" { s.Properties[f.name] = responseSchema(f.value) } } return s } // ValidateScalars checks the application's explicitly registered method contracts. func ValidateScalars(scalars ...Scalar) error { seen := map[reflect.Type]bool{} for _, s := range scalars { if err := s.validate(false); err != nil { return err } if seen[s.Type] { return fmt.Errorf("contract: duplicate scalar type %s", s.Type) } seen[s.Type] = true } return nil } // OpenAPI 3.0 nullable only applies alongside an explicit type. A union needs // an explicit null alternative; an unconstrained dynamic schema already accepts null. func nullableSchema(schema *openapi.Schema) *openapi.Schema { s := copySchema(schema) if s.Type != "" { s.Nullable = true return s } if len(s.AnyOf) > 0 { s.AnyOf = append(s.AnyOf, &openapi.Schema{Type: "string", Nullable: true, Enum: []any{nil}}) } return s }