commit dde5b75d7aa6a8fdfd91cc25d7c3c9051a8cf17e Author: Simon Ser Date: Thu Oct 6 09:23:55 2022 +0200 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bfbc57b --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Simon Ser + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33f84db --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# go-jsonschema + +A JSON schema code generator for Go. + +## Usage + + jsonschemagen + +## License + +MIT diff --git a/cmd/jsonschemagen/main.go b/cmd/jsonschemagen/main.go new file mode 100644 index 0000000..a68f5fa --- /dev/null +++ b/cmd/jsonschemagen/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "strings" + + "github.com/dave/jennifer/jen" + + "git.sr.ht/~emersion/go-jsonschema" +) + +func formatId(s string) string { + s = strings.Title(s) + // TODO: improve robustness + s = strings.ReplaceAll(s, "-", "") + s = strings.ReplaceAll(s, "_", "") + return s +} + +func resolveRef(def *jsonschema.Schema, root *jsonschema.Schema) *jsonschema.Schema { + if def.Ref == "" { + return def + } + + prefix := "#/$defs/" + if !strings.HasPrefix(def.Ref, prefix) { + log.Fatalf("unsupported $ref %q", def.Ref) + } + name := strings.TrimPrefix(def.Ref, prefix) + + result, ok := root.Defs[name] + if !ok { + log.Fatalf("invalid $ref %q", def.Ref) + } + return &result +} + +func schemaType(schema *jsonschema.Schema) jsonschema.Type { + if schema.Type != "" { + return schema.Type + } + + var v interface{} + if schema.Const != nil { + v = schema.Const + } else if len(schema.Enum) > 0 { + v = schema.Enum[0] + } + + switch v.(type) { + case bool: + return jsonschema.TypeBoolean + case map[string]interface{}: + return jsonschema.TypeObject + case []interface{}: + return jsonschema.TypeArray + case float64: + return jsonschema.TypeNumber + case string: + return jsonschema.TypeString + default: + return "" + } +} + +func generateSchemaType(schema *jsonschema.Schema, root *jsonschema.Schema) jen.Code { + if schema == nil { + return jen.Interface() + } + + schema = resolveRef(schema, root) + switch schemaType(schema) { + case jsonschema.TypeNull: + return jen.Struct() + case jsonschema.TypeBoolean: + return jen.Bool() + case jsonschema.TypeArray: + return jen.Index().Add(generateSchemaType(schema.Items, root)) + case jsonschema.TypeNumber: + return jen.Float64() + case jsonschema.TypeString: + return jen.String() + case jsonschema.TypeInteger: + return jen.Int64() + case jsonschema.TypeObject: + return jen.Map(jen.String()).Add(generateSchemaType(schema.AdditionalProperties, root)) + default: + return jen.Interface() + } +} + +func generateDef(def *jsonschema.Schema, root *jsonschema.Schema, f *jen.File, name string) { + if schemaType(def) != jsonschema.TypeObject { + return + } + if def.AdditionalProperties == nil || !def.AdditionalProperties.IsFalse() { + return + } + if len(def.PatternProperties) > 0 { + return + } + + var fields []jen.Code + for name, prop := range def.Properties { + id := formatId(name) + t := generateSchemaType(&prop, root) + tags := map[string]string{"json": name} + fields = append(fields, jen.Id(id).Add(t).Tag(tags)) + } + + f.Type().Id(formatId(name)).Struct(fields...).Line() +} + +func loadSchema(filename string) *jsonschema.Schema { + f, err := os.Open(filename) + if err != nil { + log.Fatalf("failed to open schema file: %v", err) + } + defer f.Close() + + var schema jsonschema.Schema + if err := json.NewDecoder(f).Decode(&schema); err != nil { + log.Fatalf("failed to load schema JSON: %v", err) + } + + return &schema +} + +func main() { + if len(os.Args) != 4 { + log.Fatalf("usage: jsonschemagen ") + } + + inputFilename := os.Args[1] + outputFilename := os.Args[2] + pkgName := os.Args[3] + + schema := loadSchema(inputFilename) + f := jen.NewFile(pkgName) + + generateDef(schema, schema, f, "root") + for k, def := range schema.Defs { + generateDef(&def, schema, f, k) + } + + if err := f.Save(outputFilename); err != nil { + log.Fatalf("failed to save output file: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a27059 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.sr.ht/~emersion/go-jsonschema + +go 1.16 + +require github.com/dave/jennifer v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c223082 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms= +github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM= +github.com/dave/courtney v0.3.0/go.mod h1:BAv3hA06AYfNUjfjQr+5gc6vxeBVOupLqrColj+QSD8= +github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e/go.mod h1:i00+b/gKdIDIxuLDFob7ustLAVqhsZRk2qVZrArELGQ= +github.com/dave/jennifer v1.5.1 h1:AI8gaM02nCYRw6/WTH0W+S6UNck9YqPZ05xoIxQtuoE= +github.com/dave/jennifer v1.5.1/go.mod h1:AxTG893FiZKqxy3FP1kL80VMshSMuz2G+EgvszgGRnk= +github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e/go.mod h1:qZqlPyPvfsDJt+3wHJ1EvSXDuVjFTK0j2p/ca+gtsb8= +github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc= +github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWEmXBA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..ece502a --- /dev/null +++ b/schema.go @@ -0,0 +1,122 @@ +package jsonschema + +import ( + "bytes" + "encoding/json" +) + +type Type string + +const ( + TypeNull Type = "null" + TypeBoolean Type = "boolean" + TypeObject Type = "object" + TypeArray Type = "array" + TypeNumber Type = "number" + TypeString Type = "string" + TypeInteger Type = "integer" +) + +type Schema struct { + // Core + Schema string `json:"$schema"` + Vocabulary map[string]bool `json:"$vocabulary"` + ID string `json:"$id"` + Ref string `json:"$ref"` + DynamicRef string `json:"$dynamicRef"` + Defs map[string]Schema `json:"$defs"` + Comment string `json:"$comment"` + + // Applying subschemas with logic + AllOf []Schema `json:"allOf"` + AnyOf []Schema `json:"anyOf"` + OneOf []Schema `json:"oneOf"` + Not []Schema `json:"not"` + + // Applying subschemas conditionally + If *Schema `json:"if"` + Then *Schema `json:"then"` + Else *Schema `json:"else"` + DependentSchemas map[string]Schema `json:"dependentSchemas"` + + // Applying subschemas to arrays + PrefixItems []Schema `json:"prefixItems"` + Items *Schema `json:"items"` + Contains *Schema `json:"contains"` + + // Applying subschemas to objects + Properties map[string]Schema `json:"properties"` + PatternProperties map[string]Schema `json:"patternProperties"` + AdditionalProperties *Schema `json:"additionalProperties"` + PropertyNames *Schema `json:"propertyNames"` + + // Validation + Type Type `json:"type"` + Enum []interface{} `json:"enum"` + Const interface{} `json:"const"` + + // Validation for numbers + MultipleOf json.Number `json:"multipleOf"` + Maximum json.Number `json:"maximum"` + ExclusiveMaximum json.Number `json:"exclusiveMaximum"` + Minimum json.Number `json:"minimum"` + ExclusiveMinimum json.Number `json:"exclusiveMinimum"` + + // Validation for strings + MaxLength int `json:"maxLength"` + MinLength int `json:"minLength"` + Pattern string `json:"pattern"` + + // Validation for arrays + MaxItems int `json:"maxItems"` + MinItems int `json:"minItems"` + UniqueItems bool `json:"uniqueItems"` + MaxContains int `json:"maxContains"` + MinContains int `json:"minContains"` + + // Validation for objects + MaxProperties int `json:"maxProperties"` + MinProperties int `json:"minProperties"` + Required []string `json:"required"` + DependentRequired map[string][]string `json:"dependentRequired"` + + // Basic metadata annotations + Title string `json:"title"` + Description string `json:"description"` + Default interface{} `json:"default"` + Deprecated bool `json:"deprecated"` + ReadOnly bool `json:"readOnly"` + WriteOnly bool `json:"writeOnly"` + Examples []interface{} `json:"examples"` +} + +func (schema *Schema) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("true")) { + // Nothing to do + } else if bytes.Equal(b, []byte("false")) { + *schema = Schema{Not: []Schema{ + Schema{}, + }} + } else { + type rawSchema Schema + var out rawSchema + if err := json.Unmarshal(b, &out); err != nil { + return err + } + *schema = Schema(out) + } + return nil +} + +func (schema *Schema) IsTrue() bool { + return len(schema.AllOf) == 0 && len(schema.AnyOf) == 0 && len(schema.OneOf) == 0 && len(schema.Not) == 0 && schema.If == nil && schema.Then == nil && schema.Else == nil && len(schema.DependentSchemas) == 0 && len(schema.PrefixItems) == 0 && schema.Items == nil && schema.Contains == nil && len(schema.Properties) == 0 && len(schema.PatternProperties) == 0 && schema.AdditionalProperties == nil && schema.PropertyNames == nil +} + +func (schema *Schema) IsFalse() bool { + for _, not := range schema.Not { + if not.IsTrue() { + return true + } + } + return false +}