feat: add API v2 support by adding APGroup and DNSRecord resource handling with generated code (#23)

* feat: add API v2 support by adding APGroup and DNSRecord resource handling with generated code

* fix tests
This commit is contained in:
Mateusz Filipowicz
2025-02-17 15:39:54 +01:00
committed by GitHub
parent dca894e8e7
commit aa188a6faa
14 changed files with 482 additions and 175 deletions
+119
View File
@@ -0,0 +1,119 @@
{{- $structName := .StructName }}
{{ define "field" }}
{{ .FieldName }} {{ if .IsArray }}[]{{end}}{{ .FieldType }} `json:"{{ .JSONName }}{{ if .OmitEmpty }},omitempty{{ end }}"{{if .FieldValidation }} {{ .FieldValidation }}{{ end }}` {{ if .FieldValidationComment }}// {{ .FieldValidationComment }}{{ 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 }}
func (c *client) list{{ .StructName }}(ctx context.Context, site string) ([]{{ .StructName }}, error) {
var respBody []{{ .StructName }}
err := c.Get(ctx, fmt.Sprintf("%s/site/%s/{{ .ResourcePath }}", c.apiPaths.ApiV2Path, site), nil, &respBody)
if err != nil {
return nil, err
}
return respBody, nil
}
func (c *client) get{{ .StructName }}(ctx context.Context, site, id string) (*{{ .StructName }}, error) {
var respBody {{ .StructName }}
err := c.Get(ctx, fmt.Sprintf("%s/site/%s/{{ .ResourcePath }}/%s", c.apiPaths.ApiV2Path, site, id), nil, &respBody)
if err != nil {
return nil, err
}
if respBody.ID == "" {
return nil, ErrNotFound
}
return &respBody, nil
}
func (c *client) delete{{ .StructName }}(ctx context.Context, site, id string) error {
err := c.Delete(ctx, fmt.Sprintf("%s/site/%s/{{ .ResourcePath }}/%s", c.apiPaths.ApiV2Path, 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 {{ .StructName }}
err := c.Post(ctx, fmt.Sprintf("%s/site/%s/{{ .ResourcePath }}", c.apiPaths.ApiV2Path, site), d, &respBody)
if err != nil {
return nil, err
}
return &respBody, nil
}
func (c *client) update{{ .StructName }}(ctx context.Context, site string, d *{{ .StructName }}) (*{{ .StructName }}, error) {
var respBody {{ .StructName }}
err := c.Put(ctx, fmt.Sprintf("%s/site/%s/{{ .ResourcePath }}/%s", c.apiPaths.ApiV2Path, site, d.ID), d, &respBody)
if err != nil {
return nil, err
}
return &respBody, nil
}
+4
View File
@@ -301,6 +301,8 @@ customizations:
omitEmpty: true
NetworkID:
omitEmpty: true
APGroup:
resourcePath: "apgroups"
ChannelPlan:
fields:
Channel:
@@ -312,6 +314,8 @@ customizations:
TxPower:
ifFieldType: "string"
customUnmarshalType: "numberOrString"
DNSRecord:
resourcePath: "static-dns"
Device:
fields:
_all:
+7
View File
@@ -26,6 +26,7 @@ type Generate struct {
type ResourceCustomization struct {
ResourceName string `yaml:"-"`
Fields map[string]*FieldCustomization `yaml:"fields"`
ResourcePath string `yaml:"resourcePath"`
}
type ClientCustomization struct {
@@ -75,6 +76,9 @@ func (r *ResourceCustomization) ApplyTo(resource *Resource) {
}
return currentProcessor(name, f)
}
if r.ResourcePath != "" {
resource.ResourcePath = r.ResourcePath
}
} else {
resource.FieldProcessor = compositeCustomizationsProcessor(customizationsProcessor)
}
@@ -155,6 +159,9 @@ func NewCodeCustomizer(customizationsPath string) (*CodeCustomizer, error) {
}
func (r *CodeCustomizer) IsExcludedFromClient(resourceName string) bool {
if r.Customizations.Client == nil || r.Customizations.Client.ExcludeResources == nil {
return false
}
for _, excludedResource := range r.Customizations.Client.ExcludeResources {
prefixedAll := strings.HasPrefix(excludedResource, "*")
suffixedAll := strings.HasSuffix(excludedResource, "*")
+14 -5
View File
@@ -41,23 +41,32 @@ func generateCodeFromTemplate(templateName, templateContent string, toWrite any)
}
// generateCode generates code for each generation source and writes it to file.
func generateCode(fieldsDir string, outDir string, customizer CodeCustomizer) error {
func generateCode(fieldsDir, outDir string, customizer CodeCustomizer) error {
if _, err := ensurePath(outDir); err != nil {
return fmt.Errorf("unable to create output directory %s: %w", outDir, err)
}
generators := make([]Generatable, 0)
resources, err := buildResourcesFromDownloadedFields(fieldsDir, customizer)
resources, err := buildResourcesFromDownloadedFields(fieldsDir, customizer, false)
if err != nil {
return fmt.Errorf("failed to build resources from downloaded fields: %w", err)
}
codegenPath, err := findCodegenDir()
if err != nil {
return fmt.Errorf("failed to find codegen directory: %w", err)
}
resourcesCustomV2, err := buildCustomResources(filepath.Join(codegenPath, "v2"), customizer, true)
if err != nil {
return fmt.Errorf("failed to build resources from downloaded fields: %w", err)
}
resources = append(resources, resourcesCustomV2...)
cb := NewClientInfoBuilder()
customizer.ApplyToClient(cb)
for _, resource := range resources {
if customizer.IsExcludedFromClient(resource.Name()) {
continue
if !customizer.IsExcludedFromClient(resource.Name()) {
cb.AddResource(resource)
}
cb.AddResource(resource)
customizer.ApplyToResource(resource)
generators = append(generators, resource)
}
+21 -3
View File
@@ -89,6 +89,11 @@ type Resource struct {
ResourcePath string
Types map[string]*FieldInfo
FieldProcessor FieldProcessor
V2 bool
}
func (r *Resource) IsV2() bool {
return r.V2
}
func (r *Resource) BaseType() *FieldInfo {
@@ -294,7 +299,13 @@ func (r *Resource) processJSON(b []byte) error {
//go:embed api.go.tmpl
var apiGoTemplate string
//go:embed apiv2.go.tmpl
var apiGoV2Template string
func (r *Resource) GenerateCode() (string, error) {
if r.IsV2() {
return generateCodeFromTemplate("apiv2.go.tmpl", apiGoV2Template, r)
}
return generateCodeFromTemplate("api.go.tmpl", apiGoTemplate, r)
}
@@ -320,7 +331,7 @@ func normalizeValidation(re string) string {
var skippable = []string{"AuthenticationRequest.json", "Setting.json", "Wall.json"}
func buildResourcesFromDownloadedFields(fieldsDir string, customizer CodeCustomizer) ([]*Resource, error) {
func buildResourcesFromDownloadedFields(fieldsDir string, customizer CodeCustomizer, v2 bool) ([]*Resource, error) {
fieldsFiles, err := os.ReadDir(fieldsDir)
if err != nil {
return nil, fmt.Errorf("unable to read fields directory %s: %w", fieldsDir, err)
@@ -349,7 +360,7 @@ func buildResourcesFromDownloadedFields(fieldsDir string, customizer CodeCustomi
}
resource := NewResource(structName, urlPath)
customizeResource(resource)
customizeResource(resource, v2)
customizer.ApplyToResource(resource)
err = resource.processJSON(b)
@@ -362,6 +373,10 @@ func buildResourcesFromDownloadedFields(fieldsDir string, customizer CodeCustomi
return resources, nil
}
func buildCustomResources(dir string, customizer CodeCustomizer, v2 bool) ([]*Resource, error) {
return buildResourcesFromDownloadedFields(dir, customizer, v2)
}
func customizeBaseType(resource *Resource) {
baseType := resource.BaseType()
if resource.IsSetting() {
@@ -395,8 +410,11 @@ func customizeBaseType(resource *Resource) {
}
}
func customizeResource(resource *Resource) {
func customizeResource(resource *Resource, v2 bool) {
customizeBaseType(resource)
if v2 {
resource.V2 = true
}
switch resource.StructName {
case "SettingGlobalAp":
+1 -1
View File
@@ -354,7 +354,7 @@ func TestBuildResourcesFromDownloadedFields(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := assert.New(t)
resources, err := buildResourcesFromDownloadedFields(tc.dir, CodeCustomizer{})
resources, err := buildResourcesFromDownloadedFields(tc.dir, CodeCustomizer{}, false)
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
a.Nil(resources)
+27
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
)
// ensurePath checks if a path exists and is a directory, if not it creates the directory. Returns true if the directories were created.
@@ -24,3 +25,29 @@ func ensurePath(path string) (bool, error) {
}
return false, nil
}
func findProjectRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
// Walk up the directory tree until we find a go.mod file
for {
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
return wd, nil
}
if wd == "/" {
break
}
wd = filepath.Dir(wd)
}
return "", errors.New("unable to find project root")
}
func findCodegenDir() (string, error) {
root, err := findProjectRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "codegen"), nil
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "",
"device_macs": [""]
}
+10
View File
@@ -0,0 +1,10 @@
{
"enabled": "true|false",
"key": ".{1,256}",
"port": "^[0-9][0-9]?$|^",
"priority": "^[0-9][0-9]?$|^",
"record_type": "A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT",
"ttl": "^[0-9][0-9]?$|^",
"value": ".{1,256}",
"weight": "^[0-9][0-9]?$|^"
}