2022-05-25 17:27:18 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2022-06-13 16:44:15 +00:00
|
|
|
"bytes"
|
2022-05-25 17:27:18 +00:00
|
|
|
"encoding/xml"
|
|
|
|
"errors"
|
|
|
|
"flag"
|
|
|
|
"fmt"
|
2022-06-13 16:44:15 +00:00
|
|
|
"go/format"
|
|
|
|
"io/ioutil"
|
2022-05-25 17:27:18 +00:00
|
|
|
"os"
|
2022-06-06 17:00:39 +00:00
|
|
|
"path/filepath"
|
2022-06-08 16:51:47 +00:00
|
|
|
"reflect"
|
2022-06-06 17:00:39 +00:00
|
|
|
"sort"
|
|
|
|
"strconv"
|
2022-05-25 17:27:18 +00:00
|
|
|
"strings"
|
2022-06-06 17:00:39 +00:00
|
|
|
"text/template"
|
2022-05-25 17:27:18 +00:00
|
|
|
|
2022-06-10 06:20:09 +00:00
|
|
|
"github.com/BurntSushi/toml"
|
2022-06-06 17:00:39 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/cmd/goupnp2srvgen/tmplfuncs"
|
2022-05-27 06:10:15 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/cmd/goupnp2srvgen/zipread"
|
2022-05-27 06:00:09 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/description/srvdesc"
|
2022-06-06 17:00:39 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/description/typedesc"
|
2022-05-27 06:00:09 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/description/xmlsrvdesc"
|
2022-06-08 16:51:47 +00:00
|
|
|
"github.com/huin/goupnp/v2alpha/soap"
|
2023-03-09 18:23:18 +00:00
|
|
|
"golang.org/x/exp/maps"
|
|
|
|
|
|
|
|
soaptypes "github.com/huin/goupnp/v2alpha/soap/types"
|
2022-05-25 17:27:18 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
2022-06-13 16:44:15 +00:00
|
|
|
formatOutput = flag.Bool("format_output", true, "If true, format the output source code.")
|
2022-06-13 06:28:26 +00:00
|
|
|
outputDir = flag.String("output_dir", "", "Path to directory to write output in.")
|
2022-06-10 06:20:09 +00:00
|
|
|
srvManifests = flag.String("srv_manifests", "", "Path to srvmanifests.toml")
|
2022-06-06 17:00:39 +00:00
|
|
|
srvTemplate = flag.String("srv_template", "", "Path to srv.gotemplate.")
|
2023-03-09 15:40:33 +00:00
|
|
|
upnpresourcesZip = flag.String("upnpresources_zip", "",
|
|
|
|
"Path to upnpresources.zip, downloaded from "+
|
|
|
|
"https://openconnectivity.org/upnp-specs/upnpresources.zip.")
|
2022-05-25 17:27:18 +00:00
|
|
|
)
|
|
|
|
|
2022-06-08 16:51:47 +00:00
|
|
|
const soapActionInterface = "SOAPActionInterface"
|
|
|
|
|
2022-05-25 17:27:18 +00:00
|
|
|
func main() {
|
|
|
|
flag.Parse()
|
|
|
|
if err := run(); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func run() error {
|
|
|
|
if len(flag.Args()) > 0 {
|
|
|
|
return fmt.Errorf("unused arguments: %s", strings.Join(flag.Args(), " "))
|
|
|
|
}
|
2022-06-10 06:20:09 +00:00
|
|
|
|
2022-06-13 06:28:26 +00:00
|
|
|
if *outputDir == "" {
|
2023-03-09 15:42:29 +00:00
|
|
|
return errors.New("-output_dir is a required flag")
|
2022-06-13 06:28:26 +00:00
|
|
|
}
|
|
|
|
if err := os.MkdirAll(*outputDir, 0); err != nil {
|
|
|
|
return fmt.Errorf("creating output_dir %q: %w", *outputDir, err)
|
|
|
|
}
|
|
|
|
|
2022-06-10 06:20:09 +00:00
|
|
|
if *srvManifests == "" {
|
2023-03-09 15:42:29 +00:00
|
|
|
return errors.New("-srv_manifests is a required flag")
|
2022-06-10 06:20:09 +00:00
|
|
|
}
|
|
|
|
var manifests DCPSpecManifests
|
|
|
|
_, err := toml.DecodeFile(*srvManifests, &manifests)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("loading srv_manifests %q: %w", *srvManifests, err)
|
|
|
|
}
|
|
|
|
|
2022-06-06 17:00:39 +00:00
|
|
|
if *srvTemplate == "" {
|
2023-03-09 15:42:29 +00:00
|
|
|
return errors.New("-srv_template is a required flag")
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
tmpl, err := template.New(filepath.Base(*srvTemplate)).Funcs(template.FuncMap{
|
|
|
|
"args": tmplfuncs.Args,
|
|
|
|
"quote": strconv.Quote,
|
|
|
|
}).ParseFiles(*srvTemplate)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("loading srv_template %q: %w", *srvTemplate, err)
|
|
|
|
}
|
|
|
|
|
2022-05-25 17:27:18 +00:00
|
|
|
if *upnpresourcesZip == "" {
|
2023-03-09 15:42:29 +00:00
|
|
|
return errors.New("-upnpresources_zip is a required flag")
|
2022-05-25 17:27:18 +00:00
|
|
|
}
|
|
|
|
f, err := os.Open(*upnpresourcesZip)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
upnpresources, err := zipread.FromOsFile(f)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-06-06 17:00:39 +00:00
|
|
|
|
|
|
|
// Use default type map for now. Addtional types could be use instead or
|
|
|
|
// as well as necessary for extended types.
|
2023-03-09 18:23:18 +00:00
|
|
|
typeMap := soaptypes.TypeMap().Clone()
|
2022-06-08 16:51:47 +00:00
|
|
|
typeMap[soapActionInterface] = typedesc.TypeDesc{
|
2022-06-10 16:54:04 +00:00
|
|
|
GoType: reflect.TypeOf((*soap.Action)(nil)).Elem(),
|
2022-06-08 16:51:47 +00:00
|
|
|
}
|
2022-06-06 17:00:39 +00:00
|
|
|
|
2022-06-10 06:20:09 +00:00
|
|
|
for _, m := range manifests.DCPS {
|
2022-06-13 06:28:26 +00:00
|
|
|
if err := processDCP(upnpresources, m, typeMap, tmpl, *outputDir); err != nil {
|
|
|
|
return fmt.Errorf("processing DCP %s: %w", m.SpecZipPath, err)
|
2022-05-25 17:27:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func processDCP(
|
|
|
|
upnpresources *zipread.ZipRead,
|
|
|
|
manifest *DCPSpecManifest,
|
2022-06-06 17:00:39 +00:00
|
|
|
typeMap typedesc.TypeMap,
|
|
|
|
tmpl *template.Template,
|
2022-06-13 06:28:26 +00:00
|
|
|
parentOutputDir string,
|
2022-05-25 17:27:18 +00:00
|
|
|
) error {
|
2022-06-13 06:28:26 +00:00
|
|
|
outputDir := filepath.Join(parentOutputDir, manifest.OutputDir)
|
|
|
|
if err := os.MkdirAll(outputDir, os.ModePerm); err != nil {
|
|
|
|
return fmt.Errorf("creating output directory %q for DCP: %w", outputDir, err)
|
|
|
|
}
|
|
|
|
dcpSpecData, err := upnpresources.OpenZip(manifest.SpecZipPath)
|
2022-05-25 17:27:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-06-06 17:00:39 +00:00
|
|
|
for _, srvManifest := range manifest.Services {
|
2022-06-13 06:28:26 +00:00
|
|
|
if err := processService(dcpSpecData, srvManifest, typeMap, tmpl, outputDir); err != nil {
|
2022-06-08 16:51:47 +00:00
|
|
|
return fmt.Errorf("processing service %s: %w", srvManifest.ServiceType, err)
|
2022-05-25 17:27:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func processService(
|
|
|
|
dcpSpecData *zipread.ZipRead,
|
2022-06-06 17:00:39 +00:00
|
|
|
srvManifest *ServiceManifest,
|
|
|
|
typeMap typedesc.TypeMap,
|
|
|
|
tmpl *template.Template,
|
2022-06-13 06:28:26 +00:00
|
|
|
parentOutputDir string,
|
2022-05-25 17:27:18 +00:00
|
|
|
) error {
|
2022-06-13 06:28:26 +00:00
|
|
|
outputDir := filepath.Join(parentOutputDir, srvManifest.Package)
|
|
|
|
if err := os.MkdirAll(outputDir, os.ModePerm); err != nil {
|
|
|
|
return fmt.Errorf("creating output directory %q for service: %w", outputDir, err)
|
|
|
|
}
|
|
|
|
|
2022-06-06 17:00:39 +00:00
|
|
|
f, err := dcpSpecData.Open(srvManifest.Path)
|
2022-05-25 17:27:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
d := xml.NewDecoder(f)
|
|
|
|
|
2022-05-27 06:00:09 +00:00
|
|
|
xmlSCPD := &xmlsrvdesc.SCPD{}
|
2022-05-25 17:27:18 +00:00
|
|
|
if err := d.Decode(xmlSCPD); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
xmlSCPD.Clean()
|
|
|
|
|
2022-06-06 17:00:39 +00:00
|
|
|
sd, err := srvdesc.FromXML(xmlSCPD)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("transforming service description: %w", err)
|
|
|
|
}
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
imps := newImports()
|
|
|
|
types, err := accumulateTypes(sd, typeMap, imps)
|
2022-05-25 17:27:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-06-06 17:00:39 +00:00
|
|
|
|
2022-06-13 16:44:15 +00:00
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
err = tmpl.ExecuteTemplate(buf, "service", tmplArgs{
|
2022-06-06 17:03:17 +00:00
|
|
|
Manifest: srvManifest,
|
|
|
|
Imps: imps,
|
2023-03-09 18:23:18 +00:00
|
|
|
Types: types,
|
2022-06-06 17:03:17 +00:00
|
|
|
SCPD: sd,
|
2022-06-06 17:00:39 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("executing srv_template: %w", err)
|
|
|
|
}
|
2022-06-13 16:44:15 +00:00
|
|
|
src := buf.Bytes()
|
|
|
|
if *formatOutput {
|
|
|
|
var err error
|
|
|
|
src, err = format.Source(src)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("formatting output service file: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
outputPath := filepath.Join(outputDir, srvManifest.Package+".go")
|
|
|
|
if err := ioutil.WriteFile(outputPath, src, os.ModePerm); err != nil {
|
|
|
|
return fmt.Errorf("writing output service file %q: %w", outputPath, err)
|
2022-06-13 06:28:26 +00:00
|
|
|
}
|
2022-06-06 17:00:39 +00:00
|
|
|
|
2022-05-25 17:27:18 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-06-10 06:20:09 +00:00
|
|
|
type DCPSpecManifests struct {
|
2022-06-10 06:24:33 +00:00
|
|
|
DCPS []*DCPSpecManifest `toml:"dcp"`
|
2022-06-10 06:20:09 +00:00
|
|
|
}
|
|
|
|
|
2022-05-25 17:27:18 +00:00
|
|
|
type DCPSpecManifest struct {
|
2022-06-13 06:28:26 +00:00
|
|
|
// SpecZipPath is the file path within upnpresources.zip to the DCP spec ZIP file.
|
|
|
|
SpecZipPath string `toml:"spec_zip_path"`
|
|
|
|
// OutputDir is the path relative to --output_dir which the packages are written in.
|
|
|
|
OutputDir string `toml:"output_dir"`
|
2022-05-25 17:27:18 +00:00
|
|
|
// Services maps from a service name (e.g. "FooBar:1") to a path within the DCP spec ZIP file
|
|
|
|
// (e.g. "xml data files/service/FooBar1.xml").
|
2022-06-10 06:24:33 +00:00
|
|
|
Services []*ServiceManifest `toml:"service"`
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type ServiceManifest struct {
|
|
|
|
// Package is the Go package name to generate e.g. "foo1".
|
2022-06-10 06:24:33 +00:00
|
|
|
Package string `toml:"package"`
|
2022-06-08 16:51:47 +00:00
|
|
|
// ServiceType is the SOAP namespace and service type that identifes the service e.g.
|
2022-06-06 17:00:39 +00:00
|
|
|
// "urn:schemas-upnp-org:service:Foo:1"
|
2022-06-10 06:24:33 +00:00
|
|
|
ServiceType string `toml:"type"`
|
2022-06-06 17:00:39 +00:00
|
|
|
// Path within the DCP spec ZIP file e.g. "xml data files/service/Foo1.xml".
|
2022-06-10 06:24:33 +00:00
|
|
|
Path string `toml:"path"`
|
2022-06-13 17:06:03 +00:00
|
|
|
|
|
|
|
// DocumentURL is the URL to the documentation for the service.
|
|
|
|
DocumentURL string `toml:"document_url"`
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type tmplArgs struct {
|
2022-06-06 17:03:17 +00:00
|
|
|
Manifest *ServiceManifest
|
|
|
|
Imps *imports
|
2023-03-09 18:23:18 +00:00
|
|
|
Types *types
|
2022-06-06 17:03:17 +00:00
|
|
|
SCPD *srvdesc.SCPD
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type imports struct {
|
|
|
|
// Each required import line, ordered by path.
|
|
|
|
ImportLines []importItem
|
2023-03-09 18:23:18 +00:00
|
|
|
// aliasByPath maps from import path to its imported alias.
|
|
|
|
aliasByPath map[string]string
|
|
|
|
// nextAlias is the number for the next import alias.
|
|
|
|
nextAlias int
|
|
|
|
}
|
|
|
|
|
|
|
|
func newImports() *imports {
|
|
|
|
return &imports{
|
|
|
|
aliasByPath: make(map[string]string),
|
|
|
|
nextAlias: 1,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (imps *imports) getAliasForPath(path string) string {
|
|
|
|
if alias, ok := imps.aliasByPath[path]; ok {
|
|
|
|
return alias
|
|
|
|
}
|
|
|
|
alias := fmt.Sprintf("pkg%d", imps.nextAlias)
|
|
|
|
imps.nextAlias++
|
|
|
|
imps.ImportLines = append(imps.ImportLines, importItem{
|
|
|
|
Alias: alias,
|
|
|
|
Path: path,
|
|
|
|
})
|
|
|
|
imps.aliasByPath[path] = alias
|
|
|
|
return alias
|
|
|
|
}
|
|
|
|
|
|
|
|
type types struct {
|
|
|
|
// Maps from a type name like "ui4" to the `alias.name` for the import.
|
|
|
|
TypeByName map[string]typeDesc
|
|
|
|
StringVarDefs []stringVarDef
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
2022-06-08 17:13:28 +00:00
|
|
|
type typeDesc struct {
|
|
|
|
// How to refer to the type, e.g. `pkg.Name`.
|
|
|
|
Ref string
|
|
|
|
// How to refer to the type absolutely (but not valid Go), e.g.
|
|
|
|
// `"github.com/foo/bar/pkg".Name`.
|
|
|
|
AbsRef string
|
|
|
|
// Name of the type without package, e.g. `Name`.
|
|
|
|
Name string
|
|
|
|
}
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
type stringVarDef struct {
|
|
|
|
Name string
|
|
|
|
AllowedValues []string
|
|
|
|
}
|
|
|
|
|
2022-06-06 17:00:39 +00:00
|
|
|
type importItem struct {
|
|
|
|
Alias string
|
|
|
|
Path string
|
|
|
|
}
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
// accumulateTypes creates type information, and adds any required imports for
|
|
|
|
// them.
|
|
|
|
func accumulateTypes(
|
|
|
|
srvDesc *srvdesc.SCPD,
|
|
|
|
typeMap typedesc.TypeMap,
|
|
|
|
imps *imports,
|
|
|
|
) (*types, error) {
|
|
|
|
typeNames := make(map[string]struct{})
|
|
|
|
typeNames[soapActionInterface] = struct{}{}
|
|
|
|
|
|
|
|
var stringVarDefs []stringVarDef
|
|
|
|
sortedVarNames := maps.Keys(srvDesc.VariableByName)
|
|
|
|
sort.Strings(sortedVarNames)
|
|
|
|
for _, svName := range sortedVarNames {
|
|
|
|
sv := srvDesc.VariableByName[svName]
|
|
|
|
if sv.DataType == "string" && len(sv.AllowedValues) > 0 {
|
|
|
|
stringVarDefs = append(stringVarDefs, stringVarDef{
|
|
|
|
Name: svName,
|
|
|
|
AllowedValues: srvDesc.VariableByName[svName].AllowedValues,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
2022-06-08 16:51:47 +00:00
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
err := visitTypesSCPD(srvDesc, func(sv *srvdesc.StateVariable) {
|
|
|
|
typeNames[sv.DataType] = struct{}{}
|
2022-06-06 17:00:39 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Have sorted list of import package paths. Partly for aesthetics of generated code, but also
|
|
|
|
// to have stable-generated aliases.
|
2023-03-09 18:23:18 +00:00
|
|
|
paths := make(map[string]struct{})
|
2022-06-06 17:00:39 +00:00
|
|
|
for typeName := range typeNames {
|
|
|
|
t, ok := typeMap[typeName]
|
|
|
|
if !ok {
|
|
|
|
return nil, fmt.Errorf("unknown type %q", typeName)
|
|
|
|
}
|
|
|
|
pkgPath := t.GoType.PkgPath()
|
|
|
|
if pkgPath == "" {
|
2023-03-09 18:23:18 +00:00
|
|
|
// Builtin type, no import needed.
|
2022-06-06 17:00:39 +00:00
|
|
|
continue
|
|
|
|
}
|
2023-03-09 18:23:18 +00:00
|
|
|
paths[pkgPath] = struct{}{}
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
2023-03-09 18:23:18 +00:00
|
|
|
sortedPaths := maps.Keys(paths)
|
2022-06-06 17:00:39 +00:00
|
|
|
sort.Strings(sortedPaths)
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
// Generate import aliases in deterministic order.
|
2022-06-06 17:00:39 +00:00
|
|
|
for _, path := range sortedPaths {
|
2023-03-09 18:23:18 +00:00
|
|
|
imps.getAliasForPath(path)
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
2022-06-08 17:13:28 +00:00
|
|
|
// Populate typeByName.
|
|
|
|
typeByName := make(map[string]typeDesc, len(typeNames))
|
2022-06-06 17:00:39 +00:00
|
|
|
for typeName := range typeNames {
|
|
|
|
goType := typeMap[typeName]
|
|
|
|
pkgPath := goType.GoType.PkgPath()
|
2022-06-08 17:13:28 +00:00
|
|
|
td := typeDesc{
|
|
|
|
Name: goType.GoType.Name(),
|
|
|
|
}
|
2023-03-09 18:23:18 +00:00
|
|
|
if pkgPath == "" {
|
2022-06-06 17:00:39 +00:00
|
|
|
// Builtin type.
|
2022-06-08 17:13:28 +00:00
|
|
|
td.AbsRef = td.Name
|
|
|
|
td.Ref = td.Name
|
2022-06-06 17:00:39 +00:00
|
|
|
} else {
|
2022-06-08 17:13:28 +00:00
|
|
|
td.AbsRef = strconv.Quote(pkgPath) + "." + td.Name
|
2023-03-09 18:23:18 +00:00
|
|
|
td.Ref = imps.getAliasForPath(pkgPath) + "." + td.Name
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
2022-06-08 17:13:28 +00:00
|
|
|
typeByName[typeName] = td
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
return &types{
|
|
|
|
TypeByName: typeByName,
|
|
|
|
StringVarDefs: stringVarDefs,
|
2022-06-06 17:00:39 +00:00
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
2023-03-09 18:23:18 +00:00
|
|
|
type typeVisitor func(sv *srvdesc.StateVariable)
|
2022-06-06 17:00:39 +00:00
|
|
|
|
|
|
|
// visitTypesSCPD calls `visitor` with each data type name (e.g. "ui4") referenced
|
|
|
|
// by action arguments.`
|
|
|
|
func visitTypesSCPD(scpd *srvdesc.SCPD, visitor typeVisitor) error {
|
|
|
|
for _, action := range scpd.ActionByName {
|
|
|
|
if err := visitTypesAction(action, visitor); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func visitTypesAction(action *srvdesc.Action, visitor typeVisitor) error {
|
|
|
|
for _, arg := range action.InArgs {
|
|
|
|
sv, err := arg.RelatedStateVariable()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-03-09 18:23:18 +00:00
|
|
|
visitor(sv)
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
for _, arg := range action.OutArgs {
|
|
|
|
sv, err := arg.RelatedStateVariable()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-03-09 18:23:18 +00:00
|
|
|
visitor(sv)
|
2022-06-06 17:00:39 +00:00
|
|
|
}
|
|
|
|
return nil
|
2022-05-25 17:27:18 +00:00
|
|
|
}
|