Skip to content

Commit 0b723bf

Browse files
committed
Drop flat structs for complex union types.
Drop the flat struct pattern for representing tagged unions with multiple value types, and instead use interfaces with variant types. Note that we also fix pre-existing bugs related to marshalling and unmarshalling interface wrapper types. We now correctly serialize nil interfaces to `null`, rather than `{"type": ""}`, and correspondingly unmarshal null oneOf fields to `nil`.
1 parent 44dc2f2 commit 0b723bf

8 files changed

Lines changed: 4269 additions & 1295 deletions

File tree

DESIGN.md

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ to different `serde` tagging strategies, and we handle each of them differently.
2323

2424
### Tagged union
2525

26-
When a `oneOf` has:
26+
When a `oneOf` has exactly one discriminator property (a field with a single enum value per
27+
variant), we generate an **interface with variant types** pattern. This covers both
28+
`serde(tag = "type", content = "value")` unions (where variants carry data in a `value` field) and
29+
`serde(tag = "type")` unions (where variant fields are inlined at the top level).
2730

28-
1. Exactly one discriminator property (a field with a single enum value per variant)
29-
2. Exactly one multi-type property (a field whose type varies across variants)
30-
31-
We generate an **interface with variant wrapper types** pattern.
32-
33-
**Example: `PrivateIpStack`**
31+
**Example: `PrivateIpStack`** (tag + content)
3432

3533
In Rust, `PrivateIpStack` is defined as:
3634

@@ -210,13 +208,7 @@ confusing to end users than consistent use of wrappers.
210208
Note: we can reconsider this choice if we're able to drop the use of `interface{}` types and
211209
pointers to primitives for variants, and if we're confident that those cases won't emerge again.
212210

213-
### Discriminator with multiple value fields
214-
215-
When a `oneOf` has a discriminator field and _multiple_ value fields, we use a flat struct that
216-
contains all properties from all variants. Properties that have different types across variants
217-
become `any`.
218-
219-
**Example: `DiskSource`**
211+
**Example: `DiskSource`** (tag only)
220212

221213
In Rust, `DiskSource` is defined as:
222214

@@ -253,20 +245,65 @@ DiskSource:
253245
block_size: { $ref: "#/components/schemas/BlockSize" }
254246
```
255247
256-
This has a discriminator (`type`) but no multi-type property. Each variant has different fields
257-
(`block_size`, `snapshot_id`, `image_id`), not different types for the same field. So we generate a
258-
flat struct:
248+
This has a discriminator (`type`) and each variant has different fields. Each variant struct contains
249+
only its own fields (without the discriminator):
259250

260251
```go
252+
type diskSourceVariant interface {
253+
isDiskSourceVariant()
254+
}
255+
256+
type DiskSourceBlank struct {
257+
BlockSize BlockSize `json:"block_size,omitempty"`
258+
}
259+
func (DiskSourceBlank) isDiskSourceVariant() {}
260+
261+
type DiskSourceSnapshot struct {
262+
SnapshotId string `json:"snapshot_id,omitempty"`
263+
}
264+
func (DiskSourceSnapshot) isDiskSourceVariant() {}
265+
266+
type DiskSourceImage struct {
267+
ImageId string `json:"image_id,omitempty"`
268+
}
269+
func (DiskSourceImage) isDiskSourceVariant() {}
270+
271+
type DiskSourceImportingBlocks struct {
272+
BlockSize BlockSize `json:"block_size,omitempty"`
273+
}
274+
func (DiskSourceImportingBlocks) isDiskSourceVariant() {}
275+
261276
type DiskSource struct {
262-
BlockSize BlockSize `json:"block_size,omitempty"`
263-
Type DiskSourceType `json:"type,omitempty"`
264-
SnapshotId string `json:"snapshot_id,omitempty"`
265-
ImageId string `json:"image_id,omitempty"`
277+
Value diskSourceVariant
266278
}
267279
```
268280

269-
If any property had different types across variants, it would become `any`.
281+
**Usage examples:**
282+
283+
```go
284+
// Creating a disk from a snapshot
285+
params := oxide.DiskCreateParams{
286+
Body: &oxide.DiskCreate{
287+
Name: "my-disk",
288+
DiskSource: oxide.DiskSource{
289+
Value: &oxide.DiskSourceSnapshot{
290+
SnapshotId: "snapshot-uuid",
291+
},
292+
},
293+
},
294+
}
295+
```
296+
297+
```go
298+
// Reading a disk source from the API
299+
disk, _ := client.DiskView(ctx, params)
300+
switch v := disk.DiskSource.Value.(type) {
301+
case *oxide.DiskSourceSnapshot:
302+
fmt.Printf("From snapshot: %s\n", v.SnapshotId)
303+
case *oxide.DiskSourceImage:
304+
fmt.Printf("From image: %s\n", v.ImageId)
305+
}
306+
```
270307

271308
### Untagged union
272309

internal/generate/exceptions.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ func emptyTypes() []string {
2222
}
2323

2424
func nullable() []string {
25-
// TODO: This type has a nested required "Type" field, which hinders
26-
// the usage of this type. Remove when this is fixed in the upstream API
2725
return []string{
28-
"InstanceDiskAttachment",
2926
"LldpLinkConfig",
3027
"TxEqConfig",
3128
"TxEqConfig2",

internal/generate/templates/union_tagged.go.tpl

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ func (v {{.TypeName}}) {{.DiscriminatorMethod}}() {{.DiscriminatorType}} {
1010
}
1111

1212
func (v *{{.TypeName}}) UnmarshalJSON(data []byte) error {
13+
if string(data) == "null" {
14+
return nil
15+
}
1316
type discriminator struct {
1417
Type string `json:"{{.Discriminator}}"`
1518
}
@@ -35,20 +38,21 @@ func (v *{{.TypeName}}) UnmarshalJSON(data []byte) error {
3538
}
3639

3740
func (v {{.TypeName}}) MarshalJSON() ([]byte, error) {
41+
if v.{{.ValueFieldName}} == nil {
42+
return []byte("null"), nil
43+
}
3844
m := make(map[string]any)
3945
m["{{.Discriminator}}"] = v.{{.DiscriminatorMethod}}()
40-
if v.{{.ValueFieldName}} != nil {
41-
valueBytes, err := json.Marshal(v.{{.ValueFieldName}})
42-
if err != nil {
43-
return nil, err
44-
}
45-
var valueMap map[string]any
46-
if err := json.Unmarshal(valueBytes, &valueMap); err != nil {
47-
return nil, err
48-
}
49-
for k, val := range valueMap {
50-
m[k] = val
51-
}
46+
valueBytes, err := json.Marshal(v.{{.ValueFieldName}})
47+
if err != nil {
48+
return nil, err
49+
}
50+
var valueMap map[string]any
51+
if err := json.Unmarshal(valueBytes, &valueMap); err != nil {
52+
return nil, err
53+
}
54+
for k, val := range valueMap {
55+
m[k] = val
5256
}
5357
return json.Marshal(m)
5458
}

internal/generate/test_utils/types_output

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,109 @@ type DiskIdentifier struct {
1919
}
2020

2121

22+
// diskSourceVariant is implemented by DiskSource variants.
23+
type diskSourceVariant interface {
24+
isDiskSourceVariant()
25+
}
26+
27+
2228
// DiskSourceType is the type definition for a DiskSourceType.
2329
type DiskSourceType string
2430

25-
// DiskSourceSnapshot is create a disk from a disk snapshot
31+
// DiskSourceSnapshot is a variant of DiskSource.
2632
type DiskSourceSnapshot struct {
2733
SnapshotId string `json:"snapshot_id,omitempty" yaml:"snapshot_id,omitempty"`
28-
Type DiskSourceType `json:"type,omitempty" yaml:"type,omitempty"`
2934
}
3035

36+
func (DiskSourceSnapshot) isDiskSourceVariant() {}
3137

32-
// DiskSourceImage is create a disk from a project image
38+
39+
// DiskSourceImage is a variant of DiskSource.
3340
type DiskSourceImage struct {
3441
ImageId string `json:"image_id,omitempty" yaml:"image_id,omitempty"`
35-
Type DiskSourceType `json:"type,omitempty" yaml:"type,omitempty"`
3642
}
3743

44+
func (DiskSourceImage) isDiskSourceVariant() {}
45+
3846

3947
// DiskSource is the type definition for a DiskSource.
4048
type DiskSource struct {
41-
// SnapshotId is the type definition for a SnapshotId.
42-
SnapshotId string `json:"snapshot_id,omitempty" yaml:"snapshot_id,omitempty"`
43-
// Type is the type definition for a Type.
44-
Type DiskSourceType `json:"type,omitempty" yaml:"type,omitempty"`
45-
// ImageId is the type definition for a ImageId.
46-
ImageId string `json:"image_id,omitempty" yaml:"image_id,omitempty"`
49+
Value diskSourceVariant
50+
}
51+
52+
func (v DiskSource) Type() DiskSourceType {
53+
switch v.Value.(type) {
54+
case DiskSourceSnapshot, *DiskSourceSnapshot:
55+
return DiskSourceTypeSnapshot
56+
case DiskSourceImage, *DiskSourceImage:
57+
return DiskSourceTypeImage
58+
default:
59+
return ""
60+
}
61+
}
62+
63+
func (v *DiskSource) UnmarshalJSON(data []byte) error {
64+
if string(data) == "null" {
65+
return nil
66+
}
67+
type discriminator struct {
68+
Type string `json:"type"`
69+
}
70+
var d discriminator
71+
if err := json.Unmarshal(data, &d); err != nil {
72+
return err
73+
}
74+
75+
var value diskSourceVariant
76+
switch d.Type {
77+
case "snapshot":
78+
value = &DiskSourceSnapshot{}
79+
case "image":
80+
value = &DiskSourceImage{}
81+
default:
82+
return fmt.Errorf("unknown variant %q, expected 'snapshot' or 'image'", d.Type)
83+
}
84+
if err := json.Unmarshal(data, value); err != nil {
85+
return err
86+
}
87+
v.Value = value
88+
return nil
89+
}
90+
91+
func (v DiskSource) MarshalJSON() ([]byte, error) {
92+
if v.Value == nil {
93+
return []byte("null"), nil
94+
}
95+
m := make(map[string]any)
96+
m["type"] = v.Type()
97+
valueBytes, err := json.Marshal(v.Value)
98+
if err != nil {
99+
return nil, err
100+
}
101+
var valueMap map[string]any
102+
if err := json.Unmarshal(valueBytes, &valueMap); err != nil {
103+
return nil, err
104+
}
105+
for k, val := range valueMap {
106+
m[k] = val
107+
}
108+
return json.Marshal(m)
109+
}
110+
111+
112+
113+
// AsSnapshot attempts to convert the DiskSource to a DiskSourceSnapshot.
114+
// Returns the variant and true if the conversion succeeded, nil and false otherwise.
115+
func (v DiskSource) AsSnapshot() (*DiskSourceSnapshot, bool) {
116+
val, ok := v.Value.(*DiskSourceSnapshot)
117+
return val, ok
118+
}
119+
120+
// AsImage attempts to convert the DiskSource to a DiskSourceImage.
121+
// Returns the variant and true if the conversion succeeded, nil and false otherwise.
122+
func (v DiskSource) AsImage() (*DiskSourceImage, bool) {
123+
val, ok := v.Value.(*DiskSourceImage)
124+
return val, ok
47125
}
48126

49127

0 commit comments

Comments
 (0)