diff --git a/go/tools/sdkgen/go.mod b/go/tools/sdkgen/go.mod index deff46e..f65524f 100644 --- a/go/tools/sdkgen/go.mod +++ b/go/tools/sdkgen/go.mod @@ -9,7 +9,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 unikraft.com/x/kingkong v0.0.0-20260713183529-fd34645687a0 unikraft.com/x/log v0.0.0-20260811021335-23b51b9e5a8a - unikraft.com/x/tools/openapi-gen v0.0.0-20260811021335-23b51b9e5a8a + unikraft.com/x/tools/openapi-gen v0.0.0-20260903102000-c4a61ca6aa52 unikraft.com/x/version v0.0.0-20260811021335-23b51b9e5a8a ) diff --git a/go/tools/sdkgen/go.sum b/go/tools/sdkgen/go.sum index 0f03c40..d67cca6 100644 --- a/go/tools/sdkgen/go.sum +++ b/go/tools/sdkgen/go.sum @@ -225,5 +225,9 @@ unikraft.com/x/log v0.0.0-20260811021335-23b51b9e5a8a h1:CrQjDNeIxPN9G7SyE2o5p6F unikraft.com/x/log v0.0.0-20260811021335-23b51b9e5a8a/go.mod h1:ZjQzJJ01mh+h5ISIxbh0TzOmO9Bweq/1gYSIjLU4QKo= unikraft.com/x/tools/openapi-gen v0.0.0-20260811021335-23b51b9e5a8a h1:Ix/Tizl41Wqu7sRhWQEqQ+dJHWq7hCl6sHqvUW7CiOM= unikraft.com/x/tools/openapi-gen v0.0.0-20260811021335-23b51b9e5a8a/go.mod h1:yEtLUP0qVbrhrTDe84nxpvvRlIJwJkhaecjw14srjS8= +unikraft.com/x/tools/openapi-gen v0.0.0-20260902145135-6f309413d5f6 h1:MTs0Gwfkzq+nyGAKlfwqSPX0raxBAOMTrHNKgiWZoJE= +unikraft.com/x/tools/openapi-gen v0.0.0-20260902145135-6f309413d5f6/go.mod h1:yEtLUP0qVbrhrTDe84nxpvvRlIJwJkhaecjw14srjS8= +unikraft.com/x/tools/openapi-gen v0.0.0-20260903102000-c4a61ca6aa52 h1:VEohIkBfXvS/70ZfVhx+rhppCxhm43hQZyWbxqiblN0= +unikraft.com/x/tools/openapi-gen v0.0.0-20260903102000-c4a61ca6aa52/go.mod h1:yEtLUP0qVbrhrTDe84nxpvvRlIJwJkhaecjw14srjS8= unikraft.com/x/version v0.0.0-20260811021335-23b51b9e5a8a h1:+gz1NErGeAIUF69ImIO9SPLwBhIhAxNhNXhYQZvD2jQ= unikraft.com/x/version v0.0.0-20260811021335-23b51b9e5a8a/go.mod h1:JFPFNcsa3935zxXNdL7Xm4Tfku/I4shRB1MMiNOU4p4= diff --git a/go/tools/sdkgen/internal/sdk/sdk_test.go b/go/tools/sdkgen/internal/sdk/sdk_test.go index 646b118..6ea878f 100644 --- a/go/tools/sdkgen/internal/sdk/sdk_test.go +++ b/go/tools/sdkgen/internal/sdk/sdk_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" @@ -299,3 +300,88 @@ func TestPublishRejectsMissingGoMod(t *testing.T) { t.Fatal("Publish() accepted a module without a go.mod") } } + +// unionSpec is a minimal specification whose single model carries an `anyOf` +// property of two distinct shapes, plus the named array schema one of its +// branches references. +const unionSpec = `openapi: 3.0.0 +info: + title: Union + version: v1.0.0 +paths: {} +components: + schemas: + ArgvSpec: + type: array + items: + type: string + RunRequest: + type: object + required: [cmd] + properties: + cmd: + anyOf: + - type: string + - $ref: '#/components/schemas/ArgvSpec' +` + +// renderSpec renders spec and returns the generated model source. +func renderSpec(t *testing.T, spec string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "api.yaml") + if err := os.WriteFile(path, []byte(spec), filePerm); err != nil { + t.Fatal(err) + } + + module, err := sdk.Render(t.Context(), sdk.Options{ + SpecPath: path, + Module: sdk.PublishedModulePath(pluginName), + Package: pluginName, + }) + if err != nil { + t.Fatal(err) + } + + for _, f := range module.Files { + if f.Name == "model.gen.go" { + return string(f.Data) + } + } + + t.Fatal("no model.gen.go was rendered") + + return "" +} + +func TestRenderAnyOfPropertyAsUnion(t *testing.T) { + t.Parallel() + + model := renderSpec(t, unionSpec) + + for _, want := range []string{ + "type ArgvUnion interface", + "func UnmarshalArgvUnion(v jsontext.Value) (ArgvUnion, error)", + "Cmd ArgvUnion `json:\"cmd\"`", + } { + if !strings.Contains(model, want) { + t.Errorf("model does not declare %q", want) + } + } + + if strings.Contains(model, "interface{}") { + t.Error("an anyOf property was degraded to interface{}") + } +} + +func TestRenderNonObjectSchemaAsDefinedType(t *testing.T) { + t.Parallel() + + model := renderSpec(t, unionSpec) + + // A named array schema is the type it encodes to, not a struct: rendering + // it as one leaves the union unable to decode the branch referencing it. + if want := "type ArgvSpec []string"; !strings.Contains(model, want) { + t.Errorf("model does not declare %q", want) + } +} diff --git a/go/tools/sdkgen/templates/model.go.tmpl b/go/tools/sdkgen/templates/model.go.tmpl index 1839b3a..0bbd230 100644 --- a/go/tools/sdkgen/templates/model.go.tmpl +++ b/go/tools/sdkgen/templates/model.go.tmpl @@ -4,6 +4,7 @@ package {{ .Var "package" "client" }} import ( "encoding/json" + "fmt" "time" jsonv2 "github.com/go-json-experiment/json" @@ -15,10 +16,27 @@ var ( _ = time.Time{} _ = json.Marshal _ = jsonv2.Marshal + _ = fmt.Errorf ) +{{- /* + A property declared as an `anyOf` of two or more distinct shapes has no + single Go type. Rather than degrade it to `interface{}` and leave callers + to hand-roll the encoding, "goUnions" describes each as a closed union: a + marker interface, a named type per branch that is not already a schema of + its own, and a decoder dispatching on the JSON kind of the value. + + $unions.Unions the unions to declare, ordered by name + $unions.Fields "Schema.prop" -> union name + + sdkgen renders every plugin from the same templates, so unions keep the + names goUnions derives for them: there is no per-spec override map here, + as there is in the hand-maintained platform SDK. +*/}} +{{- $unions := goUnions }} {{ range .Models }} {{- $schema := .Schema }} {{- $schemaName := .SchemaName }} +{{- $openAPIType := getType $schema }} {{- if $schema.Enum }} // {{ $schemaName }} defines the {{ $schemaName }} enum type. type {{ $schemaName }} {{ enumBaseGoType $schema }} @@ -28,7 +46,19 @@ const ( {{ $schemaName }}{{ pascalcase (printf "%v" .) }} {{ $schemaName }} = {{ enumValue $schema . }} {{- end }} ) +{{- else if and (ne $openAPIType "object") (not $schema.Properties) }} +{{- /* A named schema of a non-object shape — an array of strings, say — is the + type it encodes to under a name of its own, not a struct. */}} +// {{ $schemaName }} defines the {{ $schemaName }} type. +{{- if $schema.Description }} +// +// {{ wrapComment $schema.Description 76 "// " }} +{{- end }} +type {{ $schemaName }} {{ schemaToGoType $schema }} {{- else }} +{{- /* Members typed as a union interface, collected as the struct is rendered + and decoded separately in UnmarshalJSON below. */}} +{{- $unionFields := list }} // {{ $schemaName }} defines the {{ $schemaName }} type. {{- if $schema.Description }} // @@ -40,8 +70,16 @@ type {{ $schemaName }} struct { {{- if $prop }} {{- $propType := schemaToGoType $prop }} {{- $required := getPropertyRequired $schema . }} +{{- $union := index $unions.Fields (printf "%s.%s" $schemaName .) }} +{{- if $union }} +{{- /* A nil interface already means "not set", so a union member is never a + pointer, required or not. */}} +{{- $unionFields = append $unionFields (dict "Field" (pascalcase .) "JSON" . "Union" $union) }} + {{ pascalcase . }} {{ $union }} `json:"{{ . }}{{ if not $required }},omitempty{{ end }}"` +{{- else }} {{ pascalcase . }} {{ if and (not $required) (not (hasPrefix "[]" $propType)) }}*{{ end }}{{ $propType }} `json:"{{ . }}{{ if not $required }},omitempty{{ end }}"` {{- end }} +{{- end }} {{- end }} // AdditionalProperties captures any JSON object members that do not map to @@ -53,7 +91,37 @@ type {{ $schemaName }} struct { // (tagged `json:",embed"`) is populated with any unknown object members. func (m *{{ $schemaName }}) UnmarshalJSON(data []byte) error { type Alias {{ $schemaName }} +{{- if $unionFields }} + // Union members are decoded in a second step: a nil interface cannot be + // decoded into directly. Holding them as raw JSON ahead of the embedded + // alias shadows the alias' own members of the same name. An absent member + // leaves the current value in place, whereas an explicit null clears it. + aux := struct { +{{- range $u := $unionFields }} + {{ $u.Field }} jsontext.Value `json:"{{ $u.JSON }},omitzero"` +{{- end }} + *Alias + }{Alias: (*Alias)(m)} + if err := jsonv2.Unmarshal(data, &aux); err != nil { + return err + } +{{- range $u := $unionFields }} + if len(aux.{{ $u.Field }}) > 0 { + if aux.{{ $u.Field }}.Kind() == 'n' { + m.{{ $u.Field }} = nil + } else { + value, err := Unmarshal{{ $u.Union }}(aux.{{ $u.Field }}) + if err != nil { + return err + } + m.{{ $u.Field }} = value + } + } +{{- end }} + return nil +{{- else }} return jsonv2.Unmarshal(data, (*Alias)(m)) +{{- end }} } // MarshalJSON delegates to go-json-experiment so that AdditionalProperties @@ -75,5 +143,64 @@ const ( {{- end }} {{- end }} {{ end }} +{{- range $union := $unions.Unions }} +// {{ $union.Name }} is a closed union: exactly one of {{ join ", " $union.TypeNames }}. +// +// The zero value (nil) means "not set" and is omitted when marshalling. +type {{ $union.Name }} interface { + is{{ $union.Name }}() +} +{{ range $v := $union.Variants }} +{{- if $v.Declare }} +{{- if $v.Doc }} +// {{ wrapComment $v.Doc 76 "// " }} +{{- end }} +type {{ $v.Type }} {{ $v.Underlying }} + +{{ end }} +{{- end }} +{{- range $v := $union.Variants }} +func ({{ $v.Type }}) is{{ $union.Name }}() {} +{{ end }} +// Unmarshal{{ $union.Name }} decodes v into whichever {{ $union.Name }} variant +// matches the kind of the JSON value. +func Unmarshal{{ $union.Name }}(v jsontext.Value) ({{ $union.Name }}, error) { + // The kind of the JSON value narrows the candidates but does not prove a + // match: a failed decode falls through to the variants that share the kind, + // and its error is only reported once none of them matched either. + var err error + + switch v.Kind() { +{{- range $v := $union.Variants }} +{{- if $v.Kinds }} + case {{ join ", " $v.Kinds }}: + var out {{ $v.Type }} + if err = jsonv2.Unmarshal(v, &out); err == nil { + return out, nil + } +{{- end }} +{{- end }} + } +{{- range $v := $union.Variants }} +{{- if not $v.Kinds }} + + // {{ $v.Type }} shares its JSON kind with an earlier variant, so it can only + // be attempted speculatively. + { + var out {{ $v.Type }} + if err := jsonv2.Unmarshal(v, &out); err == nil { + return out, nil + } + } +{{- end }} +{{- end }} + + if err != nil { + return nil, err + } + + return nil, fmt.Errorf("cannot unmarshal %v into {{ $union.Name }}", v.Kind()) +} +{{ end }} // JSON is a raw JSON value. type JSON = json.RawMessage