feat(codegen): rename "fields" to "codegen" for more meaningful naming
This commit is contained in:
committed by
Mateusz Filipowicz
parent
f396b2f712
commit
e629288cba
@@ -0,0 +1,157 @@
|
||||
{{- $structName := .StructName }}
|
||||
|
||||
{{ define "field" }}
|
||||
{{ .FieldName }} {{ if .IsArray }}[]{{end}}{{ .FieldType }} `json:"{{ .JSONName }}{{ if .OmitEmpty }},omitempty{{ end }}"` {{ if .FieldValidation }}// {{ .FieldValidation }}{{ end }} {{- end }}
|
||||
{{ define "field-customUnmarshalType" }}
|
||||
{{- if eq .CustomUnmarshalType "" }}{{else}}
|
||||
{{ .FieldName }} {{ if .IsArray }}[]{{end}}{{ .CustomUnmarshalType }} `json:"{{ .JSONName }}"`{{ end }} {{- end }}
|
||||
{{ define "typecast" }}
|
||||
{{- if ne .CustomUnmarshalFunc "" }}
|
||||
dst.{{ .FieldName }}= {{ .CustomUnmarshalFunc }}(aux.{{ .FieldName }})
|
||||
{{- else if eq .CustomUnmarshalType "" }}{{else}}
|
||||
{{- if .IsArray }}
|
||||
dst.{{ .FieldName }}= make([]{{ .FieldType }}, len(aux.{{ .FieldName }}))
|
||||
for i, v := range aux.{{ .FieldName }} {
|
||||
dst.{{ .FieldName }}[i] = {{ .FieldType }}(v)
|
||||
}
|
||||
{{- else }}
|
||||
dst.{{ .FieldName }} = {{ .FieldType }}(aux.{{ .FieldName }})
|
||||
{{- end }}{{- end }}{{- end }}
|
||||
// Code generated from ace.jar fields *.json files
|
||||
// DO NOT EDIT.
|
||||
|
||||
package unifi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// just to fix compile issues with the import
|
||||
var (
|
||||
_ context.Context
|
||||
_ fmt.Formatter
|
||||
_ json.Marshaler
|
||||
)
|
||||
|
||||
{{ range $k, $v := .Types }}
|
||||
type {{ $k }} struct {
|
||||
{{ range $fk, $fv := $v.Fields }}{{ if not $fv }}
|
||||
{{ else }}{{- template "field" $fv }}{{ end }}{{ end }}
|
||||
}
|
||||
|
||||
func (dst *{{ $k }}) UnmarshalJSON(b []byte) error {
|
||||
type Alias {{ $k }}
|
||||
aux := &struct {
|
||||
{{- range $fk, $fv := $v.Fields }}{{ if not $fv }}
|
||||
{{- else }}{{- template "field-customUnmarshalType" $fv }}{{ end }}{{- end }}
|
||||
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(dst),
|
||||
}
|
||||
|
||||
err := json.Unmarshal(b, &aux)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to unmarshal alias: %w", err)
|
||||
}
|
||||
|
||||
{{- range $fk, $fv := $v.Fields }}{{ if not $fv }}
|
||||
{{- else }}{{- template "typecast" $fv }}{{ end }}{{ end }}
|
||||
|
||||
return nil
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{ if not .IsSetting }}
|
||||
func (c *Client) list{{ .StructName }}(ctx context.Context, site string) ([]{{ .StructName }}, error) {
|
||||
var respBody struct {
|
||||
Meta meta `json:"meta"`
|
||||
Data []{{ .StructName }} `json:"data"`
|
||||
}
|
||||
|
||||
err := c.do(ctx, "GET", fmt.Sprintf("s/%s/{{ if eq .StructName "Device" }}stat/{{else if eq .StructName "APGroup" }}{{ else }}rest/{{ end }}{{ .ResourcePath }}", site), nil, &respBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return respBody.Data, nil
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
func (c *Client) get{{ .StructName }}(ctx context.Context, site{{ if not .IsSetting }}, id{{ end }} string) (*{{ .StructName }}, error) {
|
||||
var respBody struct {
|
||||
Meta meta `json:"meta"`
|
||||
Data []{{ .StructName }} `json:"data"`
|
||||
}
|
||||
{{ if .IsSetting }}
|
||||
err := c.do(ctx, "GET", fmt.Sprintf("s/%s/get/setting/{{ .ResourcePath }}", site), nil, &respBody)
|
||||
{{- else }}
|
||||
err := c.do(ctx, "GET", fmt.Sprintf("s/%s/{{ if eq .StructName "Device" }}stat{{ else }}rest{{ end }}/{{ .ResourcePath }}/%s", site, id), nil, &respBody)
|
||||
{{- end }}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(respBody.Data) != 1 {
|
||||
return nil, &NotFoundError{}
|
||||
}
|
||||
|
||||
d := respBody.Data[0]
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
{{ if not .IsSetting }}
|
||||
func (c *Client) delete{{ .StructName }}(ctx context.Context, site, id string) error {
|
||||
err := c.do(ctx, "DELETE", fmt.Sprintf("s/%s/rest/{{ .ResourcePath }}/%s", site, id), struct{}{}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) create{{ .StructName }}(ctx context.Context, site string, d *{{ .StructName }}) (*{{ .StructName }}, error) {
|
||||
var respBody struct {
|
||||
Meta meta `json:"meta"`
|
||||
Data []{{ .StructName }} `json:"data"`
|
||||
}
|
||||
|
||||
err := c.do(ctx, "POST", fmt.Sprintf("s/%s/rest/{{ .ResourcePath }}", site), d, &respBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(respBody.Data) != 1 {
|
||||
return nil, &NotFoundError{}
|
||||
}
|
||||
|
||||
new := respBody.Data[0]
|
||||
|
||||
return &new, nil
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
func (c *Client) update{{ .StructName }}(ctx context.Context, site string, d *{{ .StructName }}) (*{{ .StructName }}, error) {
|
||||
var respBody struct {
|
||||
Meta meta `json:"meta"`
|
||||
Data []{{ .StructName }} `json:"data"`
|
||||
}
|
||||
{{ if .IsSetting }}
|
||||
d.Key = "{{ .ResourcePath }}"
|
||||
err := c.do(ctx, "PUT", fmt.Sprintf("s/%s/set/setting/{{ .ResourcePath }}", site), d, &respBody)
|
||||
{{- else }}
|
||||
err := c.do(ctx, "PUT", fmt.Sprintf("s/%s/rest/{{ .ResourcePath }}/%s", site, d.ID), d, &respBody)
|
||||
{{- end }}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(respBody.Data) != 1 {
|
||||
return nil, &NotFoundError{}
|
||||
}
|
||||
|
||||
new := respBody.Data[0]
|
||||
|
||||
return &new, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/ulikunitz/xz"
|
||||
"github.com/xor-gate/ar"
|
||||
)
|
||||
|
||||
func DownloadAndExtract(downloadUrl url.URL, outputDir string) error {
|
||||
targetInfo, err := os.Stat(outputDir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(outputDir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// download fields, create
|
||||
log.Debugf("downloading UniFi Controller package from: %s", downloadUrl.String())
|
||||
jarFile, err := downloadJar(downloadUrl, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("extracting JSON files with API structures from: %s to: %s", jarFile, outputDir)
|
||||
err = extractJSON(jarFile, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("JSON files extracted to: %s", outputDir)
|
||||
targetInfo, err = os.Stat(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !targetInfo.IsDir() {
|
||||
return errors.New("fields info isn't a directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadJar(downloadUrl url.URL, outputDir string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, downloadUrl.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to download UniFi Controller deb: %w", err)
|
||||
}
|
||||
|
||||
debResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to download UniFi Controller deb: %w", err)
|
||||
}
|
||||
defer debResp.Body.Close()
|
||||
|
||||
var uncompressedReader io.Reader
|
||||
|
||||
arReader := ar.NewReader(debResp.Body)
|
||||
for {
|
||||
header, err := arReader.Next()
|
||||
if errors.Is(err, io.EOF) || header == nil {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("in ar next: %w", err)
|
||||
}
|
||||
|
||||
// read the data file
|
||||
if header.Name == "data.tar.xz" {
|
||||
uncompressedReader, err = xz.NewReader(arReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("in xz reader: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if uncompressedReader == nil {
|
||||
return "", errors.New("unable to find .deb data file")
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(uncompressedReader)
|
||||
|
||||
var aceJar *os.File
|
||||
|
||||
log.Debugln("extracting ace.jar from downloaded controller package")
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("in next: %w", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg || header.Name != "./usr/lib/unifi/lib/ace.jar" {
|
||||
// skipping
|
||||
continue
|
||||
}
|
||||
|
||||
aceJar, err = os.Create(filepath.Join(outputDir, "ace.jar"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to create temp file: %w", err)
|
||||
}
|
||||
_, err = io.Copy(aceJar, tarReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to write ace.jar temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if aceJar == nil {
|
||||
return "", errors.New("unable to find ace.jar")
|
||||
}
|
||||
|
||||
defer aceJar.Close()
|
||||
log.Debugf("ace.jar extracted to: %s", aceJar.Name())
|
||||
return aceJar.Name(), nil
|
||||
}
|
||||
|
||||
func sanitizeExtractedPath(filePath, destinationDir string) (string, error) {
|
||||
absDestinationDir, err := filepath.Abs(destinationDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
absFilePath, err := filepath.Abs(filepath.Join(destinationDir, filepath.Base(filePath)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(absFilePath, absDestinationDir) {
|
||||
return "", fmt.Errorf("invalid file path: %s", filePath)
|
||||
}
|
||||
|
||||
return absFilePath, nil
|
||||
}
|
||||
|
||||
func extractJSON(jarFile, fieldsDir string) error {
|
||||
jarZip, err := zip.OpenReader(jarFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to open jar: %w", err)
|
||||
}
|
||||
defer jarZip.Close()
|
||||
|
||||
log.Tracef("opened jar %s with %d files", jarFile, len(jarZip.File))
|
||||
for _, f := range jarZip.File {
|
||||
if !strings.HasPrefix(f.Name, "api/fields/") || path.Ext(f.Name) != ".json" {
|
||||
// skip file
|
||||
continue
|
||||
}
|
||||
|
||||
err = func() error {
|
||||
log.Tracef("extracting %s", f.Name)
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstPath, err := sanitizeExtractedPath(f.Name, fieldsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
log.Debugf("extracted %s", f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to write JSON file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
settingsData, err := os.ReadFile(filepath.Join(fieldsDir, "Setting.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to open settings file: %w", err)
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
err = json.Unmarshal(settingsData, &settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to unmarshal settings: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("splitting Settings.json into individual setting files")
|
||||
for settingKey, settingValue := range settings {
|
||||
settingName := strcase.ToCamel(settingKey)
|
||||
fileName := fmt.Sprintf("Setting%s.json", settingName)
|
||||
log.Tracef("splitting %s", fileName)
|
||||
|
||||
data, err := json.MarshalIndent(settingValue, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to marshal setting %q: %w", settingKey, err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(fieldsDir, fileName), data, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to write new settings file: %w", err)
|
||||
}
|
||||
log.Tracef("splitted %s into %s", settingKey, fileName)
|
||||
}
|
||||
|
||||
// TODO: cleanup JSON
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
var firmwareUpdateApi = "https://fw-update.ubnt.com/api/firmware-latest"
|
||||
|
||||
const (
|
||||
debianPlatform = "debian"
|
||||
releaseChannel = "release"
|
||||
unifiControllerProduct = "unifi-controller"
|
||||
)
|
||||
|
||||
type firmwareUpdateApiResponse struct {
|
||||
Embedded firmwareUpdateApiResponseEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type firmwareUpdateApiResponseEmbedded struct {
|
||||
Firmware []firmwareUpdateApiResponseEmbeddedFirmware `json:"firmware"`
|
||||
}
|
||||
|
||||
type firmwareUpdateApiResponseEmbeddedFirmware struct {
|
||||
Channel string `json:"channel"`
|
||||
Created string `json:"created"`
|
||||
Id string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Product string `json:"product"`
|
||||
Version *version.Version `json:"version"`
|
||||
Links firmwareUpdateApiResponseEmbeddedFirmwareLinks `json:"_links"`
|
||||
}
|
||||
|
||||
type firmwareUpdateApiResponseEmbeddedFirmwareDataLink struct {
|
||||
Href *url.URL `json:"href"`
|
||||
}
|
||||
|
||||
func (l *firmwareUpdateApiResponseEmbeddedFirmwareDataLink) MarshalJSON() ([]byte, error) {
|
||||
var href string
|
||||
if l.Href != nil {
|
||||
href = l.Href.String()
|
||||
}
|
||||
|
||||
aux := struct {
|
||||
Href string `json:"href"`
|
||||
}{
|
||||
Href: href,
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (l *firmwareUpdateApiResponseEmbeddedFirmwareDataLink) UnmarshalJSON(j []byte) error {
|
||||
var m map[string]interface{}
|
||||
|
||||
err := json.Unmarshal(j, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if href := m["href"]; href != nil {
|
||||
url, err := url.Parse(href.(string))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Href = url
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type firmwareUpdateApiResponseEmbeddedFirmwareLinks struct {
|
||||
Data firmwareUpdateApiResponseEmbeddedFirmwareDataLink `json:"data"`
|
||||
}
|
||||
|
||||
func firmwareUpdateApiFilter(key, value string) string {
|
||||
return fmt.Sprintf("%s~~%s~~%s", "eq", key, value)
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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", "", 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", "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 string, jsonName string, fieldType string, fieldValidation string, omitempty bool, isArray bool, customUnmarshalType string) *FieldInfo {
|
||||
return &FieldInfo{
|
||||
FieldName: fieldName,
|
||||
JSONName: jsonName,
|
||||
FieldType: fieldType,
|
||||
FieldValidation: fieldValidation,
|
||||
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:
|
||||
fieldValidation := 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" {
|
||||
fieldValidation = ""
|
||||
}
|
||||
|
||||
if strings.Contains(normalized, ".") {
|
||||
if strings.Contains(validation, "\\.){3}") {
|
||||
break
|
||||
}
|
||||
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "float64", fieldValidation, omitEmpty, false, "")
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
|
||||
omitEmpty = true
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "int", fieldValidation, omitEmpty, false, "")
|
||||
fieldInfo.CustomUnmarshalType = "emptyStringInt"
|
||||
return fieldInfo, r.FieldProcessor(fieldName, fieldInfo)
|
||||
}
|
||||
}
|
||||
if validation != "" && normalized != "" {
|
||||
log.Tracef("normalize %q to %q", validation, normalized)
|
||||
}
|
||||
|
||||
omitEmpty = omitEmpty || (!strings.Contains(validation, "^$") && !strings.HasSuffix(fieldName, "ID"))
|
||||
fieldInfo = NewFieldInfo(fieldName, name, "string", fieldValidation, 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) {
|
||||
var err error
|
||||
var buf bytes.Buffer
|
||||
writer := io.Writer(&buf)
|
||||
|
||||
tpl := template.Must(template.New("api.go.tmpl").Parse(apiGoTemplate))
|
||||
|
||||
err = tpl.Execute(writer, r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render template: %w", err)
|
||||
}
|
||||
|
||||
src, err := format.Source(buf.Bytes())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to format source: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func generateCode(fieldsDir string, outDir string) error {
|
||||
fieldsFiles, err := os.ReadDir(fieldsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, 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)
|
||||
|
||||
goFile := strcase.ToSnake(structName) + ".generated.go"
|
||||
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
|
||||
}
|
||||
|
||||
var code string
|
||||
if code, err = resource.generateCode(); err != nil {
|
||||
log.Errorf("failed to generate code for %s: %s", fieldsFile.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
goFilePath := filepath.Join(outDir, goFile)
|
||||
_ = os.Remove(goFilePath)
|
||||
if err := os.WriteFile(goFile, ([]byte)(code), 0o644); err != nil {
|
||||
log.Errorf("failed to write file %s: %s", goFile, err)
|
||||
continue
|
||||
}
|
||||
log.Debugf("Generated %s with resource %s\n\n", goFile, structName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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.FieldValidation != c.expectedComment {
|
||||
t.Fatalf("expected comment %q got %q", c.expectedComment, fieldInfo.FieldValidation)
|
||||
}
|
||||
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", ".{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", "^([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",
|
||||
FieldValidation: "",
|
||||
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",
|
||||
FieldValidation: "",
|
||||
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",
|
||||
FieldValidation: "",
|
||||
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)
|
||||
})
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Printf("Usage: %s [OPTIONS] version\n", path.Base(os.Args[0]))
|
||||
fmt.Printf("version can be a specific version or '%s' (default) for the latest UniFi Controller version\n", LatestVersionMarker)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func setupLogging(debugEnabled, traceEnabled bool) {
|
||||
log.SetFormatter(&log.TextFormatter{
|
||||
DisableTimestamp: true,
|
||||
DisableLevelTruncation: true,
|
||||
ForceColors: true,
|
||||
FullTimestamp: false,
|
||||
})
|
||||
if traceEnabled {
|
||||
log.SetLevel(log.TraceLevel)
|
||||
} else if debugEnabled {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = usage
|
||||
|
||||
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 API structures JSON directory, do not generate")
|
||||
debugFlag := flag.Bool("debug", false, "Enable debug logging")
|
||||
traceFlag := flag.Bool("trace", false, "Enable trace logging")
|
||||
|
||||
flag.Parse()
|
||||
setupLogging(*debugFlag, *traceFlag)
|
||||
specifiedVersion := strings.TrimSpace(flag.Arg(0))
|
||||
if specifiedVersion == "" {
|
||||
specifiedVersion = LatestVersionMarker // default to latest version
|
||||
}
|
||||
unifiVersion, err := determineUnifiVersion(specifiedVersion)
|
||||
if err != nil {
|
||||
log.Fatalf("unable to determine version and download URL for Unifi version %s: %s", specifiedVersion, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Infof("UniFi Controller version: %s", unifiVersion.Version)
|
||||
log.Infof("UniFi Controller download URL: %s", unifiVersion.DownloadUrl.String())
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("unable to determine working directory: %s", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
structuresDir := filepath.Join(wd, *versionBaseDirFlag, fmt.Sprintf("v%s", unifiVersion.Version))
|
||||
log.Infoln("Downloading UniFi Controller API structures definitions...")
|
||||
err = DownloadAndExtract(*unifiVersion.DownloadUrl, structuresDir)
|
||||
if err != nil {
|
||||
log.Fatalf("unable to download and extract UniFi Controller API structures definitions: %s", err)
|
||||
panic(err)
|
||||
}
|
||||
log.Infof("Downloaded UniFi Controller API structures definitions in %s", structuresDir)
|
||||
|
||||
if *downloadOnly {
|
||||
log.Infoln("Structure JSONs ready!")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
log.Infoln("Generating resources code...")
|
||||
outDir := filepath.Join(wd, *outputDirFlag)
|
||||
if err = generateCode(structuresDir, outDir); err != nil {
|
||||
log.Fatalf("unable to generate resources code: %s", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Infof("Writing version file...")
|
||||
if err = writeVersionFile(unifiVersion.Version, outDir); err != nil {
|
||||
log.Fatalf("failed to write version file to %s: %s", outDir, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
basepath := filepath.Dir(wd)
|
||||
if err = writeVersionRepoMarkerFile(unifiVersion.Version, basepath); err != nil {
|
||||
log.Fatalf("failed to write version file to %s: %s", basepath, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Infof("Generated resources in %s", outDir)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
const (
|
||||
LatestVersionMarker = "latest"
|
||||
baseDownloadUrl = "https://dl.ui.com/unifi/%s/unifi_sysvinit_all.deb"
|
||||
)
|
||||
|
||||
type UnifiVersion struct {
|
||||
Version *version.Version
|
||||
DownloadUrl *url.URL
|
||||
}
|
||||
|
||||
func NewUnifiVersion(unifiVersion *version.Version, downloadUrl *url.URL) *UnifiVersion {
|
||||
return &UnifiVersion{
|
||||
Version: unifiVersion,
|
||||
DownloadUrl: downloadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
func latestUnifiVersion() (*UnifiVersion, error) {
|
||||
url, err := url.Parse(firmwareUpdateApi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := url.Query()
|
||||
query.Add("filter", firmwareUpdateApiFilter("channel", releaseChannel))
|
||||
query.Add("filter", firmwareUpdateApiFilter("product", unifiControllerProduct))
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respData firmwareUpdateApiResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&respData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, firmware := range respData.Embedded.Firmware {
|
||||
if firmware.Platform != debianPlatform {
|
||||
continue
|
||||
}
|
||||
return NewUnifiVersion(firmware.Version.Core(), firmware.Links.Data.Href), nil
|
||||
}
|
||||
|
||||
return nil, errors.New("no Unifi Controller firmware found")
|
||||
}
|
||||
|
||||
func determineUnifiVersion(versionMarker string) (*UnifiVersion, error) {
|
||||
if versionMarker == LatestVersionMarker {
|
||||
return latestUnifiVersion()
|
||||
} else {
|
||||
unifiVersion, err := version.NewVersion(versionMarker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unifiVersion = unifiVersion.Core()
|
||||
downloadUrl := fmt.Sprintf(baseDownloadUrl, unifiVersion)
|
||||
unifiDownloadUrl, err := url.Parse(downloadUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewUnifiVersion(unifiVersion, unifiDownloadUrl), nil
|
||||
}
|
||||
}
|
||||
|
||||
func writeVersionFile(version *version.Version, outDir string) error {
|
||||
versionGo := []byte(fmt.Sprintf(`
|
||||
// Generated code. DO NOT EDIT.
|
||||
|
||||
package unifi
|
||||
|
||||
const UnifiVersion = %q
|
||||
`, version.Core()))
|
||||
|
||||
versionGo, err := format.Source(versionGo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(outDir, "version.generated.go"), versionGo, 0o644)
|
||||
}
|
||||
|
||||
func writeVersionRepoMarkerFile(version *version.Version, outDir string) error {
|
||||
versionRepoMarker := []byte(version.Core().String())
|
||||
return os.WriteFile(filepath.Join(outDir, ".unifi-version"), versionRepoMarker, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func assertLatestVersionUsingProvider(t *testing.T, provider func() (*UnifiVersion, error)) {
|
||||
t.Helper()
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
fwVersion, err := version.NewVersion("7.3.83+atag-7.3.83-19645")
|
||||
require.NoError(err)
|
||||
|
||||
fwDownload, err := url.Parse("https://fw-download.ubnt.com/data/unifi-controller/c31c-debian-7.3.83-c9249c913b91416693b869b9548850c3.deb")
|
||||
require.NoError(err)
|
||||
|
||||
respData := firmwareUpdateApiResponse{
|
||||
Embedded: firmwareUpdateApiResponseEmbedded{
|
||||
Firmware: []firmwareUpdateApiResponseEmbeddedFirmware{
|
||||
{
|
||||
Channel: releaseChannel,
|
||||
Created: "2023-02-06T08:55:31+00:00",
|
||||
Id: "c9249c91-3b91-4166-93b8-69b9548850c3",
|
||||
Platform: debianPlatform,
|
||||
Product: unifiControllerProduct,
|
||||
Version: fwVersion,
|
||||
Links: firmwareUpdateApiResponseEmbeddedFirmwareLinks{
|
||||
Data: firmwareUpdateApiResponseEmbeddedFirmwareDataLink{
|
||||
Href: fwDownload,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Channel: releaseChannel,
|
||||
Created: "2023-02-06T08:51:36+00:00",
|
||||
Id: "2a600108-7f79-4b3e-b6e0-4dd262460457",
|
||||
Platform: "document",
|
||||
Product: unifiControllerProduct,
|
||||
Version: fwVersion,
|
||||
Links: firmwareUpdateApiResponseEmbeddedFirmwareLinks{
|
||||
Data: firmwareUpdateApiResponseEmbeddedFirmwareDataLink{
|
||||
Href: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Channel: releaseChannel,
|
||||
Created: "2023-02-06T08:51:37+00:00",
|
||||
Id: "9d2d413d-36ce-4742-a10d-4351aac6f08d",
|
||||
Platform: "windows",
|
||||
Product: unifiControllerProduct,
|
||||
Version: fwVersion,
|
||||
Links: firmwareUpdateApiResponseEmbeddedFirmwareLinks{
|
||||
Data: firmwareUpdateApiResponseEmbeddedFirmwareDataLink{
|
||||
Href: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
assert.Contains(query["filter"], firmwareUpdateApiFilter("channel", releaseChannel))
|
||||
assert.Contains(query["filter"], firmwareUpdateApiFilter("product", unifiControllerProduct))
|
||||
|
||||
resp, err := json.Marshal(respData)
|
||||
assert.NoError(err)
|
||||
|
||||
_, err = rw.Write(resp)
|
||||
assert.NoError(err)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
firmwareUpdateApi = server.URL
|
||||
gotVersion, err := provider()
|
||||
require.NoError(err)
|
||||
|
||||
assert.Equal(fwVersion.Core(), gotVersion.Version)
|
||||
assert.Equal(fwDownload, gotVersion.DownloadUrl)
|
||||
}
|
||||
|
||||
func TestLatestUnifiVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertLatestVersionUsingProvider(t, func() (*UnifiVersion, error) {
|
||||
return latestUnifiVersion()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetermineUnifiVersion_latest(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertLatestVersionUsingProvider(t, func() (*UnifiVersion, error) {
|
||||
return determineUnifiVersion(LatestVersionMarker)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetermineUnifiVersion_provided(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]string{
|
||||
"7.3.83+atag-7.3.83-19645": "7.3.83",
|
||||
"7.3.83": "7.3.83",
|
||||
"7.3": "7.3.0",
|
||||
"7": "7.0.0",
|
||||
}
|
||||
|
||||
for providedVersion, expectedVersion := range testCases {
|
||||
t.Run(providedVersion, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
unifiVersion, err := determineUnifiVersion(providedVersion)
|
||||
require.NoError(err)
|
||||
|
||||
assert.Equal(expectedVersion, unifiVersion.Version.String())
|
||||
assert.Equal(fmt.Sprintf(baseDownloadUrl, expectedVersion), unifiVersion.DownloadUrl.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineUnifiVersion_invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []string{
|
||||
"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user