feat: simplified generated resources code customizations with yaml file config (#17)

* feat: simplified generated code customizations with yaml file config

* chore: apply linting
This commit is contained in:
Mateusz Filipowicz
2025-02-12 09:18:44 +01:00
committed by GitHub
parent 8c99b428d9
commit 5a403dbb39
8 changed files with 456 additions and 94 deletions

View File

@@ -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

157
codegen/customize.go Normal file
View File

@@ -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)
}
}
}

199
codegen/customize_test.go Normal file
View File

@@ -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")
}

View File

@@ -41,18 +41,19 @@ func generateCodeFromTemplate(templateName, templateContent string, toWrite any)
} }
// generateCode generates code for each generation source and writes it to file. // 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 { if _, err := ensurePath(outDir); err != nil {
return fmt.Errorf("unable to create output directory %s: %w", outDir, err) return fmt.Errorf("unable to create output directory %s: %w", outDir, err)
} }
generators := make([]Generatable, 0) generators := make([]Generatable, 0)
resources, err := buildResourcesFromDownloadedFields(fieldsDir) resources, err := buildResourcesFromDownloadedFields(fieldsDir, customizer)
if err != nil { if err != nil {
return fmt.Errorf("failed to build resources from downloaded fields: %w", err) return fmt.Errorf("failed to build resources from downloaded fields: %w", err)
} }
client := newClientInfo(resources) client := newClientInfo(resources)
for _, resource := range resources { for _, resource := range resources {
customizer.ApplyToResource(resource)
generators = append(generators, resource) generators = append(generators, resource)
} }
generators = append(generators, client) generators = append(generators, client)

View File

@@ -199,7 +199,7 @@ func TestGenerateCodeFromFields(t *testing.T) {
tt.setupMockFiles(tt.fieldsDir) tt.setupMockFiles(tt.fieldsDir)
} }
err := generateCode(tt.fieldsDir, tt.outDir) err := generateCode(tt.fieldsDir, tt.outDir, noopCustomizer{})
if tt.expectedError { if tt.expectedError {
require.Error(t, err) require.Error(t, err)

View File

@@ -37,11 +37,12 @@ func setupLogging(debugEnabled, traceEnabled bool) {
} }
type options struct { type options struct {
versionBaseDir string versionBaseDir string
outputDir string outputDir string
downloadOnly bool downloadOnly bool
version string version string
firmwareUpdateApi string firmwareUpdateApi string
customizationsPath string
} }
func main() { func main() {
@@ -61,11 +62,12 @@ func main() {
specifiedVersion = LatestVersionMarker // default to latest version specifiedVersion = LatestVersionMarker // default to latest version
} }
err := generate(options{ err := generate(options{
versionBaseDir: *versionBaseDirFlag, versionBaseDir: *versionBaseDirFlag,
outputDir: *outputDirFlag, outputDir: *outputDirFlag,
downloadOnly: *downloadOnly, downloadOnly: *downloadOnly,
version: specifiedVersion, version: specifiedVersion,
firmwareUpdateApi: defaultFirmwareUpdateApi, firmwareUpdateApi: defaultFirmwareUpdateApi,
customizationsPath: "customizations.yml",
}) })
if err != nil { if err != nil {
log.Error(err) log.Error(err)
@@ -114,7 +116,11 @@ func generate(opts options) error {
} else { } else {
outDir = filepath.Join(wd, opts.outputDir) 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) return fmt.Errorf("unable to generate resources code: %w", err)
} }

View File

@@ -82,11 +82,13 @@ var fileReps = []replacement{
{"ApGroups", "APGroup"}, {"ApGroups", "APGroup"},
} }
type FieldProcessor func(name string, f *FieldInfo) error
type Resource struct { type Resource struct {
StructName string StructName string
ResourcePath string ResourcePath string
Types map[string]*FieldInfo Types map[string]*FieldInfo
FieldProcessor func(name string, f *FieldInfo) error FieldProcessor FieldProcessor
} }
func (r *Resource) BaseType() *FieldInfo { func (r *Resource) BaseType() *FieldInfo {
@@ -318,7 +320,7 @@ func normalizeValidation(re string) string {
var skippable = []string{"AuthenticationRequest.json", "Setting.json", "Wall.json"} 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) fieldsFiles, err := os.ReadDir(fieldsDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, err) 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) resource := NewResource(structName, urlPath)
customizeResource(resource) customizeResource(resource)
customizer.ApplyToResource(resource)
err = resource.processJSON(b) err = resource.processJSON(b)
if err != nil { if err != nil {
@@ -396,63 +399,6 @@ func customizeResource(resource *Resource) {
customizeBaseType(resource) customizeBaseType(resource)
switch resource.StructName { 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": case "SettingGlobalAp":
resource.FieldProcessor = func(name string, f *FieldInfo) error { resource.FieldProcessor = func(name string, f *FieldInfo) error {
if strings.HasPrefix(name, "6E") { if strings.HasPrefix(name, "6E") {
@@ -487,25 +433,5 @@ func customizeResource(resource *Resource) {
} }
return nil 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
}
} }
} }

View File

@@ -354,7 +354,7 @@ func TestBuildResourcesFromDownloadedFields(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
a := assert.New(t) a := assert.New(t)
resources, err := buildResourcesFromDownloadedFields(tc.dir) resources, err := buildResourcesFromDownloadedFields(tc.dir, noopCustomizer{})
if tc.errorContains != "" { if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains) require.ErrorContains(t, err, tc.errorContains)
a.Nil(resources) a.Nil(resources)