feat: add code generation for Unifi client interface (#11)
* feat: add code generation for Unifi client interface * chore: apply linter hints * chore: add tests * fix: nondeterministic client function signature due to iteration of map params * chore: add move version tests * chore: add more generator tests * chore: lint
This commit is contained in:
committed by
GitHub
parent
c6e20b675c
commit
6016a3d34a
40
codegen/client.go.tmpl
Normal file
40
codegen/client.go.tmpl
Normal file
@@ -0,0 +1,40 @@
|
||||
// Code generated from ace.jar fields *.json files
|
||||
// DO NOT EDIT.
|
||||
|
||||
package unifi
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{ range $k, $v := .Imports }}"{{ $v }}"{{- end }}
|
||||
)
|
||||
|
||||
type {{ .Name }} interface {
|
||||
|
||||
/* custom method signatures */
|
||||
|
||||
{{ range $k, $v := .CustomFunctions }}
|
||||
{{ $v.Signature }}
|
||||
{{- end }}
|
||||
|
||||
/* client methods generated based on resource generation */
|
||||
|
||||
{{- range $k, $v := .Functions }}
|
||||
/* client methods for {{ $v.Name }} API */
|
||||
|
||||
// Get{{ $v.Name }} returns {{ $v.Name }} resource{{ if not $v.IsSetting }} by its ID{{ end }}
|
||||
Get{{ $v.Name }}(ctx context.Context, site{{ if not $v.IsSetting }}, id{{ end }} string) (*{{ $v.Name }}, error)
|
||||
// Update{{ $v.Name }} updates {{ $v.Name }} resource{{ if not $v.IsSetting }} by its ID{{ end }}
|
||||
Update{{ $v.Name }}(ctx context.Context, site string, d *{{ $v.Name }}) (*{{ $v.Name }}, error)
|
||||
{{- if not $v.IsSetting }}
|
||||
// List{{ $v.Name }} returns list of {{ $v.Name }} resources
|
||||
List{{ $v.Name }}(ctx context.Context, site string) ([]{{ $v.Name }}, error)
|
||||
// Delete{{ $v.Name }} deletes {{ $v.Name }} resource by its ID
|
||||
Delete{{ $v.Name }}(ctx context.Context, site, id string) error
|
||||
// Create{{ $v.Name }} creates new {{ $v.Name }} resource
|
||||
Create{{ $v.Name }}(ctx context.Context, site string, d *{{ $v.Name }}) (*{{ $v.Name }}, error)
|
||||
{{ end }}
|
||||
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
|
||||
82
codegen/clients.go
Normal file
82
codegen/clients.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientFunction is the interface for client functions.
|
||||
type ClientFunction interface {
|
||||
Name() string
|
||||
IsSetting() bool
|
||||
}
|
||||
|
||||
type FunctionParam struct {
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
// CustomClientFunction represents a custom client function definition.
|
||||
type CustomClientFunction struct {
|
||||
Name string
|
||||
Parameters []FunctionParam
|
||||
ReturnParameters []string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// Signature returns the signature string for the custom client function.
|
||||
func (c *CustomClientFunction) Signature() string {
|
||||
var b strings.Builder
|
||||
if c.Comment != "" {
|
||||
b.WriteString(fmt.Sprintf("// %s %s\n", c.Name, c.Comment))
|
||||
}
|
||||
b.WriteString(c.Name)
|
||||
b.WriteString("(")
|
||||
|
||||
// Build parameters without trailing comma
|
||||
params := make([]string, 0, len(c.Parameters))
|
||||
for _, v := range c.Parameters {
|
||||
params = append(params, fmt.Sprintf("%s %s", v.Name, v.Type))
|
||||
}
|
||||
b.WriteString(strings.Join(params, ", "))
|
||||
b.WriteString(")")
|
||||
|
||||
if len(c.ReturnParameters) > 1 {
|
||||
b.WriteString(" (")
|
||||
b.WriteString(strings.Join(c.ReturnParameters, ", "))
|
||||
b.WriteString(")")
|
||||
} else if len(c.ReturnParameters) == 1 {
|
||||
b.WriteString(" " + c.ReturnParameters[0])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ClientInfo represents the client information used for code generation.
|
||||
type ClientInfo struct {
|
||||
Imports []string
|
||||
Functions []ClientFunction
|
||||
CustomFunctions []CustomClientFunction
|
||||
}
|
||||
|
||||
// newClientInfo creates ClientInfo from the provided resources.
|
||||
func newClientInfo(resources []*Resource) *ClientInfo {
|
||||
functions := make([]ClientFunction, 0)
|
||||
for _, resource := range resources {
|
||||
functions = append(functions, resource)
|
||||
}
|
||||
return &ClientInfo{Functions: functions}
|
||||
}
|
||||
|
||||
//go:embed client.go.tmpl
|
||||
var clientGoTemplate string
|
||||
|
||||
// GenerateCode generates the code for the client using a template.
|
||||
func (c *ClientInfo) GenerateCode() (string, error) {
|
||||
return generateCodeFromTemplate("client.go.tmpl", clientGoTemplate, c)
|
||||
}
|
||||
|
||||
// Name returns the name of the client.
|
||||
func (c *ClientInfo) Name() string {
|
||||
return "client"
|
||||
}
|
||||
105
codegen/clients_test.go
Normal file
105
codegen/clients_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomClientFunctionSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
fn CustomClientFunction
|
||||
wantComment string // expected comment in the signature
|
||||
wantFunc string // expected function signature
|
||||
}{
|
||||
{
|
||||
name: "no comment, no params, no returns",
|
||||
fn: CustomClientFunction{
|
||||
Name: "Foo",
|
||||
},
|
||||
wantFunc: "Foo()",
|
||||
},
|
||||
{
|
||||
name: "with comment, no params, no returns",
|
||||
fn: CustomClientFunction{
|
||||
Name: "Bar",
|
||||
Comment: "does something",
|
||||
},
|
||||
wantComment: "// Bar does something",
|
||||
wantFunc: "Bar()",
|
||||
},
|
||||
{
|
||||
name: "with one param and one return",
|
||||
fn: CustomClientFunction{
|
||||
Name: "Baz",
|
||||
Parameters: []FunctionParam{{"a", "int"}},
|
||||
ReturnParameters: []string{"error"},
|
||||
},
|
||||
wantFunc: "Baz(a int) error",
|
||||
},
|
||||
{
|
||||
name: "with multiple returns",
|
||||
fn: CustomClientFunction{
|
||||
Name: "Qux",
|
||||
Parameters: []FunctionParam{{"x", "string"}},
|
||||
ReturnParameters: []string{"int", "error"},
|
||||
},
|
||||
wantFunc: "Qux(x string) (int, error)",
|
||||
},
|
||||
{
|
||||
name: "with multiple params",
|
||||
fn: CustomClientFunction{
|
||||
Name: "MultiParams",
|
||||
Parameters: []FunctionParam{{"x", "string"}, {"y", "int"}},
|
||||
ReturnParameters: []string{},
|
||||
Comment: "function with multiple parameters",
|
||||
},
|
||||
wantComment: "// MultiParams function with multiple parameters",
|
||||
wantFunc: "MultiParams(x string, y int)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
|
||||
got := tt.fn.Signature()
|
||||
|
||||
parts := strings.Split(got, "\n")
|
||||
var comment, funcSig string
|
||||
if len(tt.wantComment) > 0 {
|
||||
comment = parts[0]
|
||||
funcSig = parts[1]
|
||||
} else {
|
||||
funcSig = parts[0]
|
||||
}
|
||||
a.Equal(tt.wantComment, comment)
|
||||
a.Equal(tt.wantFunc, funcSig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
ci := &ClientInfo{
|
||||
Imports: []string{"fmt"},
|
||||
CustomFunctions: []CustomClientFunction{
|
||||
{
|
||||
Name: "TestFunc",
|
||||
Parameters: []FunctionParam{{"x", "int"}},
|
||||
ReturnParameters: []string{"error"},
|
||||
Comment: "This is a test function",
|
||||
},
|
||||
},
|
||||
}
|
||||
code, err := ci.GenerateCode()
|
||||
require.NoError(t, err)
|
||||
a.NotEmpty(code, "GenerateCode() returned empty code")
|
||||
a.Contains(code, "TestFunc")
|
||||
}
|
||||
@@ -3,319 +3,32 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type replacement struct {
|
||||
Old string
|
||||
New string
|
||||
// Generatable is the interface for generation sources.
|
||||
type Generatable interface {
|
||||
Name() string
|
||||
GenerateCode() (string, error)
|
||||
}
|
||||
|
||||
var fieldReps = []replacement{
|
||||
{"Dhcpdv6", "DHCPDV6"},
|
||||
|
||||
{"Dhcpd", "DHCPD"},
|
||||
{"Idx", "IDX"},
|
||||
{"Ipsec", "IPSec"},
|
||||
{"Ipv6", "IPV6"},
|
||||
{"Openvpn", "OpenVPN"},
|
||||
{"Tftp", "TFTP"},
|
||||
{"Wlangroup", "WLANGroup"},
|
||||
|
||||
{"Bc", "Broadcast"},
|
||||
{"Dhcp", "DHCP"},
|
||||
{"Dns", "DNS"},
|
||||
{"Dpi", "DPI"},
|
||||
{"Dtim", "DTIM"},
|
||||
{"Firewallgroup", "FirewallGroup"},
|
||||
{"Fixedip", "FixedIP"},
|
||||
{"Icmp", "ICMP"},
|
||||
{"Id", "ID"},
|
||||
{"Igmp", "IGMP"},
|
||||
{"Ip", "IP"},
|
||||
{"Leasetime", "LeaseTime"},
|
||||
{"Mac", "MAC"},
|
||||
{"Mcastenhance", "MulticastEnhance"},
|
||||
{"Minrssi", "MinRSSI"},
|
||||
{"Monthdays", "MonthDays"},
|
||||
{"Nat", "NAT"},
|
||||
{"Networkconf", "Network"},
|
||||
{"Networkgroup", "NetworkGroup"},
|
||||
{"Pd", "PD"},
|
||||
{"Pmf", "PMF"},
|
||||
{"Portconf", "PortProfile"},
|
||||
{"Qos", "QOS"},
|
||||
{"Radiusprofile", "RADIUSProfile"},
|
||||
{"Radius", "RADIUS"},
|
||||
{"Ssid", "SSID"},
|
||||
{"Startdate", "StartDate"},
|
||||
{"Starttime", "StartTime"},
|
||||
{"Stopdate", "StopDate"},
|
||||
{"Stoptime", "StopTime"},
|
||||
{"Tcp", "TCP"},
|
||||
{"Udp", "UDP"},
|
||||
{"Usergroup", "UserGroup"},
|
||||
{"Utc", "UTC"},
|
||||
{"Vlan", "VLAN"},
|
||||
{"Vpn", "VPN"},
|
||||
{"Wan", "WAN"},
|
||||
{"Wep", "WEP"},
|
||||
{"Wlan", "WLAN"},
|
||||
{"Wpa", "WPA"},
|
||||
}
|
||||
|
||||
var fileReps = []replacement{
|
||||
{"WlanConf", "WLAN"},
|
||||
{"Dhcp", "DHCP"},
|
||||
{"Wlan", "WLAN"},
|
||||
{"NetworkConf", "Network"},
|
||||
{"PortConf", "PortProfile"},
|
||||
{"RadiusProfile", "RADIUSProfile"},
|
||||
{"ApGroups", "APGroup"},
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
StructName string
|
||||
ResourcePath string
|
||||
Types map[string]*FieldInfo
|
||||
FieldProcessor func(name string, f *FieldInfo) error
|
||||
}
|
||||
|
||||
type FieldInfo struct {
|
||||
FieldName string
|
||||
JSONName string
|
||||
FieldType string
|
||||
FieldValidation string
|
||||
FieldValidationComment string
|
||||
OmitEmpty bool
|
||||
IsArray bool
|
||||
Fields map[string]*FieldInfo
|
||||
CustomUnmarshalType string
|
||||
CustomUnmarshalFunc string
|
||||
}
|
||||
|
||||
func NewResource(structName string, resourcePath string) *Resource {
|
||||
baseType := NewFieldInfo(structName, resourcePath, "struct", "", "", false, false, "")
|
||||
resource := &Resource{
|
||||
StructName: structName,
|
||||
ResourcePath: resourcePath,
|
||||
Types: map[string]*FieldInfo{
|
||||
structName: baseType,
|
||||
},
|
||||
FieldProcessor: func(name string, f *FieldInfo) error { return nil },
|
||||
}
|
||||
|
||||
// Since template files iterate through map keys in sorted order, these initial fields
|
||||
// are named such that they stay at the top for consistency. The spacer items create a
|
||||
// blank line in the resulting generated file.
|
||||
//
|
||||
// This hack is here for stability of the generated code, but can be removed if desired.
|
||||
baseType.Fields = map[string]*FieldInfo{
|
||||
" ID": NewFieldInfo("ID", "_id", "string", "", "", true, false, ""),
|
||||
" SiteID": NewFieldInfo("SiteID", "site_id", "string", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
|
||||
" Hidden": NewFieldInfo("Hidden", "attr_hidden", "bool", "", "", true, false, ""),
|
||||
" HiddenID": NewFieldInfo("HiddenID", "attr_hidden_id", "string", "", "", true, false, ""),
|
||||
" NoDelete": NewFieldInfo("NoDelete", "attr_no_delete", "bool", "", "", true, false, ""),
|
||||
" NoEdit": NewFieldInfo("NoEdit", "attr_no_edit", "bool", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
|
||||
" _Spacer": nil,
|
||||
}
|
||||
|
||||
switch {
|
||||
case resource.IsSetting():
|
||||
resource.ResourcePath = strcase.ToSnake(strings.TrimPrefix(structName, "Setting"))
|
||||
baseType.Fields[" Key"] = NewFieldInfo("Key", "key", "string", "", "", false, false, "")
|
||||
|
||||
if resource.StructName == "SettingUsg" {
|
||||
// Removed in v7, retaining for backwards compatibility
|
||||
baseType.Fields["MdnsEnabled"] = NewFieldInfo("MdnsEnabled", "mdns_enabled", "bool", "", "", false, false, "")
|
||||
}
|
||||
case resource.StructName == "Device":
|
||||
baseType.Fields[" MAC"] = NewFieldInfo("MAC", "mac", "string", createValidations(validation{v: mac}), "", true, false, "")
|
||||
baseType.Fields["Adopted"] = NewFieldInfo("Adopted", "adopted", "bool", "", "", false, false, "")
|
||||
baseType.Fields["Model"] = NewFieldInfo("Model", "model", "string", "", "", true, false, "")
|
||||
baseType.Fields["State"] = NewFieldInfo("State", "state", "DeviceState", "", "", false, false, "")
|
||||
baseType.Fields["Type"] = NewFieldInfo("Type", "type", "string", "", "", true, false, "")
|
||||
case resource.StructName == "User":
|
||||
baseType.Fields[" IP"] = NewFieldInfo("IP", "ip", "string", createValidations(validation{v: ip}), "non-generated field", true, false, "")
|
||||
baseType.Fields[" DevIdOverride"] = NewFieldInfo("DevIdOverride", "dev_id_override", "int", "", "non-generated field", true, false, "")
|
||||
case resource.StructName == "WLAN":
|
||||
// this field removed in v6, retaining for backwards compatibility
|
||||
baseType.Fields["WLANGroupID"] = NewFieldInfo("WLANGroupID", "wlangroup_id", "string", "", "", false, false, "")
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func NewFieldInfo(fieldName, jsonName, fieldType, fieldValidation, fieldValidationComment string, omitempty bool, isArray bool, customUnmarshalType string) *FieldInfo {
|
||||
return &FieldInfo{
|
||||
FieldName: fieldName,
|
||||
JSONName: jsonName,
|
||||
FieldType: fieldType,
|
||||
FieldValidation: fieldValidation,
|
||||
FieldValidationComment: fieldValidationComment,
|
||||
OmitEmpty: omitempty,
|
||||
IsArray: isArray,
|
||||
CustomUnmarshalType: customUnmarshalType,
|
||||
}
|
||||
}
|
||||
|
||||
func cleanName(name string, reps []replacement) string {
|
||||
for _, rep := range reps {
|
||||
name = strings.ReplaceAll(name, rep.Old, rep.New)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *Resource) IsSetting() bool {
|
||||
return strings.HasPrefix(r.StructName, "Setting")
|
||||
}
|
||||
|
||||
func (r *Resource) processFields(fields map[string]interface{}) {
|
||||
t := r.Types[r.StructName]
|
||||
for name, validation := range fields {
|
||||
fieldInfo, err := r.fieldInfoFromValidation(name, validation)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Fields[fieldInfo.FieldName] = fieldInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resource) fieldInfoFromValidation(name string, validation interface{}) (*FieldInfo, error) {
|
||||
fieldName := strcase.ToCamel(name)
|
||||
fieldName = cleanName(fieldName, fieldReps)
|
||||
|
||||
empty := &FieldInfo{}
|
||||
var fieldInfo *FieldInfo
|
||||
|
||||
switch validation := validation.(type) {
|
||||
case []interface{}:
|
||||
if len(validation) == 0 {
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "string", "", "", false, true, "")
|
||||
err := r.FieldProcessor(fieldName, fieldInfo)
|
||||
return fieldInfo, err
|
||||
}
|
||||
if len(validation) > 1 {
|
||||
return empty, fmt.Errorf("unknown validation %#v", validation)
|
||||
}
|
||||
|
||||
fieldInfo, err := r.fieldInfoFromValidation(name, validation[0])
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
fieldInfo.OmitEmpty = true
|
||||
fieldInfo.IsArray = true
|
||||
|
||||
err = r.FieldProcessor(fieldName, fieldInfo)
|
||||
return fieldInfo, err
|
||||
|
||||
case map[string]interface{}:
|
||||
typeName := r.StructName + fieldName
|
||||
|
||||
result := NewFieldInfo(fieldName, name, typeName, "", "", true, false, "")
|
||||
result.Fields = make(map[string]*FieldInfo)
|
||||
|
||||
for name, fv := range validation {
|
||||
child, err := r.fieldInfoFromValidation(name, fv)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
result.Fields[child.FieldName] = child
|
||||
}
|
||||
|
||||
err := r.FieldProcessor(fieldName, result)
|
||||
r.Types[typeName] = result
|
||||
return result, err
|
||||
|
||||
case string:
|
||||
fieldValidationComment := validation
|
||||
normalized := normalizeValidation(validation)
|
||||
|
||||
omitEmpty := false
|
||||
|
||||
switch {
|
||||
case normalized == "falsetrue" || normalized == "truefalse":
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "bool", "", "", omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
default:
|
||||
if _, err := strconv.ParseFloat(normalized, 64); err == nil {
|
||||
if normalized == "09" || normalized == "09.09" {
|
||||
fieldValidationComment = ""
|
||||
}
|
||||
|
||||
if strings.Contains(normalized, ".") {
|
||||
if strings.Contains(validation, "\\.){3}") {
|
||||
break
|
||||
}
|
||||
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "float64", "", fieldValidationComment, omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
|
||||
fieldValidation := defineFieldValidation(fieldValidationComment)
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "int", fieldValidation, fieldValidationComment, omitEmpty, false, "")
|
||||
fieldInfo.CustomUnmarshalType = "emptyStringInt"
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
}
|
||||
if validation != "" && normalized != "" {
|
||||
log.Tracef("normalize %q to %q", validation, normalized)
|
||||
}
|
||||
|
||||
fieldValidation := defineFieldValidation(fieldValidationComment)
|
||||
omitEmpty = omitEmpty || (!strings.Contains(validation, "^$") && !strings.HasSuffix(fieldName, "ID"))
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "string", fieldValidation, fieldValidationComment, omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
|
||||
return empty, fmt.Errorf("unable to determine type from validation %q", validation)
|
||||
}
|
||||
|
||||
func (r *Resource) processJSON(b []byte) error {
|
||||
var fields map[string]interface{}
|
||||
err := json.Unmarshal(b, &fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.processFields(fields)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed api.go.tmpl
|
||||
var apiGoTemplate string
|
||||
|
||||
func (r *Resource) generateCode() (string, error) {
|
||||
// generateCodeFromTemplate renders a template with provided content and formats the source.
|
||||
func generateCodeFromTemplate(templateName, templateContent string, toWrite any) (string, error) {
|
||||
var err error
|
||||
var buf bytes.Buffer
|
||||
writer := io.Writer(&buf)
|
||||
|
||||
tpl := template.Must(template.New("api.go.tmpl").Parse(apiGoTemplate))
|
||||
tpl := template.Must(template.New(templateName).Parse(templateContent))
|
||||
|
||||
err = tpl.Execute(writer, r)
|
||||
err = tpl.Execute(writer, toWrite)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render template: %w", err)
|
||||
}
|
||||
@@ -328,193 +41,43 @@ func (r *Resource) generateCode() (string, error) {
|
||||
return string(src), err
|
||||
}
|
||||
|
||||
func normalizeValidation(re string) string {
|
||||
re = strings.ReplaceAll(re, "\\d", "[0-9]")
|
||||
re = strings.ReplaceAll(re, "[-+]?", "")
|
||||
re = strings.ReplaceAll(re, "[+-]?", "")
|
||||
re = strings.ReplaceAll(re, "[-]?", "")
|
||||
re = strings.ReplaceAll(re, "\\.", ".")
|
||||
re = strings.ReplaceAll(re, "[.]?", ".")
|
||||
|
||||
quants := regexp.MustCompile(`\{\d*,?\d*\}|\*|\+|\?`)
|
||||
re = quants.ReplaceAllString(re, "")
|
||||
|
||||
control := regexp.MustCompile(`[\(\[\]\)\|\-\$\^]`)
|
||||
re = control.ReplaceAllString(re, "")
|
||||
|
||||
re = strings.TrimPrefix(re, "^")
|
||||
re = strings.TrimSuffix(re, "$")
|
||||
|
||||
return re
|
||||
}
|
||||
|
||||
// generateCode generates code for each generation source and writes it to file.
|
||||
func generateCode(fieldsDir string, outDir string) error {
|
||||
fieldsFiles, err := os.ReadDir(fieldsDir)
|
||||
generators := make([]Generatable, 0)
|
||||
resources, err := buildResourcesFromDownloadedFields(fieldsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, err)
|
||||
return fmt.Errorf("failed to build resources from downloaded fields: %w", err)
|
||||
}
|
||||
for _, fieldsFile := range fieldsFiles {
|
||||
name := fieldsFile.Name()
|
||||
ext := filepath.Ext(name)
|
||||
|
||||
switch name {
|
||||
case "AuthenticationRequest.json", "Setting.json", "Wall.json":
|
||||
continue
|
||||
}
|
||||
|
||||
if filepath.Ext(name) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("Processing %s...", fieldsFile.Name())
|
||||
name = name[:len(name)-len(ext)]
|
||||
|
||||
urlPath := strings.ToLower(name)
|
||||
structName := cleanName(name, fileReps)
|
||||
|
||||
fieldsFilePath := filepath.Join(fieldsDir, fieldsFile.Name())
|
||||
b, err := os.ReadFile(fieldsFilePath)
|
||||
if err != nil {
|
||||
log.Warnf("skipping file %s: %s", fieldsFile.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
resource := NewResource(structName, urlPath)
|
||||
|
||||
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") {
|
||||
f.FieldName = strings.Replace(f.FieldName, "6E", "SixE", 1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
case "SettingMgmt":
|
||||
sshKeyField := NewFieldInfo(resource.StructName+"XSshKeys", "x_ssh_keys", "struct", "", "", false, false, "")
|
||||
sshKeyField.Fields = map[string]*FieldInfo{
|
||||
"name": NewFieldInfo("Name", "name", "string", "", "", false, false, ""),
|
||||
"keyType": NewFieldInfo("KeyType", "type", "string", "", "", false, false, ""),
|
||||
"key": NewFieldInfo("Key", "key", "string", "", "", false, false, ""),
|
||||
"comment": NewFieldInfo("Comment", "comment", "string", "", "", false, false, ""),
|
||||
"date": NewFieldInfo("Date", "date", "string", "", "", false, false, ""),
|
||||
"fingerprint": NewFieldInfo("Fingerprint", "fingerprint", "string", "", "", false, false, ""),
|
||||
}
|
||||
resource.Types[sshKeyField.FieldName] = sshKeyField
|
||||
|
||||
resource.FieldProcessor = func(name string, f *FieldInfo) error {
|
||||
if name == "XSshKeys" {
|
||||
f.FieldType = sshKeyField.FieldName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case "SettingUsg":
|
||||
resource.FieldProcessor = func(name string, f *FieldInfo) error {
|
||||
if strings.HasSuffix(name, "Timeout") && name != "ArpCacheTimeout" {
|
||||
f.FieldType = "int"
|
||||
f.CustomUnmarshalType = "emptyStringInt"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
err = resource.processJSON(b)
|
||||
if err != nil {
|
||||
log.Warnf("skipping file %s: %s", fieldsFile.Name(), err)
|
||||
continue
|
||||
}
|
||||
client := newClientInfo(resources)
|
||||
for _, resource := range resources {
|
||||
generators = append(generators, resource)
|
||||
}
|
||||
generators = append(generators, client)
|
||||
|
||||
for _, g := range generators {
|
||||
var code string
|
||||
if code, err = resource.generateCode(); err != nil {
|
||||
log.Errorf("failed to generate code for %s: %s", fieldsFile.Name(), err)
|
||||
if code, err = g.GenerateCode(); err != nil {
|
||||
log.Errorf("failed to generate code for %s: %s", g.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
goFile := strcase.ToSnake(structName) + ".generated.go"
|
||||
goFilePath := filepath.Join(outDir, goFile)
|
||||
_ = os.Remove(goFilePath)
|
||||
if err := os.WriteFile(goFilePath, ([]byte)(code), 0o644); err != nil {
|
||||
log.Errorf("failed to write file %s: %s", goFile, err)
|
||||
filename, err := writeGeneratedFile(outDir, g.Name(), code)
|
||||
if err != nil {
|
||||
log.Errorf("failed to write file %s: %s", g.Name(), err)
|
||||
continue
|
||||
}
|
||||
log.Debugf("Generated %s with resource %s\n\n", goFile, structName)
|
||||
log.Debugf("Generated %s with resource %s\n\n", filename, g.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeGeneratedFile writes generated file content to a file.
|
||||
func writeGeneratedFile(outDir string, name string, content string) (string, error) {
|
||||
goFile := strcase.ToSnake(name) + ".generated.go"
|
||||
goFilePath := filepath.Join(outDir, goFile)
|
||||
_ = os.Remove(goFilePath)
|
||||
if err := os.WriteFile(goFilePath, []byte(content), 0o644); err != nil {
|
||||
return goFile, fmt.Errorf("failed to write file %s: %w", goFile, err)
|
||||
}
|
||||
return goFile, nil
|
||||
}
|
||||
|
||||
@@ -1,168 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFieldInfoFromValidation(t *testing.T) {
|
||||
func TestGenerateCodeFromTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for i, c := range []struct {
|
||||
expectedType string
|
||||
expectedComment string
|
||||
expectedOmitEmpty bool
|
||||
validation interface{}
|
||||
tests := []struct {
|
||||
name string
|
||||
templateName string
|
||||
template string
|
||||
data interface{}
|
||||
expectedCode string
|
||||
expectedError bool
|
||||
errorContains string
|
||||
}{
|
||||
{"string", "", true, ""},
|
||||
{"string", "default|custom", true, "default|custom"},
|
||||
{"string", ".{0,32}", true, ".{0,32}"},
|
||||
{"string", "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$", false, "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$"},
|
||||
{
|
||||
name: "valid template",
|
||||
templateName: "simple",
|
||||
template: `package main
|
||||
|
||||
{"int", "^([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$", true, "^([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$"},
|
||||
{"int", "", true, "^[0-9]*$"},
|
||||
const greeting = "{{.Greeting}}"`,
|
||||
data: struct{ Greeting string }{Greeting: "hello"},
|
||||
expectedCode: "const greeting = \"hello\"",
|
||||
},
|
||||
{
|
||||
name: "invalid go code output",
|
||||
templateName: "invalid_code",
|
||||
template: `not valid {{ .Value }} go code`,
|
||||
data: struct{ Value string }{Value: "test"},
|
||||
expectedError: true,
|
||||
errorContains: "failed to format source",
|
||||
},
|
||||
{
|
||||
name: "no data",
|
||||
templateName: "nil_data",
|
||||
template: `package main`,
|
||||
data: nil,
|
||||
expectedCode: "package main",
|
||||
},
|
||||
{
|
||||
name: "complex template",
|
||||
templateName: "complex",
|
||||
template: `package main
|
||||
|
||||
{"float64", "", true, "[-+]?[0-9]*\\.?[0-9]+"},
|
||||
// this one is really an error as the . is not escaped
|
||||
{"float64", "", true, "^([-]?[\\d]+[.]?[\\d]*)$"},
|
||||
{"float64", "", true, "^([\\d]+[.]?[\\d]*)$"},
|
||||
type {{.TypeName}} struct {
|
||||
{{range .Fields}}
|
||||
{{.Name}} {{.Type}}
|
||||
{{end}}
|
||||
}`,
|
||||
data: struct {
|
||||
TypeName string
|
||||
Fields []struct{ Name, Type string }
|
||||
}{
|
||||
TypeName: "Person",
|
||||
Fields: []struct{ Name, Type string }{
|
||||
{Name: "Name", Type: "string"},
|
||||
{Name: "Age", Type: "int"},
|
||||
},
|
||||
},
|
||||
expectedCode: "type Person struct",
|
||||
},
|
||||
}
|
||||
|
||||
{"bool", "", false, "false|true"},
|
||||
{"bool", "", false, "true|false"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%d %s %s", i, c.expectedType, c.validation), func(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
code, err := generateCodeFromTemplate(tt.templateName, tt.template, tt.data)
|
||||
|
||||
resource := &Resource{
|
||||
StructName: "TestType",
|
||||
Types: make(map[string]*FieldInfo),
|
||||
FieldProcessor: func(name string, f *FieldInfo) error { return nil },
|
||||
}
|
||||
|
||||
fieldInfo, err := resource.fieldInfoFromValidation("fieldName", c.validation)
|
||||
// actualType, actualComment, actualOmitEmpty, err := fieldInfoFromValidation(c.validation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fieldInfo.FieldType != c.expectedType {
|
||||
t.Fatalf("expected type %q got %q", c.expectedType, fieldInfo.FieldType)
|
||||
}
|
||||
if fieldInfo.FieldValidationComment != c.expectedComment {
|
||||
t.Fatalf("expected comment %q got %q", c.expectedComment, fieldInfo.FieldValidationComment)
|
||||
}
|
||||
if fieldInfo.OmitEmpty != c.expectedOmitEmpty {
|
||||
t.Fatalf("expected omitempty %t got %t", c.expectedOmitEmpty, fieldInfo.OmitEmpty)
|
||||
if tt.expectedError {
|
||||
require.ErrorContains(t, err, tt.errorContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
a.Contains(code, tt.expectedCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceTypes(t *testing.T) {
|
||||
func TestWriteGeneratedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
content string
|
||||
expectedFileName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid file",
|
||||
fileName: "TestFile",
|
||||
content: "package main\n\n// Code content",
|
||||
expectedFileName: "test_file.generated.go",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
fileName: "EmptyFile",
|
||||
content: "",
|
||||
expectedFileName: "empty_file.generated.go",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "file with spaces",
|
||||
fileName: "Test File",
|
||||
content: "package main",
|
||||
expectedFileName: "test_file.generated.go",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
testData := `
|
||||
{
|
||||
"note": ".{0,1024}",
|
||||
"date": "^$|^(20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])Z?$",
|
||||
"mac": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
|
||||
"number": "\\d+",
|
||||
"boolean": "true|false",
|
||||
"nested_type": {
|
||||
"nested_field": "^$"
|
||||
},
|
||||
"nested_type_array": [{
|
||||
"nested_field": "^$"
|
||||
}]
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
fileName, err := writeGeneratedFile(tempDir, tt.fileName, tt.content)
|
||||
require.NoError(t, err)
|
||||
a.Equal(tt.expectedFileName, fileName)
|
||||
|
||||
expectedFile := filepath.Join(tempDir, tt.expectedFileName)
|
||||
dataBytes, err := os.ReadFile(expectedFile)
|
||||
require.NoError(t, err)
|
||||
a.Equal(tt.content, string(dataBytes))
|
||||
})
|
||||
}
|
||||
}
|
||||
`
|
||||
expectedFields := map[string]*FieldInfo{
|
||||
"Note": NewFieldInfo("Note", "note", "string", "validate:\"omitempty,gte=0,lte=1024\"", ".{0,1024}", true, false, ""),
|
||||
"Date": NewFieldInfo("Date", "date", "string", "", "^$|^(20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])Z?$", false, false, ""),
|
||||
"MAC": NewFieldInfo("MAC", "mac", "string", "validate:\"omitempty,mac\"", "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$", true, false, ""),
|
||||
"Number": NewFieldInfo("Number", "number", "int", "", "", true, false, "emptyStringInt"),
|
||||
"Boolean": NewFieldInfo("Boolean", "boolean", "bool", "", "", false, false, ""),
|
||||
"NestedType": {
|
||||
FieldName: "NestedType",
|
||||
JSONName: "nested_type",
|
||||
FieldType: "StructNestedType",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: true,
|
||||
IsArray: false,
|
||||
Fields: map[string]*FieldInfo{
|
||||
"NestedFieldModified": NewFieldInfo("NestedFieldModified", "nested_field", "string", "", "^$", false, false, ""),
|
||||
},
|
||||
|
||||
func TestWriteGeneratedFile_OverrideExistingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
tempDir := t.TempDir()
|
||||
fileName := "test"
|
||||
|
||||
_, err := writeGeneratedFile(tempDir, fileName, "starting content")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writeGeneratedFile(tempDir, fileName, "updated content")
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedFile := filepath.Join(tempDir, "test.generated.go")
|
||||
dataBytes, err := os.ReadFile(expectedFile)
|
||||
require.NoError(t, err)
|
||||
a.Equal("updated content", string(dataBytes))
|
||||
}
|
||||
|
||||
func TestWriteGeneratedFile_InvalidPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
invalidDir := filepath.Join(tempDir, "nonexistent")
|
||||
|
||||
_, err := writeGeneratedFile(invalidDir, "test", "content")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "failed to write file")
|
||||
}
|
||||
|
||||
func TestGenerateCodeFromFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
fieldsDir string
|
||||
outDir string
|
||||
expectedError bool
|
||||
errorContains string
|
||||
setupMockFiles func(string)
|
||||
}{
|
||||
{
|
||||
name: "invalid fields directory",
|
||||
fieldsDir: "nonexistent",
|
||||
outDir: t.TempDir(),
|
||||
expectedError: true,
|
||||
errorContains: "failed to build resources from downloaded fields",
|
||||
},
|
||||
"NestedTypeArray": {
|
||||
FieldName: "NestedTypeArray",
|
||||
JSONName: "nested_type_array",
|
||||
FieldType: "StructNestedTypeArray",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: true,
|
||||
IsArray: true,
|
||||
Fields: map[string]*FieldInfo{
|
||||
"NestedFieldModified": NewFieldInfo("NestedFieldModified", "nested_field", "string", "", "^$", false, false, ""),
|
||||
{
|
||||
name: "valid empty fields directory",
|
||||
fieldsDir: t.TempDir(),
|
||||
outDir: t.TempDir(),
|
||||
setupMockFiles: func(dir string) {
|
||||
// Create empty directory structure
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
}
|
||||
|
||||
expectedStruct := map[string]*FieldInfo{
|
||||
"Struct": {
|
||||
FieldName: "Struct",
|
||||
JSONName: "path",
|
||||
FieldType: "struct",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: false,
|
||||
IsArray: false,
|
||||
Fields: map[string]*FieldInfo{
|
||||
" ID": NewFieldInfo("ID", "_id", "string", "", "", true, false, ""),
|
||||
" SiteID": NewFieldInfo("SiteID", "site_id", "string", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
" Hidden": NewFieldInfo("Hidden", "attr_hidden", "bool", "", "", true, false, ""),
|
||||
" HiddenID": NewFieldInfo("HiddenID", "attr_hidden_id", "string", "", "", true, false, ""),
|
||||
" NoDelete": NewFieldInfo("NoDelete", "attr_no_delete", "bool", "", "", true, false, ""),
|
||||
" NoEdit": NewFieldInfo("NoEdit", "attr_no_edit", "bool", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
" _Spacer": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range expectedFields {
|
||||
expectedStruct["Struct"].Fields[k] = v
|
||||
}
|
||||
|
||||
expectation := &Resource{
|
||||
StructName: "Struct",
|
||||
ResourcePath: "path",
|
||||
|
||||
Types: map[string]*FieldInfo{
|
||||
"Struct": expectedStruct["Struct"],
|
||||
"StructNestedType": expectedStruct["Struct"].Fields["NestedType"],
|
||||
"StructNestedTypeArray": expectedStruct["Struct"].Fields["NestedTypeArray"],
|
||||
},
|
||||
|
||||
FieldProcessor: func(name string, f *FieldInfo) error {
|
||||
if name == "NestedField" {
|
||||
f.FieldName = "NestedFieldModified"
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.setupMockFiles != nil {
|
||||
tt.setupMockFiles(tt.fieldsDir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
err := generateCode(tt.fieldsDir, tt.outDir)
|
||||
|
||||
if tt.expectedError {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.errorContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("structural test", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resource := NewResource("Struct", "path")
|
||||
resource.FieldProcessor = expectation.FieldProcessor
|
||||
|
||||
err := resource.processJSON(([]byte)(testData))
|
||||
|
||||
require.NoError(t, err, "No error processing JSON")
|
||||
assert.Equal(t, expectation.StructName, resource.StructName)
|
||||
assert.Equal(t, expectation.ResourcePath, resource.ResourcePath)
|
||||
assert.Equal(t, expectation.Types, resource.Types)
|
||||
})
|
||||
}
|
||||
|
||||
512
codegen/resources.go
Normal file
512
codegen/resources.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type replacement struct {
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
var fieldReps = []replacement{
|
||||
{"Dhcpdv6", "DHCPDV6"},
|
||||
|
||||
{"Dhcpd", "DHCPD"},
|
||||
{"Idx", "IDX"},
|
||||
{"Ipsec", "IPSec"},
|
||||
{"Ipv6", "IPV6"},
|
||||
{"Openvpn", "OpenVPN"},
|
||||
{"Tftp", "TFTP"},
|
||||
{"Wlangroup", "WLANGroup"},
|
||||
|
||||
{"Bc", "Broadcast"},
|
||||
{"Dhcp", "DHCP"},
|
||||
{"Dns", "DNS"},
|
||||
{"Dpi", "DPI"},
|
||||
{"Dtim", "DTIM"},
|
||||
{"Firewallgroup", "FirewallGroup"},
|
||||
{"Fixedip", "FixedIP"},
|
||||
{"Icmp", "ICMP"},
|
||||
{"Id", "ID"},
|
||||
{"Igmp", "IGMP"},
|
||||
{"Ip", "IP"},
|
||||
{"Leasetime", "LeaseTime"},
|
||||
{"Mac", "MAC"},
|
||||
{"Mcastenhance", "MulticastEnhance"},
|
||||
{"Minrssi", "MinRSSI"},
|
||||
{"Monthdays", "MonthDays"},
|
||||
{"Nat", "NAT"},
|
||||
{"Networkconf", "Network"},
|
||||
{"Networkgroup", "NetworkGroup"},
|
||||
{"Pd", "PD"},
|
||||
{"Pmf", "PMF"},
|
||||
{"Portconf", "PortProfile"},
|
||||
{"Qos", "QOS"},
|
||||
{"Radiusprofile", "RADIUSProfile"},
|
||||
{"Radius", "RADIUS"},
|
||||
{"Ssid", "SSID"},
|
||||
{"Startdate", "StartDate"},
|
||||
{"Starttime", "StartTime"},
|
||||
{"Stopdate", "StopDate"},
|
||||
{"Stoptime", "StopTime"},
|
||||
{"Tcp", "TCP"},
|
||||
{"Udp", "UDP"},
|
||||
{"Usergroup", "UserGroup"},
|
||||
{"Utc", "UTC"},
|
||||
{"Vlan", "VLAN"},
|
||||
{"Vpn", "VPN"},
|
||||
{"Wan", "WAN"},
|
||||
{"Wep", "WEP"},
|
||||
{"Wlan", "WLAN"},
|
||||
{"Wpa", "WPA"},
|
||||
}
|
||||
|
||||
var fileReps = []replacement{
|
||||
{"WlanConf", "WLAN"},
|
||||
{"Dhcp", "DHCP"},
|
||||
{"Wlan", "WLAN"},
|
||||
{"NetworkConf", "Network"},
|
||||
{"PortConf", "PortProfile"},
|
||||
{"RadiusProfile", "RADIUSProfile"},
|
||||
{"ApGroups", "APGroup"},
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
StructName string
|
||||
ResourcePath string
|
||||
Types map[string]*FieldInfo
|
||||
FieldProcessor func(name string, f *FieldInfo) error
|
||||
}
|
||||
|
||||
func (r *Resource) BaseType() *FieldInfo {
|
||||
return r.Types[r.StructName]
|
||||
}
|
||||
|
||||
type FieldInfo struct {
|
||||
FieldName string
|
||||
JSONName string
|
||||
FieldType string
|
||||
FieldValidation string
|
||||
FieldValidationComment string
|
||||
OmitEmpty bool
|
||||
IsArray bool
|
||||
Fields map[string]*FieldInfo
|
||||
CustomUnmarshalType string
|
||||
CustomUnmarshalFunc string
|
||||
}
|
||||
|
||||
func NewResource(structName string, resourcePath string) *Resource {
|
||||
baseType := NewFieldInfo(structName, resourcePath, "struct", "", "", false, false, "")
|
||||
resource := &Resource{
|
||||
StructName: structName,
|
||||
ResourcePath: resourcePath,
|
||||
Types: map[string]*FieldInfo{
|
||||
structName: baseType,
|
||||
},
|
||||
FieldProcessor: func(name string, f *FieldInfo) error { return nil },
|
||||
}
|
||||
|
||||
// Since template files iterate through map keys in sorted order, these initial fields
|
||||
// are named such that they stay at the top for consistency. The spacer items create a
|
||||
// blank line in the resulting generated file.
|
||||
//
|
||||
// This hack is here for stability of the generated code, but can be removed if desired.
|
||||
baseType.Fields = map[string]*FieldInfo{
|
||||
" ID": NewFieldInfo("ID", "_id", "string", "", "", true, false, ""),
|
||||
" SiteID": NewFieldInfo("SiteID", "site_id", "string", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
|
||||
" Hidden": NewFieldInfo("Hidden", "attr_hidden", "bool", "", "", true, false, ""),
|
||||
" HiddenID": NewFieldInfo("HiddenID", "attr_hidden_id", "string", "", "", true, false, ""),
|
||||
" NoDelete": NewFieldInfo("NoDelete", "attr_no_delete", "bool", "", "", true, false, ""),
|
||||
" NoEdit": NewFieldInfo("NoEdit", "attr_no_edit", "bool", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
|
||||
" _Spacer": nil,
|
||||
}
|
||||
|
||||
if resource.IsSetting() {
|
||||
resource.ResourcePath = strcase.ToSnake(strings.TrimPrefix(structName, "Setting"))
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
func NewFieldInfo(fieldName, jsonName, fieldType, fieldValidation, fieldValidationComment string, omitempty bool, isArray bool, customUnmarshalType string) *FieldInfo {
|
||||
return &FieldInfo{
|
||||
FieldName: fieldName,
|
||||
JSONName: jsonName,
|
||||
FieldType: fieldType,
|
||||
FieldValidation: fieldValidation,
|
||||
FieldValidationComment: fieldValidationComment,
|
||||
OmitEmpty: omitempty,
|
||||
IsArray: isArray,
|
||||
CustomUnmarshalType: customUnmarshalType,
|
||||
}
|
||||
}
|
||||
|
||||
func cleanName(name string, reps []replacement) string {
|
||||
for _, rep := range reps {
|
||||
name = strings.ReplaceAll(name, rep.Old, rep.New)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *Resource) IsSetting() bool {
|
||||
return strings.HasPrefix(r.StructName, "Setting")
|
||||
}
|
||||
|
||||
func (r *Resource) Name() string {
|
||||
return r.StructName
|
||||
}
|
||||
|
||||
func (r *Resource) processFields(fields map[string]interface{}) {
|
||||
t := r.Types[r.StructName]
|
||||
for name, validation := range fields {
|
||||
fieldInfo, err := r.fieldInfoFromValidation(name, validation)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Fields[fieldInfo.FieldName] = fieldInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resource) fieldInfoFromValidation(name string, validation interface{}) (*FieldInfo, error) {
|
||||
fieldName := strcase.ToCamel(name)
|
||||
fieldName = cleanName(fieldName, fieldReps)
|
||||
|
||||
empty := &FieldInfo{}
|
||||
var fieldInfo *FieldInfo
|
||||
|
||||
switch validation := validation.(type) {
|
||||
case []interface{}:
|
||||
if len(validation) == 0 {
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "string", "", "", false, true, "")
|
||||
err := r.FieldProcessor(fieldName, fieldInfo)
|
||||
return fieldInfo, err
|
||||
}
|
||||
if len(validation) > 1 {
|
||||
return empty, fmt.Errorf("unknown validation %#v", validation)
|
||||
}
|
||||
|
||||
fieldInfo, err := r.fieldInfoFromValidation(name, validation[0])
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
fieldInfo.OmitEmpty = true
|
||||
fieldInfo.IsArray = true
|
||||
|
||||
err = r.FieldProcessor(fieldName, fieldInfo)
|
||||
return fieldInfo, err
|
||||
|
||||
case map[string]interface{}:
|
||||
typeName := r.StructName + fieldName
|
||||
|
||||
result := NewFieldInfo(fieldName, name, typeName, "", "", true, false, "")
|
||||
result.Fields = make(map[string]*FieldInfo)
|
||||
|
||||
for name, fv := range validation {
|
||||
child, err := r.fieldInfoFromValidation(name, fv)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
result.Fields[child.FieldName] = child
|
||||
}
|
||||
|
||||
err := r.FieldProcessor(fieldName, result)
|
||||
r.Types[typeName] = result
|
||||
return result, err
|
||||
|
||||
case string:
|
||||
fieldValidationComment := validation
|
||||
normalized := normalizeValidation(validation)
|
||||
|
||||
omitEmpty := false
|
||||
|
||||
switch {
|
||||
case normalized == "falsetrue" || normalized == "truefalse":
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "bool", "", "", omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
default:
|
||||
if _, err := strconv.ParseFloat(normalized, 64); err == nil {
|
||||
if normalized == "09" || normalized == "09.09" {
|
||||
fieldValidationComment = ""
|
||||
}
|
||||
|
||||
if strings.Contains(normalized, ".") {
|
||||
if strings.Contains(validation, "\\.){3}") {
|
||||
break
|
||||
}
|
||||
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "float64", "", fieldValidationComment, omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
|
||||
fieldValidation := defineFieldValidation(fieldValidationComment)
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "int", fieldValidation, fieldValidationComment, omitEmpty, false, "")
|
||||
fieldInfo.CustomUnmarshalType = "emptyStringInt"
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
}
|
||||
if validation != "" && normalized != "" {
|
||||
log.Tracef("normalize %q to %q", validation, normalized)
|
||||
}
|
||||
|
||||
fieldValidation := defineFieldValidation(fieldValidationComment)
|
||||
omitEmpty = omitEmpty || (!strings.Contains(validation, "^$") && !strings.HasSuffix(fieldName, "ID"))
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "string", fieldValidation, fieldValidationComment, omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
|
||||
return empty, fmt.Errorf("unable to determine type from validation %q", validation)
|
||||
}
|
||||
|
||||
func (r *Resource) processJSON(b []byte) error {
|
||||
var fields map[string]interface{}
|
||||
err := json.Unmarshal(b, &fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.processFields(fields)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed api.go.tmpl
|
||||
var apiGoTemplate string
|
||||
|
||||
func (r *Resource) GenerateCode() (string, error) {
|
||||
return generateCodeFromTemplate("api.go.tmpl", apiGoTemplate, r)
|
||||
}
|
||||
|
||||
func normalizeValidation(re string) string {
|
||||
re = strings.ReplaceAll(re, "\\d", "[0-9]")
|
||||
re = strings.ReplaceAll(re, "[-+]?", "")
|
||||
re = strings.ReplaceAll(re, "[+-]?", "")
|
||||
re = strings.ReplaceAll(re, "[-]?", "")
|
||||
re = strings.ReplaceAll(re, "\\.", ".")
|
||||
re = strings.ReplaceAll(re, "[.]?", ".")
|
||||
|
||||
quants := regexp.MustCompile(`\{\d*,?\d*\}|\*|\+|\?`)
|
||||
re = quants.ReplaceAllString(re, "")
|
||||
|
||||
control := regexp.MustCompile(`[\(\[\]\)\|\-\$\^]`)
|
||||
re = control.ReplaceAllString(re, "")
|
||||
|
||||
re = strings.TrimPrefix(re, "^")
|
||||
re = strings.TrimSuffix(re, "$")
|
||||
|
||||
return re
|
||||
}
|
||||
|
||||
var skippable = []string{"AuthenticationRequest.json", "Setting.json", "Wall.json"}
|
||||
|
||||
func buildResourcesFromDownloadedFields(fieldsDir string) ([]*Resource, error) {
|
||||
fieldsFiles, err := os.ReadDir(fieldsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, err)
|
||||
}
|
||||
|
||||
resources := make([]*Resource, 0)
|
||||
|
||||
for _, fieldsFile := range fieldsFiles {
|
||||
name := fieldsFile.Name()
|
||||
ext := filepath.Ext(name)
|
||||
|
||||
if slices.Contains(skippable, name) || ext != ".json" {
|
||||
continue
|
||||
}
|
||||
log.Debugf("Processing %s...", fieldsFile.Name())
|
||||
name = name[:len(name)-len(ext)]
|
||||
|
||||
urlPath := strings.ToLower(name)
|
||||
structName := cleanName(name, fileReps)
|
||||
|
||||
fieldsFilePath := filepath.Join(fieldsDir, fieldsFile.Name())
|
||||
b, err := os.ReadFile(fieldsFilePath)
|
||||
if err != nil {
|
||||
log.Warnf("skipping file %s: %s", fieldsFile.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
resource := NewResource(structName, urlPath)
|
||||
customizeResource(resource)
|
||||
|
||||
err = resource.processJSON(b)
|
||||
if err != nil {
|
||||
log.Warnf("skipping file %s: %s", fieldsFile.Name(), err)
|
||||
continue
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func customizeBaseType(resource *Resource) {
|
||||
baseType := resource.BaseType()
|
||||
if resource.IsSetting() {
|
||||
baseType.Fields[" Key"] = NewFieldInfo("Key", "key", "string", "", "", false, false, "")
|
||||
|
||||
if resource.StructName == "SettingUsg" {
|
||||
// Removed in v7, retaining for backwards compatibility
|
||||
baseType.Fields["MdnsEnabled"] = NewFieldInfo("MdnsEnabled", "mdns_enabled", "bool", "", "", false, false, "")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case resource.IsSetting():
|
||||
baseType.Fields[" Key"] = NewFieldInfo("Key", "key", "string", "", "", false, false, "")
|
||||
|
||||
if resource.StructName == "SettingUsg" {
|
||||
// Removed in v7, retaining for backwards compatibility
|
||||
baseType.Fields["MdnsEnabled"] = NewFieldInfo("MdnsEnabled", "mdns_enabled", "bool", "", "", false, false, "")
|
||||
}
|
||||
case resource.StructName == "Device":
|
||||
baseType.Fields[" MAC"] = NewFieldInfo("MAC", "mac", "string", createValidations(validation{v: mac}), "", true, false, "")
|
||||
baseType.Fields["Adopted"] = NewFieldInfo("Adopted", "adopted", "bool", "", "", false, false, "")
|
||||
baseType.Fields["Model"] = NewFieldInfo("Model", "model", "string", "", "", true, false, "")
|
||||
baseType.Fields["State"] = NewFieldInfo("State", "state", "DeviceState", "", "", false, false, "")
|
||||
baseType.Fields["Type"] = NewFieldInfo("Type", "type", "string", "", "", true, false, "")
|
||||
case resource.StructName == "User":
|
||||
baseType.Fields[" IP"] = NewFieldInfo("IP", "ip", "string", createValidations(validation{v: ip}), "non-generated field", true, false, "")
|
||||
baseType.Fields[" DevIdOverride"] = NewFieldInfo("DevIdOverride", "dev_id_override", "int", "", "non-generated field", true, false, "")
|
||||
case resource.StructName == "WLAN":
|
||||
// this field removed in v6, retaining for backwards compatibility
|
||||
baseType.Fields["WLANGroupID"] = NewFieldInfo("WLANGroupID", "wlangroup_id", "string", "", "", false, false, "")
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
f.FieldName = strings.Replace(f.FieldName, "6E", "SixE", 1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
case "SettingMgmt":
|
||||
sshKeyField := NewFieldInfo(resource.StructName+"XSshKeys", "x_ssh_keys", "struct", "", "", false, false, "")
|
||||
sshKeyField.Fields = map[string]*FieldInfo{
|
||||
"name": NewFieldInfo("Name", "name", "string", "", "", false, false, ""),
|
||||
"keyType": NewFieldInfo("KeyType", "type", "string", "", "", false, false, ""),
|
||||
"key": NewFieldInfo("Key", "key", "string", "", "", false, false, ""),
|
||||
"comment": NewFieldInfo("Comment", "comment", "string", "", "", false, false, ""),
|
||||
"date": NewFieldInfo("Date", "date", "string", "", "", false, false, ""),
|
||||
"fingerprint": NewFieldInfo("Fingerprint", "fingerprint", "string", "", "", false, false, ""),
|
||||
}
|
||||
resource.Types[sshKeyField.FieldName] = sshKeyField
|
||||
|
||||
resource.FieldProcessor = func(name string, f *FieldInfo) error {
|
||||
if name == "XSshKeys" {
|
||||
f.FieldType = sshKeyField.FieldName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case "SettingUsg":
|
||||
resource.FieldProcessor = func(name string, f *FieldInfo) error {
|
||||
if strings.HasSuffix(name, "Timeout") && name != "ArpCacheTimeout" {
|
||||
f.FieldType = "int"
|
||||
f.CustomUnmarshalType = "emptyStringInt"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
371
codegen/resources_test.go
Normal file
371
codegen/resources_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFieldInfoFromValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for i, c := range []struct {
|
||||
expectedType string
|
||||
expectedComment string
|
||||
expectedOmitEmpty bool
|
||||
validation interface{}
|
||||
}{
|
||||
{"string", "", true, ""},
|
||||
{"string", "default|custom", true, "default|custom"},
|
||||
{"string", ".{0,32}", true, ".{0,32}"},
|
||||
{"string", "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$", false, "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$"},
|
||||
|
||||
{"int", "^([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$", true, "^([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^$"},
|
||||
{"int", "", true, "^[0-9]*$"},
|
||||
|
||||
{"float64", "", true, "[-+]?[0-9]*\\.?[0-9]+"},
|
||||
// this one is really an error as the . is not escaped
|
||||
{"float64", "", true, "^([-]?[\\d]+[.]?[\\d]*)$"},
|
||||
{"float64", "", true, "^([\\d]+[.]?[\\d]*)$"},
|
||||
|
||||
{"bool", "", false, "false|true"},
|
||||
{"bool", "", false, "true|false"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%d %s %s", i, c.expectedType, c.validation), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resource := &Resource{
|
||||
StructName: "TestType",
|
||||
Types: make(map[string]*FieldInfo),
|
||||
FieldProcessor: func(name string, f *FieldInfo) error { return nil },
|
||||
}
|
||||
|
||||
fieldInfo, err := resource.fieldInfoFromValidation("fieldName", c.validation)
|
||||
// actualType, actualComment, actualOmitEmpty, err := fieldInfoFromValidation(c.validation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fieldInfo.FieldType != c.expectedType {
|
||||
t.Fatalf("expected type %q got %q", c.expectedType, fieldInfo.FieldType)
|
||||
}
|
||||
if fieldInfo.FieldValidationComment != c.expectedComment {
|
||||
t.Fatalf("expected comment %q got %q", c.expectedComment, fieldInfo.FieldValidationComment)
|
||||
}
|
||||
if fieldInfo.OmitEmpty != c.expectedOmitEmpty {
|
||||
t.Fatalf("expected omitempty %t got %t", c.expectedOmitEmpty, fieldInfo.OmitEmpty)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testData := `
|
||||
{
|
||||
"note": ".{0,1024}",
|
||||
"date": "^$|^(20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])Z?$",
|
||||
"mac": "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$",
|
||||
"number": "\\d+",
|
||||
"boolean": "true|false",
|
||||
"nested_type": {
|
||||
"nested_field": "^$"
|
||||
},
|
||||
"nested_type_array": [{
|
||||
"nested_field": "^$"
|
||||
}]
|
||||
}
|
||||
`
|
||||
expectedFields := map[string]*FieldInfo{
|
||||
"Note": NewFieldInfo("Note", "note", "string", "validate:\"omitempty,gte=0,lte=1024\"", ".{0,1024}", true, false, ""),
|
||||
"Date": NewFieldInfo("Date", "date", "string", "", "^$|^(20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9])Z?$", false, false, ""),
|
||||
"MAC": NewFieldInfo("MAC", "mac", "string", "validate:\"omitempty,mac\"", "^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$", true, false, ""),
|
||||
"Number": NewFieldInfo("Number", "number", "int", "", "", true, false, "emptyStringInt"),
|
||||
"Boolean": NewFieldInfo("Boolean", "boolean", "bool", "", "", false, false, ""),
|
||||
"NestedType": {
|
||||
FieldName: "NestedType",
|
||||
JSONName: "nested_type",
|
||||
FieldType: "StructNestedType",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: true,
|
||||
IsArray: false,
|
||||
Fields: map[string]*FieldInfo{
|
||||
"NestedFieldModified": NewFieldInfo("NestedFieldModified", "nested_field", "string", "", "^$", false, false, ""),
|
||||
},
|
||||
},
|
||||
"NestedTypeArray": {
|
||||
FieldName: "NestedTypeArray",
|
||||
JSONName: "nested_type_array",
|
||||
FieldType: "StructNestedTypeArray",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: true,
|
||||
IsArray: true,
|
||||
Fields: map[string]*FieldInfo{
|
||||
"NestedFieldModified": NewFieldInfo("NestedFieldModified", "nested_field", "string", "", "^$", false, false, ""),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expectedStruct := map[string]*FieldInfo{
|
||||
"Struct": {
|
||||
FieldName: "Struct",
|
||||
JSONName: "path",
|
||||
FieldType: "struct",
|
||||
FieldValidationComment: "",
|
||||
OmitEmpty: false,
|
||||
IsArray: false,
|
||||
Fields: map[string]*FieldInfo{
|
||||
" ID": NewFieldInfo("ID", "_id", "string", "", "", true, false, ""),
|
||||
" SiteID": NewFieldInfo("SiteID", "site_id", "string", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
" Hidden": NewFieldInfo("Hidden", "attr_hidden", "bool", "", "", true, false, ""),
|
||||
" HiddenID": NewFieldInfo("HiddenID", "attr_hidden_id", "string", "", "", true, false, ""),
|
||||
" NoDelete": NewFieldInfo("NoDelete", "attr_no_delete", "bool", "", "", true, false, ""),
|
||||
" NoEdit": NewFieldInfo("NoEdit", "attr_no_edit", "bool", "", "", true, false, ""),
|
||||
" _Spacer": nil,
|
||||
" _Spacer": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range expectedFields {
|
||||
expectedStruct["Struct"].Fields[k] = v
|
||||
}
|
||||
|
||||
expectation := &Resource{
|
||||
StructName: "Struct",
|
||||
ResourcePath: "path",
|
||||
|
||||
Types: map[string]*FieldInfo{
|
||||
"Struct": expectedStruct["Struct"],
|
||||
"StructNestedType": expectedStruct["Struct"].Fields["NestedType"],
|
||||
"StructNestedTypeArray": expectedStruct["Struct"].Fields["NestedTypeArray"],
|
||||
},
|
||||
|
||||
FieldProcessor: func(name string, f *FieldInfo) error {
|
||||
if name == "NestedField" {
|
||||
f.FieldName = "NestedFieldModified"
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("structural test", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resource := NewResource("Struct", "path")
|
||||
resource.FieldProcessor = expectation.FieldProcessor
|
||||
|
||||
err := resource.processJSON(([]byte)(testData))
|
||||
|
||||
require.NoError(t, err, "No error processing JSON")
|
||||
assert.Equal(t, expectation.StructName, resource.StructName)
|
||||
assert.Equal(t, expectation.ResourcePath, resource.ResourcePath)
|
||||
assert.Equal(t, expectation.Types, resource.Types)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"\\d+", "09"},
|
||||
{"[-+]?[0-9]*\\.?[0-9]+", "09.09"},
|
||||
{"^([0-9]|[1-9][0-9]|25[0-5])$", "0919092505"},
|
||||
{"^(([0-9]\\.[0-9]{2})\\.){3}([0-9]\\.[0-9])$", "09.09.09.09"},
|
||||
{"[+-]?[0-9]*\\.?[0-9]+", "09.09"},
|
||||
{"[-]?[\\d]+[.]?[\\d]*", "09.09"},
|
||||
{"^$|^(20[0-9]{2}T([01][0-9]):[1-5]:[0-9])Z?$", "2009T0109:15:09Z"},
|
||||
{"false|true", "falsetrue"},
|
||||
{"true|false", "truefalse"},
|
||||
{".{0,32}", "."},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual := normalizeValidation(tc.input)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var testReps = []replacement{
|
||||
{"dhcpd", "DHCPD"},
|
||||
{"ip", "IP"},
|
||||
}
|
||||
|
||||
func TestCleanName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
reps []replacement
|
||||
expected string
|
||||
}{
|
||||
{"field replacements basic", "dhcpd_enabled", testReps, "DHCPD_enabled"},
|
||||
{"field replacements multiple", "dhcpd_ip_mac", testReps, "DHCPD_IP_mac"},
|
||||
{"field replacements no match", "something_else", testReps, "something_else"},
|
||||
{"empty string", "", fieldReps, ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
actual := cleanName(tc.input, tc.reps)
|
||||
a.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
structName string
|
||||
expected bool
|
||||
}{
|
||||
{"Setting", true},
|
||||
{"SettingUsg", true},
|
||||
{"SettingGlobalAp", true},
|
||||
{"Settings", true},
|
||||
{"Device", false},
|
||||
{"Network", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.structName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resource := &Resource{StructName: tc.structName}
|
||||
assert.Equal(t, tc.expected, resource.IsSetting())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldInfoFromValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fieldName string
|
||||
validation interface{}
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
"invalid validation type",
|
||||
"field",
|
||||
123,
|
||||
"unable to determine type from validation",
|
||||
},
|
||||
{
|
||||
"empty array",
|
||||
"field",
|
||||
[]interface{}{},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"array with multiple items",
|
||||
"field",
|
||||
[]interface{}{"item1", "item2"},
|
||||
"unknown validation",
|
||||
},
|
||||
{
|
||||
"invalid nested validation",
|
||||
"field",
|
||||
map[string]interface{}{
|
||||
"nested": 123,
|
||||
},
|
||||
"unable to determine type from validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
resource := NewResource("Test", "test")
|
||||
fieldInfo, err := resource.fieldInfoFromValidation(tc.fieldName, tc.validation)
|
||||
if tc.errorContains != "" {
|
||||
require.ErrorContains(t, err, tc.errorContains)
|
||||
a.NotNil(fieldInfo)
|
||||
a.EqualValues(&FieldInfo{}, fieldInfo)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
a.NotNil(fieldInfo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourcesFromDownloadedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test JSON files
|
||||
validJSON := `{
|
||||
"name": "test",
|
||||
"value": "^[0-9]*$",
|
||||
"enabled": "true|false"
|
||||
}`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "Test.json"), []byte(validJSON), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(tmpDir, "Invalid.json"), []byte("invalid json"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(tmpDir, "Setting.json"), []byte(validJSON), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
expectedLen int
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
"valid directory",
|
||||
tmpDir,
|
||||
1, // Only Test.json should be processed (Setting.json is skipped, Invalid.json fails)
|
||||
"",
|
||||
},
|
||||
{
|
||||
"non-existent directory",
|
||||
"non-existent",
|
||||
0,
|
||||
"unable to read fields directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
resources, err := buildResourcesFromDownloadedFields(tc.dir)
|
||||
if tc.errorContains != "" {
|
||||
require.ErrorContains(t, err, tc.errorContains)
|
||||
a.Nil(resources)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
a.Len(resources, tc.expectedLen)
|
||||
if tc.expectedLen > 0 {
|
||||
a.Equal("Test", resources[0].StructName)
|
||||
a.Equal("test", resources[0].ResourcePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func latestUnifiVersion() (*UnifiVersion, error) {
|
||||
}
|
||||
|
||||
for _, firmware := range respData.Embedded.Firmware {
|
||||
if firmware.Platform != debianPlatform {
|
||||
if firmware.Platform != debianPlatform || firmware.Version == nil {
|
||||
continue
|
||||
}
|
||||
return NewUnifiVersion(firmware.Version.Core(), firmware.Links.Data.Href), nil
|
||||
@@ -102,7 +102,8 @@ const UnifiVersion = %q
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(outDir, "version.generated.go"), versionGo, 0o644)
|
||||
_, err = writeGeneratedFile(outDir, "version", string(versionGo))
|
||||
return err
|
||||
}
|
||||
|
||||
func writeVersionRepoMarkerFile(version *version.Version, outDir string) error {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
@@ -117,14 +119,13 @@ func TestDetermineUnifiVersion_provided(t *testing.T) {
|
||||
for providedVersion, expectedVersion := range testCases {
|
||||
t.Run(providedVersion, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
a := assert.New(t)
|
||||
|
||||
unifiVersion, err := determineUnifiVersion(providedVersion)
|
||||
require.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(expectedVersion, unifiVersion.Version.String())
|
||||
assert.Equal(fmt.Sprintf(baseDownloadUrl, expectedVersion), unifiVersion.DownloadUrl.String())
|
||||
a.Equal(expectedVersion, unifiVersion.Version.String())
|
||||
a.Equal(fmt.Sprintf(baseDownloadUrl, expectedVersion), unifiVersion.DownloadUrl.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -132,17 +133,230 @@ func TestDetermineUnifiVersion_provided(t *testing.T) {
|
||||
func TestDetermineUnifiVersion_invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []string{
|
||||
"a7.3.83",
|
||||
"7.3.83 ",
|
||||
"invalid",
|
||||
"-1",
|
||||
"",
|
||||
}
|
||||
assert := assert.New(t)
|
||||
|
||||
for _, providedVersion := range testCases {
|
||||
t.Run(providedVersion, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := determineUnifiVersion(providedVersion)
|
||||
assert.ErrorContains(err, providedVersion)
|
||||
require.ErrorContains(t, err, providedVersion)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnifiVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
downloadUrl, err := url.Parse("https://example.com/download")
|
||||
require.NoError(t, err)
|
||||
|
||||
unifiVersion := NewUnifiVersion(v, downloadUrl)
|
||||
a.Equal(v, unifiVersion.Version)
|
||||
a.Equal(downloadUrl, unifiVersion.DownloadUrl)
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion_HttpError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
firmwareUpdateApi = server.URL
|
||||
_, err := latestUnifiVersion()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion_InvalidJson(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
_, err := rw.Write([]byte("invalid json"))
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
firmwareUpdateApi = server.URL
|
||||
_, err := latestUnifiVersion()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "invalid")
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion_NoDebianFirmware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fwVersion, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
respData := firmwareUpdateApiResponse{
|
||||
Embedded: firmwareUpdateApiResponseEmbedded{
|
||||
Firmware: []firmwareUpdateApiResponseEmbeddedFirmware{
|
||||
{
|
||||
Channel: releaseChannel,
|
||||
Platform: "windows",
|
||||
Product: unifiControllerProduct,
|
||||
Version: fwVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
resp, err := json.Marshal(respData)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
_, err = rw.Write(resp)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
firmwareUpdateApi = server.URL
|
||||
_, err = latestUnifiVersion()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no Unifi Controller firmware found")
|
||||
}
|
||||
|
||||
func TestWriteVersionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionFile(v, tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "version.generated.go"))
|
||||
require.NoError(t, err)
|
||||
a.Contains(string(content), `const UnifiVersion = "7.3.83"`)
|
||||
}
|
||||
|
||||
func TestWriteVersionRepoMarkerFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := assert.New(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionRepoMarkerFile(v, tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, ".unifi-version"))
|
||||
require.NoError(t, err)
|
||||
a.Equal("7.3.83", string(content))
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion_InvalidUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
firmwareUpdateApi = ":\\invalid"
|
||||
_, err := latestUnifiVersion()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "invalid")
|
||||
}
|
||||
|
||||
func TestWriteVersionFile_InvalidDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionFile(v, "/nonexistent/directory")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no such file or directory")
|
||||
}
|
||||
|
||||
func TestWriteVersionRepoMarkerFile_InvalidDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionRepoMarkerFile(v, "/nonexistent/directory")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no such file or directory")
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion_NilVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
respData := firmwareUpdateApiResponse{
|
||||
Embedded: firmwareUpdateApiResponseEmbedded{
|
||||
Firmware: []firmwareUpdateApiResponseEmbeddedFirmware{
|
||||
{
|
||||
Channel: releaseChannel,
|
||||
Platform: debianPlatform,
|
||||
Product: unifiControllerProduct,
|
||||
Version: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
resp, err := json.Marshal(respData)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
_, err = rw.Write(resp)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
firmwareUpdateApi = server.URL
|
||||
_, err := latestUnifiVersion()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWriteVersionFile_EmptyVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
v, err := version.NewVersion("0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionFile(v, tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "version.generated.go"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), `const UnifiVersion = "0.0.0"`)
|
||||
}
|
||||
|
||||
func TestWriteVersionRepoMarkerFile_Permissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("Skipping test when running as root")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
readOnlyDir := filepath.Join(tmpDir, "readonly")
|
||||
err := os.Mkdir(readOnlyDir, 0o555)
|
||||
require.NoError(t, err)
|
||||
|
||||
v, err := version.NewVersion("7.3.83")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writeVersionRepoMarkerFile(v, readOnlyDir)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "permission denied")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user