From cd8720c0295d27dea68000d71ee9f5cbd17635c1 Mon Sep 17 00:00:00 2001 From: Alexander Staubo Date: Mon, 1 Oct 2018 15:34:59 -0400 Subject: [PATCH] Add flag --capitalization, for registering custom capitalizations. --- cmd/gojsonschema/main.go | 20 +++- pkg/codegen/utils.go | 36 ------- pkg/generator/generate.go | 99 +++++++++++-------- pkg/generator/utils.go | 52 ++++++++-- tests/data/misc/capitalization.go.output | 55 +++++++++++ tests/data/misc/capitalization.json | 46 +++++++++ .../{misc => miscWithDefaults}/case.go.output | 0 .../data/{misc => miscWithDefaults}/case.json | 0 .../caseDupes.go.output | 0 .../{misc => miscWithDefaults}/caseDupes.json | 0 .../emptyDefinition.FAIL.json | 0 .../rootEmptyJustDefinitions.go.output | 0 .../rootEmptyJustDefinitions.json | 0 .../rootIsArrayOfString.go.output | 0 .../rootIsArrayOfString.json | 0 tests/integration_test.go | 76 +++++++++++--- 16 files changed, 278 insertions(+), 106 deletions(-) create mode 100644 tests/data/misc/capitalization.go.output create mode 100644 tests/data/misc/capitalization.json rename tests/data/{misc => miscWithDefaults}/case.go.output (100%) rename tests/data/{misc => miscWithDefaults}/case.json (100%) rename tests/data/{misc => miscWithDefaults}/caseDupes.go.output (100%) rename tests/data/{misc => miscWithDefaults}/caseDupes.json (100%) rename tests/data/{misc => miscWithDefaults}/emptyDefinition.FAIL.json (100%) rename tests/data/{misc => miscWithDefaults}/rootEmptyJustDefinitions.go.output (100%) rename tests/data/{misc => miscWithDefaults}/rootEmptyJustDefinitions.json (100%) rename tests/data/{misc => miscWithDefaults}/rootIsArrayOfString.go.output (100%) rename tests/data/{misc => miscWithDefaults}/rootIsArrayOfString.json (100%) diff --git a/cmd/gojsonschema/main.go b/cmd/gojsonschema/main.go index 6dea0a6b..c7741f26 100644 --- a/cmd/gojsonschema/main.go +++ b/cmd/gojsonschema/main.go @@ -18,6 +18,7 @@ var ( schemaPackages []string schemaOutputs []string schemaRootTypes []string + capitalizations []string ) var rootCmd = &cobra.Command{ @@ -47,7 +48,15 @@ var rootCmd = &cobra.Command{ abortWithErr(err) } - schemaMappings := []generator.SchemaMapping{} + cfg := generator.Config{ + Warner: func(message string) { + log("Warning: %s", message) + }, + Capitalizations: capitalizations, + DefaultOutputName: defaultOutput, + DefaultPackageName: defaultPackage, + SchemaMappings: []generator.SchemaMapping{}, + } for _, id := range allKeys(schemaPackageMap, schemaOutputMap, schemaRootTypeMap) { mapping := generator.SchemaMapping{SchemaID: id} if s, ok := schemaPackageMap[id]; ok { @@ -63,12 +72,10 @@ var rootCmd = &cobra.Command{ if s, ok := schemaRootTypeMap[id]; ok { mapping.RootType = s } - schemaMappings = append(schemaMappings, mapping) + cfg.SchemaMappings = append(cfg.SchemaMappings, mapping) } - generator, err := generator.New(schemaMappings, defaultPackage, defaultOutput, func(message string) { - log("Warning: %s", message) - }) + generator, err := generator.New(cfg) if err != nil { abortWithErr(err) } @@ -129,6 +136,9 @@ func main() { rootCmd.PersistentFlags().StringSliceVar(&schemaRootTypes, "schema-root-type", nil, "Override name to use for the root type of a specific schema ID; "+ "must be in the format URI=PACKAGE. By default, it is derived from the file name.") + rootCmd.PersistentFlags().StringSliceVar(&capitalizations, "capitalization", nil, + "Specify a preferred Go capitalization for a string. For example, by default a field "+ + "named 'id' becomes 'Id'. With --capitalization ID, it will be generated as 'ID'.") abortWithErr(rootCmd.Execute()) } diff --git a/pkg/codegen/utils.go b/pkg/codegen/utils.go index eef06592..1b07bd39 100644 --- a/pkg/codegen/utils.go +++ b/pkg/codegen/utils.go @@ -2,8 +2,6 @@ package codegen import ( "fmt" - "path/filepath" - "strings" "github.com/atombender/go-jsonschema/pkg/schemas" ) @@ -23,37 +21,3 @@ func PrimitiveTypeFromJSONSchemaType(jsType string) (Type, error) { } return nil, fmt.Errorf("unknown JSON Schema type %q", jsType) } - -func IdentifierFromFileName(fileName string) string { - s := filepath.Base(fileName) - return Identifierize(strings.TrimSuffix(strings.TrimSuffix(s, ".json"), ".schema")) -} - -func Identifierize(s string) string { - // FIXME: Better handling of non-identifier chars - var sb strings.Builder - seps := "_-. \t" - for { - i := strings.IndexAny(s, seps) - if i == -1 { - sb.WriteString(capitalize(s)) - break - } - sb.WriteString(capitalize(s[0:i])) - for i < len(s) && strings.ContainsRune(seps, rune(s[i])) { - i++ - } - if i >= len(s) { - break - } - s = s[i:] - } - return sb.String() -} - -func capitalize(s string) string { - if len(s) == 0 { - return "" - } - return strings.ToUpper(s[0:1]) + s[1:] -} diff --git a/pkg/generator/generate.go b/pkg/generator/generate.go index 311c1a82..044e99c2 100644 --- a/pkg/generator/generate.go +++ b/pkg/generator/generate.go @@ -13,6 +13,14 @@ import ( "github.com/atombender/go-jsonschema/pkg/schemas" ) +type Config struct { + SchemaMappings []SchemaMapping + Capitalizations []string + DefaultPackageName string + DefaultOutputName string + Warner func(string) +} + type SchemaMapping struct { SchemaID string PackageName string @@ -21,25 +29,15 @@ type SchemaMapping struct { } type Generator struct { + config Config emitter *codegen.Emitter - defaultPackageName string - defaultOutputName string - schemaMappings []SchemaMapping - warner func(string) outputs map[string]*output schemaCacheByFileName map[string]*schemas.Schema } -func New( - schemaMappings []SchemaMapping, - defaultPackageName string, - defaultOutputName string, - warner func(string)) (*Generator, error) { +func New(config Config) (*Generator, error) { return &Generator{ - warner: warner, - schemaMappings: schemaMappings, - defaultPackageName: defaultPackageName, - defaultOutputName: defaultOutputName, + config: config, outputs: map[string]*output{}, schemaCacheByFileName: map[string]*schemas.Schema{}, }, nil @@ -123,12 +121,12 @@ func (g *Generator) loadSchemaFromFile(fileName, parentFileName string) (*schema } func (g *Generator) getRootTypeName(schema *schemas.Schema, fileName string) string { - for _, m := range g.schemaMappings { + for _, m := range g.config.SchemaMappings { if m.SchemaID == schema.ID && m.RootType != "" { return m.RootType } } - return codegen.IdentifierFromFileName(fileName) + return g.identifierFromFileName(fileName) } func (g *Generator) findOutputFileForSchemaID(id string) (*output, error) { @@ -136,12 +134,12 @@ func (g *Generator) findOutputFileForSchemaID(id string) (*output, error) { return o, nil } - for _, m := range g.schemaMappings { + for _, m := range g.config.SchemaMappings { if m.SchemaID == id { return g.beginOutput(id, m.OutputName, m.PackageName) } } - return g.beginOutput(id, g.defaultOutputName, g.defaultPackageName) + return g.beginOutput(id, g.config.DefaultOutputName, g.config.DefaultPackageName) } func (g *Generator) beginOutput( @@ -170,7 +168,7 @@ func (g *Generator) beginOutput( } output := &output{ - warner: g.warner, + warner: g.config.Warner, file: &codegen.File{ FileName: outputName, Package: pkg, @@ -183,6 +181,39 @@ func (g *Generator) beginOutput( return output, nil } +func (g *Generator) makeEnumConstantName(typeName, value string) string { + if strings.ContainsAny(typeName[len(typeName)-1:], "0123456789") { + return typeName + "_" + g.identifierize(value) + } + return typeName + g.identifierize(value) +} + +func (g *Generator) identifierFromFileName(fileName string) string { + s := filepath.Base(fileName) + return g.identifierize(strings.TrimSuffix(strings.TrimSuffix(s, ".json"), ".schema")) +} + +func (g *Generator) identifierize(s string) string { + // FIXME: Better handling of non-identifier chars + var sb strings.Builder + for _, part := range splitIdentifierByCaseAndSeparators(s) { + _, _ = sb.WriteString(g.capitalize(part)) + } + return sb.String() +} + +func (g *Generator) capitalize(s string) string { + if len(s) == 0 { + return "" + } + for _, c := range g.config.Capitalizations { + if strings.ToLower(c) == strings.ToLower(s) { + return c + } + } + return strings.ToUpper(s[0:1]) + s[1:] +} + type schemaGenerator struct { *Generator output *output @@ -197,7 +228,7 @@ func (g *schemaGenerator) generateRootType() error { if g.schema.Type.Type == "" { for name, def := range g.schema.Definitions { - _, err := g.generateDeclaredType(def, newNameScope(codegen.Identifierize(name))) + _, err := g.generateDeclaredType(def, newNameScope(g.identifierize(name))) if err != nil { return err } @@ -250,7 +281,7 @@ func (g *schemaGenerator) generateReferencedType(ref string) (codegen.Type, erro } // Minor hack to make definitions default to being objects def.Type = schemas.TypeNameObject - defName = codegen.Identifierize(defName) + defName = g.identifierize(defName) } else { def = schema.Type defName = g.getRootTypeName(schema, fileName) @@ -423,7 +454,7 @@ func (g *schemaGenerator) generateStructType( scope nameScope) (codegen.Type, error) { if len(t.Properties) == 0 { if len(t.Required) > 0 { - g.warner("object type with no properties has required fields; " + + g.config.Warner("object type with no properties has required fields; " + "skipping validation code for them since we don't know their types") } return &codegen.MapType{ @@ -450,11 +481,11 @@ func (g *schemaGenerator) generateStructType( prop := t.Properties[name] isRequired := requiredNames[name] - fieldName := codegen.Identifierize(name) + fieldName := g.identifierize(name) if count, ok := uniqueNames[fieldName]; ok { uniqueNames[fieldName] = count + 1 fieldName = fmt.Sprintf("%s_%d", fieldName, count+1) - g.warner(fmt.Sprintf("field %q maps to a field by the same name declared "+ + g.config.Warner(fmt.Sprintf("field %q maps to a field by the same name declared "+ "in the same struct; it will be declared as %s", name, fieldName)) } else { uniqueNames[fieldName] = 1 @@ -575,7 +606,7 @@ func (g *schemaGenerator) generateEnumType( enumType = codegen.PrimitiveType{primitiveType} } if wrapInStruct { - g.warner("Enum field wrapped in struct in order to store values of multiple types") + g.config.Warner("Enum field wrapped in struct in order to store values of multiple types") enumType = &codegen.StructType{ Fields: []codegen.StructField{ { @@ -659,7 +690,7 @@ func (g *schemaGenerator) generateEnumType( if s, ok := v.(string); ok { // TODO: Make sure the name is unique across scope g.output.file.Package.AddDecl(&codegen.Constant{ - Name: makeEnumConstantName(enumDecl.Name, s), + Name: g.makeEnumConstantName(enumDecl.Name, s), Type: &codegen.NamedType{Decl: &enumDecl}, Value: s, }) @@ -670,24 +701,6 @@ func (g *schemaGenerator) generateEnumType( return &codegen.NamedType{Decl: &enumDecl}, nil } -// func (g *schemaGenerator) generateAnonymousType( -// t *schemas.Type, name string) (codegen.Type, error) { -// if t.Type == schemas.TypeNameObject { -// if len(t.Properties) == 0 { -// return codegen.MapType{ -// KeyType: codegen.PrimitiveType{"string"}, -// ValueType: codegen.EmptyInterfaceType{}, -// }, nil -// } -// s, err := g.generateStructDecl(t, name, "", false) -// if err != nil { -// return nil, err -// } -// return &codegen.NamedType{Decl: s}, nil -// } -// return nil, fmt.Errorf("unexpected type %q", t.Type) -// } - type output struct { file *codegen.File enums map[string]cachedEnum diff --git a/pkg/generator/utils.go b/pkg/generator/utils.go index 222da1a4..12a0d88e 100644 --- a/pkg/generator/utils.go +++ b/pkg/generator/utils.go @@ -4,9 +4,7 @@ import ( "crypto/sha256" "fmt" "sort" - "strings" - - "github.com/atombender/go-jsonschema/pkg/codegen" + "unicode" ) func hashArrayOfValues(values []interface{}) string { @@ -23,9 +21,49 @@ func hashArrayOfValues(values []interface{}) string { return fmt.Sprintf("%x", h.Sum(nil)) } -func makeEnumConstantName(typeName, value string) string { - if strings.ContainsAny(typeName[len(typeName)-1:], "0123456789") { - return typeName + "_" + codegen.Identifierize(value) +func splitIdentifierByCaseAndSeparators(s string) []string { + if len(s) == 0 { + return nil + } + + type state int + const ( + stateNothing state = iota + stateLower + stateUpper + stateNumber + stateDelimiter + ) + + var result []string + currState, j := stateNothing, 0 + for i := 0; i < len(s); i++ { + var nextState state + c := rune(s[i]) + switch { + case unicode.IsLower(c): + nextState = stateLower + case unicode.IsUpper(c): + nextState = stateUpper + case unicode.IsNumber(c): + nextState = stateNumber + default: + nextState = stateDelimiter + } + if nextState != currState { + if currState == stateDelimiter { + j = i + } else if !(currState == stateUpper && nextState == stateLower) { + if i > j { + result = append(result, s[j:i]) + } + j = i + } + currState = nextState + } + } + if currState != stateDelimiter && len(s)-j > 0 { + result = append(result, s[j:]) } - return typeName + codegen.Identifierize(value) + return result } diff --git a/tests/data/misc/capitalization.go.output b/tests/data/misc/capitalization.go.output new file mode 100644 index 00000000..db945df3 --- /dev/null +++ b/tests/data/misc/capitalization.go.output @@ -0,0 +1,55 @@ +// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT. + +package test +import "encoding/json" + +type Capitalization struct { + // HtMl corresponds to the JSON schema field "html". + HtMl *string `json:"html,omitempty"` + + // HtMlSomethingElse corresponds to the JSON schema field "htmlSomethingElse". + HtMlSomethingElse *string `json:"htmlSomethingElse,omitempty"` + + // HtMl_2 corresponds to the JSON schema field "html__". + HtMl_2 *string `json:"html__,omitempty"` + + // HtMlSomething corresponds to the JSON schema field "html_something". + HtMlSomething *string `json:"html_something,omitempty"` + + // ID corresponds to the JSON schema field "id". + ID *string `json:"id,omitempty"` + + // IDSomethingElse corresponds to the JSON schema field "idSomethingElse". + IDSomethingElse *string `json:"idSomethingElse,omitempty"` + + // ID_2 corresponds to the JSON schema field "id__". + ID_2 *string `json:"id__,omitempty"` + + // IDSomething corresponds to the JSON schema field "id_something". + IDSomething *string `json:"id_something,omitempty"` + + // URL corresponds to the JSON schema field "url". + URL *string `json:"url,omitempty"` + + // URLSomethingElse corresponds to the JSON schema field "urlSomethingElse". + URLSomethingElse *string `json:"urlSomethingElse,omitempty"` + + // URL_2 corresponds to the JSON schema field "url__". + URL_2 *string `json:"url__,omitempty"` + + // URLSomething corresponds to the JSON schema field "url_something". + URLSomething *string `json:"url_something,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *Capitalization) UnmarshalJSON(b []byte) error { + var v struct { + } + if err := json.Unmarshal(b, &v); err != nil { return err } + type plain Capitalization + var p plain + if err := json.Unmarshal(b, &p); err != nil { return err } + *j = Capitalization(p) + return nil +} + diff --git a/tests/data/misc/capitalization.json b/tests/data/misc/capitalization.json new file mode 100644 index 00000000..781c7bdd --- /dev/null +++ b/tests/data/misc/capitalization.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "https://example.com/case", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "html": { + "type": "string" + }, + + "url_something": { + "type": "string" + }, + "id_something": { + "type": "string" + }, + "html_something": { + "type": "string" + }, + + "urlSomethingElse": { + "type": "string" + }, + "idSomethingElse": { + "type": "string" + }, + "htmlSomethingElse": { + "type": "string" + }, + + "url__": { + "type": "string" + }, + "id__": { + "type": "string" + }, + "html__": { + "type": "string" + } + } +} diff --git a/tests/data/misc/case.go.output b/tests/data/miscWithDefaults/case.go.output similarity index 100% rename from tests/data/misc/case.go.output rename to tests/data/miscWithDefaults/case.go.output diff --git a/tests/data/misc/case.json b/tests/data/miscWithDefaults/case.json similarity index 100% rename from tests/data/misc/case.json rename to tests/data/miscWithDefaults/case.json diff --git a/tests/data/misc/caseDupes.go.output b/tests/data/miscWithDefaults/caseDupes.go.output similarity index 100% rename from tests/data/misc/caseDupes.go.output rename to tests/data/miscWithDefaults/caseDupes.go.output diff --git a/tests/data/misc/caseDupes.json b/tests/data/miscWithDefaults/caseDupes.json similarity index 100% rename from tests/data/misc/caseDupes.json rename to tests/data/miscWithDefaults/caseDupes.json diff --git a/tests/data/misc/emptyDefinition.FAIL.json b/tests/data/miscWithDefaults/emptyDefinition.FAIL.json similarity index 100% rename from tests/data/misc/emptyDefinition.FAIL.json rename to tests/data/miscWithDefaults/emptyDefinition.FAIL.json diff --git a/tests/data/misc/rootEmptyJustDefinitions.go.output b/tests/data/miscWithDefaults/rootEmptyJustDefinitions.go.output similarity index 100% rename from tests/data/misc/rootEmptyJustDefinitions.go.output rename to tests/data/miscWithDefaults/rootEmptyJustDefinitions.go.output diff --git a/tests/data/misc/rootEmptyJustDefinitions.json b/tests/data/miscWithDefaults/rootEmptyJustDefinitions.json similarity index 100% rename from tests/data/misc/rootEmptyJustDefinitions.json rename to tests/data/miscWithDefaults/rootEmptyJustDefinitions.json diff --git a/tests/data/misc/rootIsArrayOfString.go.output b/tests/data/miscWithDefaults/rootIsArrayOfString.go.output similarity index 100% rename from tests/data/misc/rootIsArrayOfString.go.output rename to tests/data/miscWithDefaults/rootIsArrayOfString.go.output diff --git a/tests/data/misc/rootIsArrayOfString.json b/tests/data/miscWithDefaults/rootIsArrayOfString.json similarity index 100% rename from tests/data/misc/rootIsArrayOfString.json rename to tests/data/miscWithDefaults/rootIsArrayOfString.json diff --git a/tests/integration_test.go b/tests/integration_test.go index 0d0d43e0..97e45772 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -1,19 +1,26 @@ package tests import ( + "fmt" "io/ioutil" "os" + "os/exec" "path/filepath" - "reflect" "strings" "testing" "github.com/atombender/go-jsonschema/pkg/generator" ) +var basicConfig = generator.Config{ + SchemaMappings: []generator.SchemaMapping{}, + DefaultPackageName: "github.com/example/test", + DefaultOutputName: "-", + Warner: func(message string) {}, +} + func TestCore(t *testing.T) { - generator, err := generator.New([]generator.SchemaMapping{}, - "github.com/example/test", "-", func(message string) {}) + generator, err := generator.New(basicConfig) if err != nil { t.Error(err) } @@ -21,25 +28,24 @@ func TestCore(t *testing.T) { } func TestValidation(t *testing.T) { - generator, err := generator.New([]generator.SchemaMapping{}, - "github.com/example/test", "-", func(message string) {}) + generator, err := generator.New(basicConfig) if err != nil { t.Error(err) } testExamples(t, generator, "./data/validation") } -func TestMisc(t *testing.T) { - generator, err := generator.New([]generator.SchemaMapping{}, - "github.com/example/test", "-", func(message string) {}) +func TestMiscWithDefaults(t *testing.T) { + generator, err := generator.New(basicConfig) if err != nil { t.Error(err) } - testExamples(t, generator, "./data/misc") + testExamples(t, generator, "./data/miscWithDefaults") } func TestCrossPackage(t *testing.T) { - generator, err := generator.New([]generator.SchemaMapping{ + cfg := basicConfig + cfg.SchemaMappings = []generator.SchemaMapping{ { SchemaID: "https://example.com/schema", PackageName: "github.com/example/schema", @@ -50,13 +56,24 @@ func TestCrossPackage(t *testing.T) { PackageName: "github.com/example/other", OutputName: "other.go", }, - }, "github.com/example/test", "-", func(message string) {}) + } + generator, err := generator.New(cfg) if err != nil { t.Error(err) } testExampleFile(t, generator, "./data/crossPackage/schema.json") } +func TestCapitalization(t *testing.T) { + cfg := basicConfig + cfg.Capitalizations = []string{"ID", "URL", "HtMl"} + generator, err := generator.New(cfg) + if err != nil { + t.Error(err) + } + testExampleFile(t, generator, "./data/misc/capitalization.json") +} + func testExamples(t *testing.T, generator *generator.Generator, dataDir string) { fileInfos, err := ioutil.ReadDir(dataDir) if err != nil { @@ -104,10 +121,8 @@ func testExampleFile(t *testing.T, generator *generator.Generator, fileName stri t.Error(err) } } - if !reflect.DeepEqual(source, goldenData) { - t.Logf("Expected: %s", goldenData) - t.Logf("Actual: %s", source) - t.Error("Contents different") + if diff, ok := diffStrings(t, string(goldenData), string(source)); !ok { + t.Error(fmt.Sprintf("Contents different (left is expected, right is actual):\n%s", *diff)) } } }) @@ -120,3 +135,34 @@ func testFailingExampleFile(t *testing.T, generator *generator.Generator, fileNa } }) } + +func diffStrings(t *testing.T, expected, actual string) (*string, bool) { + if actual == expected { + return nil, true + } + + dir, err := ioutil.TempDir("", "test") + if err != nil { + t.Error(err.Error()) + } + defer func() { + _ = os.RemoveAll(dir) + }() + + if err := ioutil.WriteFile(fmt.Sprintf("%s/expected", dir), []byte(expected), 0644); err != nil { + t.Error(err.Error()) + } + if err := ioutil.WriteFile(fmt.Sprintf("%s/actual", dir), []byte(actual), 0644); err != nil { + t.Error(err.Error()) + } + + out, err := exec.Command("diff", "--side-by-side", + fmt.Sprintf("%s/expected", dir), + fmt.Sprintf("%s/actual", dir)).Output() + if _, ok := err.(*exec.ExitError); !ok { + t.Error(err.Error()) + } + + diff := string(out) + return &diff, false +}