200 lines
5.6 KiB
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
|
|
}
|