init
This commit is contained in:
278
pkg/util/json_schema/json_schema.go
Normal file
278
pkg/util/json_schema/json_schema.go
Normal file
@@ -0,0 +1,278 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ForType generates a JSON Schema for the given reflect.Type.
|
||||
// It supports struct fields with json tags and desc tags for metadata.
|
||||
func ForType(t reflect.Type) (map[string]any, error) {
|
||||
definitions := make(map[string]any)
|
||||
schema, err := forTypeInternal(t, "", make(map[reflect.Type]string), definitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(definitions) == 0 {
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": definitions,
|
||||
}
|
||||
maps.Copy(result, schema)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func forTypeInternal(
|
||||
t reflect.Type,
|
||||
fieldName string,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
) (map[string]any, error) {
|
||||
if t == nil {
|
||||
return nil, errors.New("type cannot be nil")
|
||||
}
|
||||
|
||||
// Dereference pointer types
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
// Handle previously visited types
|
||||
if refName, ok := visited[t]; ok {
|
||||
return map[string]any{"$ref": "#/definitions/" + refName}, nil
|
||||
}
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Struct:
|
||||
return handleStructType(t, fieldName, visited, definitions)
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
return handleArrayType(t, visited, definitions)
|
||||
|
||||
case reflect.Map:
|
||||
return handleMapType(t, visited, definitions)
|
||||
|
||||
default:
|
||||
return handlePrimitiveType(t)
|
||||
}
|
||||
}
|
||||
|
||||
func handleStructType(
|
||||
t reflect.Type,
|
||||
fieldName string,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
) (map[string]any, error) {
|
||||
// Handle special types.
|
||||
if t == reflect.TypeOf(time.Time{}) {
|
||||
return map[string]any{
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if t == reflect.TypeOf(time.Duration(0)) {
|
||||
return map[string]any{
|
||||
"type": "string",
|
||||
"format": "duration",
|
||||
"pattern": "^([0-9]+(s|m|h))+$",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate type name.
|
||||
typeName := t.Name()
|
||||
if typeName == "" {
|
||||
typeName = "Anonymous" + fieldName
|
||||
}
|
||||
visited[t] = typeName
|
||||
|
||||
// Process schema.
|
||||
schema := map[string]any{"type": "object"}
|
||||
|
||||
properties, err := handleStructFields(t, visited, definitions)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "handle struct fields")
|
||||
}
|
||||
if len(properties) > 0 {
|
||||
schema["properties"] = properties
|
||||
}
|
||||
|
||||
definitions[typeName] = schema
|
||||
|
||||
return map[string]any{"$ref": "#/definitions/" + typeName}, nil
|
||||
}
|
||||
|
||||
func handleStructFields(
|
||||
t reflect.Type,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
) (properties map[string]any, err error) {
|
||||
properties = make(map[string]any, t.NumField())
|
||||
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
propName := getPropertyName(field)
|
||||
if propName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Anonymous {
|
||||
if err := handleEmbeddedStruct(field, visited, definitions, properties); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fieldSchema, err := forTypeInternal(field.Type, field.Name, visited, definitions)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "generating schema for field %s", field.Name)
|
||||
}
|
||||
|
||||
if desc := field.Tag.Get("desc"); desc != "" {
|
||||
fieldSchema["description"] = desc
|
||||
}
|
||||
|
||||
properties[propName] = fieldSchema
|
||||
}
|
||||
|
||||
return properties, nil
|
||||
}
|
||||
|
||||
func handleArrayType(
|
||||
t reflect.Type,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
) (map[string]any, error) {
|
||||
itemSchema, err := forTypeInternal(t.Elem(), "", visited, definitions)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generating array item schema")
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"items": itemSchema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handleMapType(
|
||||
t reflect.Type,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
) (map[string]any, error) {
|
||||
if t.Key().Kind() != reflect.String {
|
||||
return nil, errors.Errorf("unsupported map key type: %s (must be string)", t.Key().Kind())
|
||||
}
|
||||
|
||||
valueSchema, err := forTypeInternal(t.Elem(), "", visited, definitions)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generating map value schema")
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": valueSchema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handlePrimitiveType(t reflect.Type) (map[string]any, error) {
|
||||
schema := make(map[string]any)
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
schema["type"] = "string"
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if t == reflect.TypeOf(time.Duration(0)) {
|
||||
schema["type"] = "string"
|
||||
schema["format"] = "duration"
|
||||
schema["pattern"] = "^([0-9]+(s|m|h))+$"
|
||||
} else {
|
||||
schema["type"] = "integer"
|
||||
}
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
schema["type"] = "integer"
|
||||
schema["minimum"] = 0
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
schema["type"] = "number"
|
||||
|
||||
case reflect.Bool:
|
||||
schema["type"] = "boolean"
|
||||
|
||||
default:
|
||||
return nil, errors.Errorf("unsupported type: %s", t.Kind())
|
||||
}
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func getPropertyName(field reflect.StructField) string {
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "-" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if jsonTag != "" {
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return field.Name
|
||||
}
|
||||
|
||||
func handleEmbeddedStruct(
|
||||
field reflect.StructField,
|
||||
visited map[reflect.Type]string,
|
||||
definitions map[string]any,
|
||||
properties map[string]any,
|
||||
) error {
|
||||
embeddedSchema, err := forTypeInternal(field.Type, "", visited, definitions)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "generating schema for embedded field %s", field.Name)
|
||||
}
|
||||
|
||||
if embeddedType, ok := embeddedSchema["$ref"]; ok {
|
||||
refType := embeddedType.(string)
|
||||
key := strings.TrimPrefix(refType, "#/definitions/")
|
||||
if def, ok := definitions[key]; ok {
|
||||
if embeddedProps, ok := def.(map[string]any)["properties"].(map[string]any); ok {
|
||||
maps.Copy(properties, embeddedProps)
|
||||
}
|
||||
|
||||
delete(definitions, key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
262
pkg/util/json_schema/json_schema_test.go
Normal file
262
pkg/util/json_schema/json_schema_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/test"
|
||||
)
|
||||
|
||||
func TestForType(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
type givenDetail struct{}
|
||||
type whenDetail struct {
|
||||
inputType reflect.Type
|
||||
}
|
||||
type thenExpected struct {
|
||||
schema map[string]any
|
||||
hasError bool
|
||||
errorText string
|
||||
}
|
||||
|
||||
type SimpleStruct struct {
|
||||
Name string `json:"name" desc:"The name field"`
|
||||
Age int `json:"age"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IgnoreField string `json:"-"`
|
||||
}
|
||||
|
||||
type EmbeddedStruct struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type ComplexStruct struct {
|
||||
EmbeddedStruct
|
||||
Time time.Time `json:"time"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Tags []string `json:"tags"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Value string `json:"value"`
|
||||
Next *Node `json:"next"`
|
||||
Children []Node `json:"children"`
|
||||
}
|
||||
|
||||
type LinkedList struct {
|
||||
Head *Node `json:"head"`
|
||||
}
|
||||
|
||||
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
||||
{
|
||||
Scenario: "Generate schema for simple struct",
|
||||
When: "providing a struct with basic types",
|
||||
Then: "should generate correct JSON schema",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: reflect.TypeOf(SimpleStruct{}),
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
schema: map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": map[string]any{
|
||||
"SimpleStruct": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The name field",
|
||||
},
|
||||
"age": map[string]any{
|
||||
"type": "integer",
|
||||
},
|
||||
"is_active": map[string]any{
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"$ref": "#/definitions/SimpleStruct",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Generate schema for complex struct",
|
||||
When: "providing a struct with embedded fields and special types",
|
||||
Then: "should generate correct JSON schema with all fields",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: reflect.TypeOf(ComplexStruct{}),
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
schema: map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": map[string]any{
|
||||
"ComplexStruct": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"time": map[string]any{
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
},
|
||||
"duration": map[string]any{
|
||||
"type": "string",
|
||||
"format": "duration",
|
||||
"pattern": "^([0-9]+(s|m|h))+$",
|
||||
},
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"$ref": "#/definitions/ComplexStruct",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Generate schema for struct with circular reference",
|
||||
When: "providing a struct that references itself",
|
||||
Then: "should generate correct JSON schema using $ref",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: reflect.TypeOf(Node{}),
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
schema: map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": map[string]any{
|
||||
"Node": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"next": map[string]any{
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
"children": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Generate schema for struct with nested circular reference",
|
||||
When: "providing a struct that contains a circular reference",
|
||||
Then: "should generate correct JSON schema using $ref",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: reflect.TypeOf(LinkedList{}),
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
schema: map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": map[string]any{
|
||||
"LinkedList": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"head": map[string]any{
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Node": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"next": map[string]any{
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
"children": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"$ref": "#/definitions/Node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"$ref": "#/definitions/LinkedList",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Generate schema for nil type",
|
||||
When: "providing a nil type",
|
||||
Then: "should return error",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: nil,
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
hasError: true,
|
||||
errorText: "type cannot be nil",
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Generate schema for unsupported map key type",
|
||||
When: "providing a map with non-string key type",
|
||||
Then: "should return error",
|
||||
WhenDetail: whenDetail{
|
||||
inputType: reflect.TypeOf(map[int]string{}),
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
hasError: true,
|
||||
errorText: "unsupported map key type: int (must be string)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Scenario, func(t *testing.T) {
|
||||
// When.
|
||||
schema, err := ForType(tt.WhenDetail.inputType)
|
||||
|
||||
// Then.
|
||||
if tt.ThenExpected.hasError {
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.errorText))
|
||||
} else {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(schema).To(Equal(tt.ThenExpected.schema))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user