diff --git a/codegen/customizations.yml b/codegen/customizations.yml new file mode 100644 index 0000000..15847ba --- /dev/null +++ b/codegen/customizations.yml @@ -0,0 +1,73 @@ +--- +customizations: + resources: + Account: + fields: + IP: + omitEmpty: true + NetworkID: + omitEmpty: true + ChannelPlan: + fields: + Channel: + ifFieldType: "string" + customUnmarshalType: "numberOrString" + BackupChannel: + ifFieldType: "string" + customUnmarshalType: "numberOrString" + TxPower: + ifFieldType: "string" + customUnmarshalType: "numberOrString" + Device: + fields: + _all: + omitEmpty: true + X: + fieldType: "float64" + Y: + fieldType: "float64" + StpPriority: + fieldType: "string" + customUnmarshalType: "numberOrString" + Ht: + fieldType: "int" + Channel: + customUnmarshalType: "numberOrString" + ifFieldType: "string" + BackupChannel: + customUnmarshalType: "numberOrString" + ifFieldType: "string" + TxPower: + customUnmarshalType: "numberOrString" + ifFieldType: "string" + LteExtAnt: + customUnmarshalType: "booleanishString" + LtePoe: + customUnmarshalType: "booleanishString" + PortOverrides: + omitEmpty: false + Network: + fields: + InternetAccessEnabled: + ifFieldType: "bool" + customUnmarshalType: "*bool" + customUnmarshalFunc: "emptyBoolToTrue" + IntraNetworkAccessEnabled: + ifFieldType: "bool" + customUnmarshalType: "*bool" + customUnmarshalFunc: "emptyBoolToTrue" + WANUsername: + omitEmpty: true + XWANPassword: + omitEmpty: true + User: + fields: + Blocked: + fieldType: "bool" + LastSeen: + fieldType: "int" + customUnmarshalType: "emptyStringInt" + WLAN: + fields: + ScheduleWithDuration: + omitEmpty: false diff --git a/codegen/customize.go b/codegen/customize.go new file mode 100644 index 0000000..7a390a3 --- /dev/null +++ b/codegen/customize.go @@ -0,0 +1,157 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +const ( + AllFieldsCustomizationKeyword = "_all" + defaultCustomizationsPath = "customizations.yml" +) + +type Generate struct { + Customizations struct { + Resources map[string]*ResourceCustomization `yaml:"resources"` + } `yaml:"customizations"` +} + +type ResourceCustomization struct { + ResourceName string `yaml:"-"` + Fields map[string]*FieldCustomization `yaml:"fields"` +} + +type FieldCustomization struct { + FieldName string `yaml:"-"` + Overrides *FieldInfoOverride `yaml:",inline"` + IfFieldType string `yaml:"ifFieldType"` +} + +type FieldInfoOverride struct { + FieldName *string `yaml:"fieldName"` + FieldType *string `yaml:"fieldType"` + OmitEmpty *bool `yaml:"omitEmpty"` + CustomUnmarshalType *string `yaml:"customUnmarshalType"` + CustomUnmarshalFunc *string `yaml:"customUnmarshalFunc"` +} + +func compositeCustomizationsProcessor(customizationsProcessor FieldProcessor) FieldProcessor { + return func(name string, f *FieldInfo) error { + err := customizationsProcessor(AllFieldsCustomizationKeyword, f) + if err != nil { + return fmt.Errorf("failed applying all fields customization to %s field: %w", name, err) + } + err = customizationsProcessor(name, f) + if err != nil { + return fmt.Errorf("failed applying customization to %s fields: %w", name, err) + } + return nil + } +} + +func (r *ResourceCustomization) ApplyTo(resource *Resource) { + if resource.StructName == r.ResourceName { + currentProcessor := resource.FieldProcessor + customizationsProcessor := r.toFieldProcessor() + if currentProcessor != nil { + // create composite processor with existing processor, first running pre-defined customizations, then user-defined + resource.FieldProcessor = func(name string, f *FieldInfo) error { + err := compositeCustomizationsProcessor(customizationsProcessor)(name, f) + if err != nil { + return err + } + return currentProcessor(name, f) + } + } else { + resource.FieldProcessor = compositeCustomizationsProcessor(customizationsProcessor) + } + } +} + +func (r *ResourceCustomization) toFieldProcessor() FieldProcessor { + return func(name string, f *FieldInfo) error { + if fc, ok := r.Fields[name]; ok && fc.Overrides != nil && (fc.IfFieldType == "" || fc.IfFieldType == f.FieldType) { + if fc.Overrides.FieldType != nil { + f.FieldType = *fc.Overrides.FieldType + } + if fc.Overrides.CustomUnmarshalType != nil { + f.CustomUnmarshalType = *fc.Overrides.CustomUnmarshalType + } + if fc.Overrides.OmitEmpty != nil { + f.OmitEmpty = *fc.Overrides.OmitEmpty + } + if fc.Overrides.CustomUnmarshalFunc != nil { + f.CustomUnmarshalFunc = *fc.Overrides.CustomUnmarshalFunc + } + if fc.Overrides.FieldName != nil { + f.FieldName = *fc.Overrides.FieldName + } + } + return nil + } +} + +//go:embed customizations.yml +var defaultCustomizationYml []byte + +func readCustomizationsYml(customizationsPath string) ([]byte, error) { + if customizationsPath == "" || customizationsPath == defaultCustomizationsPath { + return defaultCustomizationYml, nil + } + customizations, err := os.ReadFile(customizationsPath) + if err != nil { + return nil, fmt.Errorf("failed reading customizations file %s: %w", customizationsPath, err) + } + return customizations, nil +} + +func unmarshalCustomizationYaml(customizationsPath string) (*Generate, error) { + var generate Generate + customizationsYml, err := readCustomizationsYml(customizationsPath) + if err != nil { + return nil, err + } + err = yaml.Unmarshal(customizationsYml, &generate) + if err != nil { + return nil, fmt.Errorf("failed unmarshalling YAML to Generate structure: %w", err) + } + // Assign ResourceName and FieldName based on the map keys + for resourceName, resource := range generate.Customizations.Resources { + resource.ResourceName = resourceName + for fieldName, field := range resource.Fields { + field.FieldName = fieldName + } + } + return &generate, nil +} + +type YamlConfigCodeCustomizer struct { + Customizations map[string]*ResourceCustomization +} + +type CodeCustomizer interface { + ApplyToResource(resource *Resource) +} + +type noopCustomizer struct{} + +func (noopCustomizer) ApplyToResource(resource *Resource) {} + +func NewCodeCustomizer(customizationsPath string) (CodeCustomizer, error) { //nolint: ireturn + generate, err := unmarshalCustomizationYaml(customizationsPath) + if err != nil { + return nil, err + } + return &YamlConfigCodeCustomizer{generate.Customizations.Resources}, nil +} + +func (r *YamlConfigCodeCustomizer) ApplyToResource(resource *Resource) { + for resourceName, resourceCustomization := range r.Customizations { + if resource.StructName == resourceName { + resourceCustomization.ApplyTo(resource) + } + } +} diff --git a/codegen/customize_test.go b/codegen/customize_test.go new file mode 100644 index 0000000..aa85a95 --- /dev/null +++ b/codegen/customize_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Removed dummy type declarations for FieldInfo and Resource since they are already defined in the package + +func TestUnmarshalCustomizationYamlDefault(t *testing.T) { + t.Parallel() + a := assert.New(t) + + generate, err := unmarshalCustomizationYaml("") + require.NoError(t, err) + require.NotNil(t, generate) + + // Check that some expected resource customizations exist + a.Contains(generate.Customizations.Resources, "Account") + a.Contains(generate.Customizations.Resources, "Device") + + dvc, ok := generate.Customizations.Resources["Device"] + a.True(ok, "Device customization should exist") + a.Contains(dvc.Fields, AllFieldsCustomizationKeyword) +} + +func TestNewCodeCustomizer_NonExistent(t *testing.T) { + t.Parallel() + cc, err := NewCodeCustomizer("nonexistent.yml") + require.Error(t, err) + require.ErrorContains(t, err, "failed reading customizations file") + assert.Nil(t, cc) +} + +func TestApplyToResource(t *testing.T) { + t.Parallel() + a := assert.New(t) + + cc, err := NewCodeCustomizer("") + require.NoError(t, err) + + // Create a dummy Resource for 'Device' + res := &Resource{StructName: "Device"} + cc.ApplyToResource(res) + a.NotNil(res.FieldProcessor, "FieldProcessor should be set after applying customizations") + + // Test field 'X': should update FieldType to "float64" and _all customization sets omitEmpty true + fiX := &FieldInfo{ + FieldName: "X", + FieldType: "string", + OmitEmpty: false, + } + err = res.FieldProcessor("X", fiX) + require.NoError(t, err) + a.Equal("float64", fiX.FieldType, "X field type should be updated to float64") + a.True(fiX.OmitEmpty, "OmitEmpty should be true due to _all customization") + + // Test field 'Channel': applied only when FieldType equals "string" + fiChannel := &FieldInfo{ + FieldName: "Channel", + FieldType: "string", + } + err = res.FieldProcessor("Channel", fiChannel) + require.NoError(t, err) + a.Equal("numberOrString", fiChannel.CustomUnmarshalType, "Channel should get customUnmarshalType override") + + // Test 'Channel' with non-matching FieldType: no override gets applied + fiChannelMismatch := &FieldInfo{ + FieldName: "Channel", + FieldType: "int", + } + err = res.FieldProcessor("Channel", fiChannelMismatch) + require.NoError(t, err) + a.Equal("", fiChannelMismatch.CustomUnmarshalType, "Override should not apply when FieldType does not match") +} + +func TestCompositeFieldProcessor(t *testing.T) { + t.Parallel() + a := assert.New(t) + + cc, err := NewCodeCustomizer("") + require.NoError(t, err) + + // Create a Resource for 'Account' with a pre-existing FieldProcessor that appends "_original" to FieldName + res := &Resource{ + StructName: "Account", + FieldProcessor: func(name string, f *FieldInfo) error { + // Original processing: append '_original' to FieldName + f.FieldName = f.FieldName + "_original" + return nil + }, + } + cc.ApplyToResource(res) + a.NotNil(res.FieldProcessor, "Composite FieldProcessor should be set") + + // For Account, customization for field 'IP' sets omitEmpty true + fiIP := &FieldInfo{ + FieldName: "IP", + FieldType: "string", + OmitEmpty: false, + } + err = res.FieldProcessor("IP", fiIP) + require.NoError(t, err) + // Expected behavior: customization applies first (e.g. setting omitEmpty) and then the original processor appends suffix + a.True(fiIP.OmitEmpty, "OmitEmpty should be set to true by customization") + a.Equal("IP_original", fiIP.FieldName, "FieldName should have '_original' appended by the composite processor") +} + +func TestNoCustomizationForResource(t *testing.T) { + t.Parallel() + // Create a Resource that does not have any associated customizations + res := &Resource{StructName: "NonExistent"} + + cc, err := NewCodeCustomizer("") + require.NoError(t, err) + + cc.ApplyToResource(res) + assert.Nil(t, res.FieldProcessor, "FieldProcessor should remain nil if no customization applies") +} + +func createTempCustomizationsYaml(t *testing.T, data string) string { + t.Helper() + tempFile := filepath.Join(t.TempDir(), "temp_customizations.yml") + err := os.WriteFile(tempFile, []byte(data), 0o644) + require.NoError(t, err, "should create temp file") + return tempFile +} + +func TestReadCustomizationsYmlError(t *testing.T) { + t.Parallel() + tempFile := createTempCustomizationsYaml(t, "invalid: yaml: ::::\n") + + // Expect an error due to invalid YAML + _, err := unmarshalCustomizationYaml(tempFile) + require.Error(t, err) + require.ErrorContains(t, err, "failed unmarshalling YAML") +} + +func TestApplyToResource_CustomInline(t *testing.T) { + t.Parallel() + yamlContent := ` +customizations: + resources: + CustomResource: + fields: + CustomField: + fieldType: int + omitEmpty: true +` + tempFile := createTempCustomizationsYaml(t, yamlContent) + cc, err := NewCodeCustomizer(tempFile) + require.NoError(t, err) + + res := &Resource{StructName: "CustomResource"} + cc.ApplyToResource(res) + require.NotNil(t, res.FieldProcessor) + + fi := &FieldInfo{ + FieldName: "CustomField", + FieldType: "string", + OmitEmpty: false, + } + err = res.FieldProcessor("CustomField", fi) + require.NoError(t, err) + assert.Equal(t, "int", fi.FieldType, "Custom field type should be updated to int") + assert.True(t, fi.OmitEmpty, "Custom field omitEmpty should be true") +} + +func TestApplyToResource_CustomFieldMismatch(t *testing.T) { + t.Parallel() + yamlContent := ` +customizations: + resources: + CustomResource: + fields: + CustomField: + ifFieldType: string + customUnmarshalType: customType +` + tempFile := createTempCustomizationsYaml(t, yamlContent) + cc, err := NewCodeCustomizer(tempFile) + require.NoError(t, err) + + res := &Resource{StructName: "CustomResource"} + cc.ApplyToResource(res) + require.NotNil(t, res.FieldProcessor) + + fi := &FieldInfo{ + FieldName: "CustomField", + FieldType: "int", + } + err = res.FieldProcessor("CustomField", fi) + require.NoError(t, err) + assert.Empty(t, fi.CustomUnmarshalType, "Customization should not apply if field type mismatches") +} diff --git a/codegen/generator.go b/codegen/generator.go index 960c699..6c9642d 100644 --- a/codegen/generator.go +++ b/codegen/generator.go @@ -41,18 +41,19 @@ func generateCodeFromTemplate(templateName, templateContent string, toWrite any) } // generateCode generates code for each generation source and writes it to file. -func generateCode(fieldsDir string, outDir string) error { +func generateCode(fieldsDir string, outDir string, customizer CodeCustomizer) error { if _, err := ensurePath(outDir); err != nil { return fmt.Errorf("unable to create output directory %s: %w", outDir, err) } generators := make([]Generatable, 0) - resources, err := buildResourcesFromDownloadedFields(fieldsDir) + resources, err := buildResourcesFromDownloadedFields(fieldsDir, customizer) if err != nil { return fmt.Errorf("failed to build resources from downloaded fields: %w", err) } client := newClientInfo(resources) for _, resource := range resources { + customizer.ApplyToResource(resource) generators = append(generators, resource) } generators = append(generators, client) diff --git a/codegen/generator_test.go b/codegen/generator_test.go index 0f15a29..b5f8fd7 100644 --- a/codegen/generator_test.go +++ b/codegen/generator_test.go @@ -199,7 +199,7 @@ func TestGenerateCodeFromFields(t *testing.T) { tt.setupMockFiles(tt.fieldsDir) } - err := generateCode(tt.fieldsDir, tt.outDir) + err := generateCode(tt.fieldsDir, tt.outDir, noopCustomizer{}) if tt.expectedError { require.Error(t, err) diff --git a/codegen/main.go b/codegen/main.go index cbadd93..8c29a5f 100644 --- a/codegen/main.go +++ b/codegen/main.go @@ -37,11 +37,12 @@ func setupLogging(debugEnabled, traceEnabled bool) { } type options struct { - versionBaseDir string - outputDir string - downloadOnly bool - version string - firmwareUpdateApi string + versionBaseDir string + outputDir string + downloadOnly bool + version string + firmwareUpdateApi string + customizationsPath string } func main() { @@ -61,11 +62,12 @@ func main() { specifiedVersion = LatestVersionMarker // default to latest version } err := generate(options{ - versionBaseDir: *versionBaseDirFlag, - outputDir: *outputDirFlag, - downloadOnly: *downloadOnly, - version: specifiedVersion, - firmwareUpdateApi: defaultFirmwareUpdateApi, + versionBaseDir: *versionBaseDirFlag, + outputDir: *outputDirFlag, + downloadOnly: *downloadOnly, + version: specifiedVersion, + firmwareUpdateApi: defaultFirmwareUpdateApi, + customizationsPath: "customizations.yml", }) if err != nil { log.Error(err) @@ -114,7 +116,11 @@ func generate(opts options) error { } else { outDir = filepath.Join(wd, opts.outputDir) } - if err = generateCode(structuresDir, outDir); err != nil { + customizer, err := NewCodeCustomizer(opts.customizationsPath) + if err != nil { + return fmt.Errorf("unable to create code customizer: %w", err) + } + if err = generateCode(structuresDir, outDir, customizer); err != nil { return fmt.Errorf("unable to generate resources code: %w", err) } diff --git a/codegen/resources.go b/codegen/resources.go index 12a4d83..d33f4cf 100644 --- a/codegen/resources.go +++ b/codegen/resources.go @@ -82,11 +82,13 @@ var fileReps = []replacement{ {"ApGroups", "APGroup"}, } +type FieldProcessor func(name string, f *FieldInfo) error + type Resource struct { StructName string ResourcePath string Types map[string]*FieldInfo - FieldProcessor func(name string, f *FieldInfo) error + FieldProcessor FieldProcessor } func (r *Resource) BaseType() *FieldInfo { @@ -318,7 +320,7 @@ func normalizeValidation(re string) string { var skippable = []string{"AuthenticationRequest.json", "Setting.json", "Wall.json"} -func buildResourcesFromDownloadedFields(fieldsDir string) ([]*Resource, error) { +func buildResourcesFromDownloadedFields(fieldsDir string, customizer CodeCustomizer) ([]*Resource, error) { fieldsFiles, err := os.ReadDir(fieldsDir) if err != nil { return nil, fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, err) @@ -348,6 +350,7 @@ func buildResourcesFromDownloadedFields(fieldsDir string) ([]*Resource, error) { resource := NewResource(structName, urlPath) customizeResource(resource) + customizer.ApplyToResource(resource) err = resource.processJSON(b) if err != nil { @@ -396,63 +399,6 @@ func customizeResource(resource *Resource) { customizeBaseType(resource) switch resource.StructName { - case "Account": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "IP", "NetworkID": - f.OmitEmpty = true - } - return nil - } - case "ChannelPlan": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "Channel", "BackupChannel", "TxPower": - if f.FieldType == "string" { - f.CustomUnmarshalType = "numberOrString" - } - } - return nil - } - case "Device": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "X", "Y": - f.FieldType = "float64" - case "StpPriority": - f.FieldType = "string" - f.CustomUnmarshalType = "numberOrString" - case "Ht": - f.FieldType = "int" - case "Channel", "BackupChannel", "TxPower": - if f.FieldType == "string" { - f.CustomUnmarshalType = "numberOrString" - } - case "LteExtAnt", "LtePoe": - f.CustomUnmarshalType = "booleanishString" - } - - f.OmitEmpty = true - switch name { - case "PortOverrides": - f.OmitEmpty = false - } - - return nil - } - case "Network": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "InternetAccessEnabled", "IntraNetworkAccessEnabled": - if f.FieldType == "bool" { - f.CustomUnmarshalType = "*bool" - f.CustomUnmarshalFunc = "emptyBoolToTrue" - } - case "WANUsername", "XWANPassword": - f.OmitEmpty = true - } - return nil - } case "SettingGlobalAp": resource.FieldProcessor = func(name string, f *FieldInfo) error { if strings.HasPrefix(name, "6E") { @@ -487,25 +433,5 @@ func customizeResource(resource *Resource) { } return nil } - case "User": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "Blocked": - f.FieldType = "bool" - case "LastSeen": - f.FieldType = "int" - f.CustomUnmarshalType = "emptyStringInt" - } - return nil - } - case "WLAN": - resource.FieldProcessor = func(name string, f *FieldInfo) error { - switch name { - case "ScheduleWithDuration": - // always send schedule, so we can empty it if we want to - f.OmitEmpty = false - } - return nil - } } } diff --git a/codegen/resources_test.go b/codegen/resources_test.go index 5792b89..ef0329e 100644 --- a/codegen/resources_test.go +++ b/codegen/resources_test.go @@ -354,7 +354,7 @@ func TestBuildResourcesFromDownloadedFields(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() a := assert.New(t) - resources, err := buildResourcesFromDownloadedFields(tc.dir) + resources, err := buildResourcesFromDownloadedFields(tc.dir, noopCustomizer{}) if tc.errorContains != "" { require.ErrorContains(t, err, tc.errorContains) a.Nil(resources)