New unmarshalling rules for fields which could be numeric or string values were not properly typecasted upon being deserialized. Cleaned up the api template file and moved custom unmarshalling type logic into go code out of the template.
501 lines
12 KiB
Go
501 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/iancoleman/strcase"
|
|
)
|
|
|
|
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"},
|
|
}
|
|
|
|
var embedTypes bool
|
|
|
|
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
|
|
OmitEmpty bool
|
|
IsArray bool
|
|
Fields map[string]*FieldInfo
|
|
CustomUnmarshalType 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 generatd 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)
|
|
case resource.StructName == "Device":
|
|
baseType.Fields[" MAC"] = NewFieldInfo("MAC", "mac", "string", "", true, false)
|
|
case resource.StructName == "User":
|
|
baseType.Fields[" IP"] = NewFieldInfo("IP", "ip", "string", "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 string, jsonName string, fieldType string, fieldValidation string, omitempty bool, isArray bool) *FieldInfo {
|
|
return &FieldInfo{
|
|
FieldName: fieldName,
|
|
JSONName: jsonName,
|
|
FieldType: fieldType,
|
|
FieldValidation: fieldValidation,
|
|
OmitEmpty: omitempty,
|
|
IsArray: isArray,
|
|
}
|
|
}
|
|
|
|
func cleanName(name string, reps []replacement) string {
|
|
for _, rep := range reps {
|
|
name = strings.ReplaceAll(name, rep.Old, rep.New)
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Printf("Usage: %s [OPTIONS] version\n", path.Base(os.Args[0]))
|
|
flag.PrintDefaults()
|
|
}
|
|
|
|
func main() {
|
|
|
|
flag.Usage = usage
|
|
|
|
noEmbeddedTypesFlag := flag.Bool("no-embedded-types", true, "Whether to generate top-level type definitions for embedded type definitions")
|
|
versionBaseDirFlag := flag.String("version-base-dir", ".", "The base directory for version JSON files")
|
|
outputDirFlag := flag.String("output-dir", ".", "The output directory of the generated Go code")
|
|
downloadOnly := flag.Bool("download-only", false, "Only download and build the fields JSON directory, do not generate")
|
|
|
|
flag.Parse()
|
|
|
|
embedTypes = !*noEmbeddedTypesFlag
|
|
|
|
versionDir := flag.Arg(0)
|
|
if versionDir == "" {
|
|
fmt.Print("error: no version directory specified\n\n")
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
fieldsDir := filepath.Join(wd, *versionBaseDirFlag, versionDir)
|
|
outDir := filepath.Join(wd, *outputDirFlag)
|
|
|
|
fieldsInfo, err := os.Stat(fieldsDir)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
panic(err)
|
|
}
|
|
|
|
// download fields, create
|
|
jarFile, err := downloadJar(versionDir)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
err = extractJSON(jarFile, fieldsDir)
|
|
os.Remove(jarFile)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
fieldsInfo, err = os.Stat(fieldsDir)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
if !fieldsInfo.IsDir() {
|
|
panic("version info isn't a directory")
|
|
}
|
|
|
|
if *downloadOnly {
|
|
fmt.Println("Fields JSON ready!")
|
|
os.Exit(0)
|
|
}
|
|
|
|
fieldsFiles, err := ioutil.ReadDir(fieldsDir)
|
|
if err != nil {
|
|
panic(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
|
|
}
|
|
|
|
name = name[:len(name)-len(ext)]
|
|
|
|
urlPath := strings.ToLower(name)
|
|
structName := cleanName(name, fileReps)
|
|
|
|
goFile := strcase.ToSnake(structName) + ".generated.go"
|
|
fieldsFilePath := filepath.Join(fieldsDir, fieldsFile.Name())
|
|
b, err := ioutil.ReadFile(fieldsFilePath)
|
|
if err != nil {
|
|
fmt.Printf("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 {
|
|
if name == "IP" {
|
|
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", "Ht":
|
|
f.FieldType = "string"
|
|
f.CustomUnmarshalType = ""
|
|
case "Channel", "BackupChannel", "TxPower":
|
|
if f.FieldType == "string" {
|
|
f.CustomUnmarshalType = "numberOrString"
|
|
}
|
|
}
|
|
|
|
f.OmitEmpty = true
|
|
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
|
|
}
|
|
}
|
|
|
|
err = resource.processJSON(b)
|
|
if err != nil {
|
|
fmt.Printf("skipping file %s: %s", fieldsFile.Name(), err)
|
|
continue
|
|
}
|
|
|
|
code, _ := resource.generateCode()
|
|
|
|
_ = os.Remove(filepath.Join(outDir, goFile))
|
|
ioutil.WriteFile(filepath.Join(outDir, goFile), ([]byte)(code), 0644)
|
|
}
|
|
|
|
fmt.Printf("%s\n", outDir)
|
|
}
|
|
|
|
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 *FieldInfo, err error) {
|
|
fieldName := strcase.ToCamel(name)
|
|
fieldName = cleanName(fieldName, fieldReps)
|
|
|
|
empty := &FieldInfo{}
|
|
|
|
switch validation := validation.(type) {
|
|
case []interface{}:
|
|
if len(validation) == 0 {
|
|
fieldInfo, err = NewFieldInfo(fieldName, name, "string", "", false, true), nil
|
|
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:
|
|
fieldValidation := validation
|
|
normalized := normalizeValidation(validation)
|
|
|
|
omitEmpty := false
|
|
|
|
switch {
|
|
case normalized == "falsetrue" || normalized == "truefalse":
|
|
fieldInfo, err = NewFieldInfo(fieldName, name, "bool", "", omitEmpty, false), nil
|
|
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
|
default:
|
|
if _, err := strconv.ParseFloat(normalized, 64); err == nil {
|
|
|
|
if normalized == "09" || normalized == "09.09" {
|
|
fieldValidation = ""
|
|
}
|
|
|
|
if strings.Contains(normalized, ".") {
|
|
if strings.Contains(validation, "\\.){3}") {
|
|
break
|
|
}
|
|
|
|
omitEmpty = true
|
|
fieldInfo, err = NewFieldInfo(fieldName, name, "float64", fieldValidation, omitEmpty, false), nil
|
|
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
|
}
|
|
|
|
omitEmpty = true
|
|
fieldInfo, err = NewFieldInfo(fieldName, name, "int", fieldValidation, omitEmpty, false), nil
|
|
fieldInfo.CustomUnmarshalType = "emptyStringInt"
|
|
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
|
}
|
|
}
|
|
if validation != "" && normalized != "" {
|
|
fmt.Printf("normalize %q to %q\n", validation, normalized)
|
|
}
|
|
|
|
omitEmpty = omitEmpty || (!strings.Contains(validation, "^$") && !strings.HasSuffix(fieldName, "ID"))
|
|
fieldInfo, err = NewFieldInfo(fieldName, name, "string", fieldValidation, omitEmpty, false), nil
|
|
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) {
|
|
var err error
|
|
var buf bytes.Buffer
|
|
writer := io.Writer(&buf)
|
|
|
|
tpl := template.Must(template.New("api.go.tmpl").Funcs(template.FuncMap{
|
|
"embedTypes": func() bool { return embedTypes },
|
|
}).Parse(apiGoTemplate))
|
|
|
|
tpl.Execute(writer, r)
|
|
|
|
return buf.String(), 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
|
|
}
|