Files
go-omni/contract/scalar.go

200 lines
5.6 KiB
Go

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
}