cmdb/internal/schema/reflect.go
2023-01-25 13:40:44 +03:00

514 lines
15 KiB
Go

package schema
import (
"encoding/json"
"net"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"time"
)
// Version is the JSON Schema version.
// If extending JSON Schema with custom values use a custom URI.
// RFC draft-wright-json-schema-00, section 6
var Version = "http://json-schema.org/draft-04/schema#"
// Schema is the root schema.
// RFC draft-wright-json-schema-00, section 4.5
type Schema struct {
*Type
Definitions Definitions `json:"definitions,omitempty"`
}
// Type represents a JSON Schema object type.
type Type struct {
// RFC draft-wright-json-schema-00
// Version string `json:"$schema,omitempty"` // section 6.1
Version string `json:"-"` // section 6.1
Ref string `json:"$ref,omitempty"` // section 7
// RFC draft-wright-json-schema-validation-00, section 5
MultipleOf int `json:"multipleOf,omitempty"` // section 5.1
Maximum int `json:"maximum,omitempty"` // section 5.2
ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty"` // section 5.3
Minimum int `json:"minimum,omitempty"` // section 5.4
ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty"` // section 5.5
MaxLength int `json:"maxLength,omitempty"` // section 5.6
MinLength int `json:"minLength,omitempty"` // section 5.7
Pattern string `json:"pattern,omitempty"` // section 5.8
AdditionalItems *Type `json:"additionalItems,omitempty"` // section 5.9
Items *Type `json:"items,omitempty"` // section 5.9
MaxItems int `json:"maxItems,omitempty"` // section 5.10
MinItems int `json:"minItems,omitempty"` // section 5.11
UniqueItems bool `json:"uniqueItems,omitempty"` // section 5.12
MaxProperties int `json:"maxProperties,omitempty"` // section 5.13
MinProperties int `json:"minProperties,omitempty"` // section 5.14
Required []string `json:"required,omitempty"` // section 5.15
Properties map[string]*Type `json:"properties,omitempty"` // section 5.16
PatternProperties map[string]*Type `json:"patternProperties,omitempty"` // section 5.17
AdditionalProperties json.RawMessage `json:"additionalProperties,omitempty"` // section 5.18
Dependencies map[string]*Type `json:"dependencies,omitempty"` // section 5.19
Enum []interface{} `json:"enum,omitempty"` // section 5.20
Type string `json:"type,omitempty"` // section 5.21
AllOf []*Type `json:"allOf,omitempty"` // section 5.22
AnyOf []*Type `json:"anyOf,omitempty"` // section 5.23
OneOf []*Type `json:"oneOf,omitempty"` // section 5.24
Not *Type `json:"not,omitempty"` // section 5.25
Definitions Definitions `json:"definitions,omitempty"` // section 5.26
// RFC draft-wright-json-schema-validation-00, section 6, 7
Title string `json:"title,omitempty"` // section 6.1
Description string `json:"description,omitempty"` // section 6.1
Default interface{} `json:"default,omitempty"` // section 6.2
Format string `json:"format,omitempty"` // section 7
// RFC draft-wright-json-schema-hyperschema-00, section 4
Media *Type `json:"media,omitempty"` // section 4.3
BinaryEncoding string `json:"binaryEncoding,omitempty"` // section 4.3
}
// Reflect reflects to Schema from a value using the default Reflector
func Reflect(v interface{}) *Schema {
return ReflectFromType(reflect.TypeOf(v))
}
// ReflectFromType generates root schema using the default Reflector
func ReflectFromType(t reflect.Type) *Schema {
r := &Reflector{}
return r.ReflectFromType(t)
}
// A Reflector reflects values into a Schema.
type Reflector struct {
// AllowAdditionalProperties will cause the Reflector to generate a schema
// with additionalProperties to 'true' for all struct types. This means
// the presence of additional keys in JSON objects will not cause validation
// to fail. Note said additional keys will simply be dropped when the
// validated JSON is unmarshaled.
AllowAdditionalProperties bool
// RequiredFromJSONSchemaTags will cause the Reflector to generate a schema
// that requires any key tagged with `jsonschema:required`, overriding the
// default of requiring any key *not* tagged with `json:,omitempty`.
RequiredFromJSONSchemaTags bool
// ExpandedStruct will cause the toplevel definitions of the schema not
// be referenced itself to a definition.
ExpandedStruct bool
// TitleNotation will cause the Reflector to generate a schema
// with different title notations. This means
// the title will be transform your structure name from
// camelcase(go-case) notation to the declared. There are
// two supported notations: snake and dash.
// P.S. first letter capitalisation will be ignored (converted to lower-case)
TitleNotation string
}
// Reflect reflects to Schema from a value.
func (r *Reflector) Reflect(v interface{}) *Schema {
return r.ReflectFromType(reflect.TypeOf(v))
}
// ReflectTitle return only structure name as profile type title
func (r *Reflector) ReflectTitle(v interface{}) string {
return r.getTitle(reflect.TypeOf(v))
}
func (r *Reflector) getTitle(t reflect.Type) string {
name := t.Name()
if t.Kind() == reflect.Ptr {
name = t.Elem().Name()
}
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
switch r.TitleNotation {
case "dash":
return strings.ToLower(matchAllCap.ReplaceAllString(name, "${1}-${2}"))
case "snake":
return strings.ToLower(matchAllCap.ReplaceAllString(name, "${1}_${2}"))
default:
return name
}
}
// ReflectFromType generates root schema
func (r *Reflector) ReflectFromType(t reflect.Type) *Schema {
definitions := Definitions{}
if r.ExpandedStruct {
st := &Type{
Version: Version,
Title: r.getTitle(t),
Type: "object",
Properties: map[string]*Type{},
AdditionalProperties: []byte("false"),
}
if r.AllowAdditionalProperties {
st.AdditionalProperties = []byte("true")
}
r.reflectStructFields(st, definitions, t)
r.reflectStruct(definitions, t)
delete(definitions, t.Name())
return &Schema{Type: st, Definitions: definitions}
}
s := &Schema{
Type: r.reflectTypeToSchema(definitions, t),
Definitions: definitions,
}
return s
}
// Definitions hold schema definitions.
// http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.26
// RFC draft-wright-json-schema-validation-00, section 5.26
type Definitions map[string]*Type
// Available Go defined types for JSON Schema Validation.
// RFC draft-wright-json-schema-validation-00, section 7.3
var (
timeType = reflect.TypeOf(time.Time{}) // date-time RFC section 7.3.1
ipType = reflect.TypeOf(net.IP{}) // ipv4 and ipv6 RFC section 7.3.4, 7.3.5
uriType = reflect.TypeOf(url.URL{}) // uri RFC section 7.3.6
)
// Byte slices will be encoded as base64
var byteSliceType = reflect.TypeOf([]byte(nil))
// Go code generated from protobuf enum types should fulfil this interface.
type protoEnum interface {
EnumDescriptor() ([]byte, []int)
}
var protoEnumType = reflect.TypeOf((*protoEnum)(nil)).Elem()
func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) *Type {
// Already added to definitions?
if _, ok := definitions[t.Name()]; ok {
return &Type{Ref: "#/definitions/" + t.Name()}
}
// jsonpb will marshal protobuf enum options as either strings or integers.
// It will unmarshal either.
if t.Implements(protoEnumType) {
return &Type{OneOf: []*Type{
{Type: "string"},
{Type: "integer"},
}}
}
// Defined format types for JSON Schema Validation
// RFC draft-wright-json-schema-validation-00, section 7.3
// TODO email RFC section 7.3.2, hostname RFC section 7.3.3, uriref RFC section 7.3.7
switch t {
case ipType:
// TODO differentiate ipv4 and ipv6 RFC section 7.3.4, 7.3.5
return &Type{Type: "string", Format: "ipv4"} // ipv4 RFC section 7.3.4
}
switch t.Kind() {
case reflect.Struct:
switch t {
case timeType: // date-time RFC section 7.3.1
return &Type{Type: "string", Format: "date-time"}
case uriType: // uri RFC section 7.3.6
return &Type{Type: "string", Format: "uri"}
default:
return r.reflectStruct(definitions, t)
}
case reflect.Map:
rt := &Type{
Type: "object",
PatternProperties: map[string]*Type{
".*": r.reflectTypeToSchema(definitions, t.Elem()),
},
}
delete(rt.PatternProperties, "additionalProperties")
return rt
case reflect.Slice, reflect.Array:
returnType := &Type{}
if t.Kind() == reflect.Array {
returnType.MinItems = t.Len()
returnType.MaxItems = returnType.MinItems
}
switch t {
case byteSliceType:
returnType.Type = "string"
returnType.Media = &Type{BinaryEncoding: "base64"}
return returnType
default:
returnType.Type = "array"
returnType.Items = r.reflectTypeToSchema(definitions, t.Elem())
return returnType
}
case reflect.Interface:
return &Type{
Type: "object",
AdditionalProperties: []byte("true"),
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return &Type{Type: "integer"}
case reflect.Float32, reflect.Float64:
return &Type{Type: "number"}
case reflect.Bool:
return &Type{Type: "boolean"}
case reflect.String:
return &Type{Type: "string"}
case reflect.Ptr:
return r.reflectTypeToSchema(definitions, t.Elem())
}
panic("unsupported type " + t.String())
}
// Refects a struct to a JSON Schema type.
func (r *Reflector) reflectStruct(definitions Definitions, t reflect.Type) *Type {
st := &Type{
Type: "object",
Properties: map[string]*Type{},
AdditionalProperties: []byte("false"),
}
if r.AllowAdditionalProperties {
st.AdditionalProperties = []byte("true")
}
definitions[t.Name()] = st
r.reflectStructFields(st, definitions, t)
return &Type{
Version: Version,
Ref: "#/definitions/" + t.Name(),
}
}
func (r *Reflector) reflectStructFields(st *Type, definitions Definitions, t reflect.Type) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
// Skip non Struct types
// https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/reflect/value.go;l=1219
if t.Kind() != reflect.Struct {
return
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
// anonymous and exported type should be processed recursively
// current type should inherit properties of anonymous one
if f.Anonymous && (f.Type.Kind() == reflect.Struct || f.Type.Kind() == reflect.Ptr) {
r.reflectStructFields(st, definitions, f.Type)
continue
}
name, required := r.reflectFieldName(f)
if name == "" {
continue
}
property := r.reflectTypeToSchema(definitions, f.Type)
property.structKeywordsFromTags(f)
property.defaultValueFromTags(f)
st.Properties[name] = property
if required {
st.Required = append(st.Required, name)
}
}
}
func (t *Type) defaultValueFromTags(f reflect.StructField) {
def := f.Tag.Get("default") // Get default value
switch t.Type {
case "bool", "boolean":
if def == "true" {
t.Default = true
} else if def == "false" {
t.Default = false
}
case "string":
if def != "" {
t.Default = def
}
case "number", "integer":
if num, err := strconv.Atoi(def); err == nil {
t.Default = num
}
case "array":
// TODO: implement default values for arrays
}
}
func (t *Type) structKeywordsFromTags(f reflect.StructField) {
tags := strings.Split(f.Tag.Get("jsonschema"), ",")
switch t.Type {
case "string":
t.stringKeywords(tags)
case "number", "integer":
t.numbericKeywords(tags)
case "array":
t.arrayKeywords(tags)
}
}
// read struct tags for string type keyworks
func (t *Type) stringKeywords(tags []string) {
for _, tag := range tags {
nameValue := strings.Split(tag, "=")
if len(nameValue) == 2 {
name, val := nameValue[0], nameValue[1]
switch name {
case "minLength":
i, _ := strconv.Atoi(val)
t.MinLength = i
case "maxLength":
i, _ := strconv.Atoi(val)
t.MaxLength = i
case "format":
switch val {
case "date-time", "email", "hostname", "ipv4", "ipv6", "uri":
t.Format = val
break
}
}
}
}
}
// read struct tags for numberic type keyworks
func (t *Type) numbericKeywords(tags []string) {
for _, tag := range tags {
nameValue := strings.Split(tag, "=")
if len(nameValue) == 2 {
name, val := nameValue[0], nameValue[1]
switch name {
case "multipleOf":
i, _ := strconv.Atoi(val)
t.MultipleOf = i
case "minimum":
i, _ := strconv.Atoi(val)
t.Minimum = i
case "maximum":
i, _ := strconv.Atoi(val)
t.Maximum = i
case "exclusiveMaximum":
b, _ := strconv.ParseBool(val)
t.ExclusiveMaximum = b
case "exclusiveMinimum":
b, _ := strconv.ParseBool(val)
t.ExclusiveMinimum = b
}
}
}
}
// read struct tags for object type keyworks
// func (t *Type) objectKeywords(tags []string) {
// for _, tag := range tags{
// nameValue := strings.Split(tag, "=")
// name, val := nameValue[0], nameValue[1]
// switch name{
// case "dependencies":
// t.Dependencies = val
// break;
// case "patternProperties":
// t.PatternProperties = val
// break;
// }
// }
// }
// read struct tags for array type keyworks
func (t *Type) arrayKeywords(tags []string) {
for _, tag := range tags {
nameValue := strings.Split(tag, "=")
if len(nameValue) == 2 {
name, val := nameValue[0], nameValue[1]
switch name {
case "minItems":
i, _ := strconv.Atoi(val)
t.MinItems = i
case "maxItems":
i, _ := strconv.Atoi(val)
t.MaxItems = i
case "uniqueItems":
t.UniqueItems = true
}
}
}
}
func requiredFromJSONTags(tags []string) bool {
if ignoredByJSONTags(tags) {
return false
}
for _, tag := range tags[1:] {
if tag == "omitempty" {
return false
}
}
return true
}
func requiredFromJSONSchemaTags(tags []string) bool {
if ignoredByJSONSchemaTags(tags) {
return false
}
for _, tag := range tags {
if tag == "required" {
return true
}
}
return false
}
func ignoredByJSONTags(tags []string) bool {
return tags[0] == "-"
}
func ignoredByJSONSchemaTags(tags []string) bool {
return tags[0] == "-"
}
func (r *Reflector) reflectFieldName(f reflect.StructField) (string, bool) {
if !f.IsExported() { // unexported field, ignore it
return "", false
}
jsonTags := strings.Split(f.Tag.Get("json"), ",")
if ignoredByJSONTags(jsonTags) {
return "", false
}
jsonSchemaTags := strings.Split(f.Tag.Get("jsonschema"), ",")
if ignoredByJSONSchemaTags(jsonSchemaTags) {
return "", false
}
name := f.Name
required := requiredFromJSONTags(jsonTags)
if r.RequiredFromJSONSchemaTags {
required = requiredFromJSONSchemaTags(jsonSchemaTags)
}
if jsonTags[0] != "" {
name = jsonTags[0]
}
return name, required
}