Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go/tools/sdkgen/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 4 additions & 0 deletions go/tools/sdkgen/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
86 changes: 86 additions & 0 deletions go/tools/sdkgen/internal/sdk/sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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)
}
}
127 changes: 127 additions & 0 deletions go/tools/sdkgen/templates/model.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package {{ .Var "package" "client" }}

import (
"encoding/json"
"fmt"
"time"

jsonv2 "github.com/go-json-experiment/json"
Expand All @@ -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 }}
Expand All @@ -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 }}
//
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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