Skip to content
Draft
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
205 changes: 197 additions & 8 deletions DESIGN.md
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This design makes sense to me! My only question is do we only use this untagged style when the variants are the same type? I know in Rust they aren't the same type but I mean their serialized form. That is, IpNet variants are all string and IpRange are all struct{first, last}.

Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,21 @@ If any property had different types across variants, it would become `any`.

### Untagged union

When a `oneOf` has no object properties (i.e., variants are primitive types or references wrapped in
`allOf`), the type becomes `interface{}`.
When a `oneOf` schema has no discriminator property (i.e., it's defined in Rust using
`serde(untagged)`), we can't use the discriminator to determine the correct variant type for
unmarshalling. Instead, if the variants use OpenAPI `format` or `pattern` fields, we use those to
choose the variant type. In this case, we use the interface with marker methods pattern, as for
tagged unions.

**Example: `IpNet`**
Untagged unions are detected when:

1. Each variant is an `allOf` wrapper containing a single `$ref`
2. The referenced types can be discriminated by either:
- **Format-based**: Object types where fields have distinct `format` values (e.g., `ipv4` vs
`ipv6`)
- **Pattern-based**: String types with distinct regex `pattern` values

**Example: `IpNet` (pattern-based)**

In Rust, `IpNet` is defined as:

Expand All @@ -284,13 +295,191 @@ IpNet:
- title: v6
allOf:
- $ref: "#/components/schemas/Ipv6Net"

Ipv4Net:
type: string
pattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"

Ipv6Net:
type: string
pattern: "^[0-9a-fA-F:]+/[0-9]{1,3}$"
```

Since `Ipv4Net` and `Ipv6Net` are string types with distinct regex patterns, we generate:

```go
type IpNet interface{}
// Interface with marker method
type ipNetVariant interface {
isIpNetVariant()
}

// Marker methods on existing types
func (Ipv4Net) isIpNetVariant() {}
func (Ipv6Net) isIpNetVariant() {}

// Wrapper struct
type IpNet struct {
Value ipNetVariant `json:"value,omitempty"`
}

// Pattern-based discrimination using compiled regexes
var (
ipv4netPattern = regexp.MustCompile(`^...`)
ipv6netPattern = regexp.MustCompile(`^...`)
)

func (v *IpNet) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
if ipv4netPattern.MatchString(s) {
val := Ipv4Net(s)
v.Value = &val
return nil
}
if ipv6netPattern.MatchString(s) {
val := Ipv6Net(s)
v.Value = &val
return nil
}
return fmt.Errorf("no pattern matched for IpNet")
}
```

Note: we may be able to handle these types better in the future. For example, we could detect that
all variants are effectively strings and represent `IpNet` as `string`. Alternatively, we could
represent `Ipv4Net` and `Ipv6Net` as distinct types with their own validation logic, and attempt to
unmarshal into each variant type until we find a match.
**Example: `IpRange` (format-based)**

In Rust, `IpRange` is defined as:

```rust
#[serde(untagged)]
pub enum IpRange {
V4(Ipv4Range),
V6(Ipv6Range),
}
```

This generates the following OpenAPI spec:

```yaml
IpRange:
oneOf:
- title: v4
allOf:
- $ref: "#/components/schemas/Ipv4Range"
- title: v6
allOf:
- $ref: "#/components/schemas/Ipv6Range"

Ipv4Range:
type: object
properties:
first: { type: string, format: ipv4 }
last: { type: string, format: ipv4 }

Ipv6Range:
type: object
properties:
first: { type: string, format: ipv6 }
last: { type: string, format: ipv6 }
```

Since `Ipv4Range` and `Ipv6Range` have fields with distinct `format` values, we generate:

```go
// Interface with marker method
type ipRangeVariant interface {
isIpRangeVariant()
}

// Marker methods on existing types
func (Ipv4Range) isIpRangeVariant() {}
func (Ipv6Range) isIpRangeVariant() {}

// Wrapper struct
type IpRange struct {
Value ipRangeVariant `json:"value,omitempty"`
}

// Format detection functions (generated code uses formatDetectors map)
func detectIpv4RangeFormat(v *Ipv4Range) bool {
if !formatDetectors["ipv4"](v.First) {
return false
}
if !formatDetectors["ipv4"](v.Last) {
return false
}
return true
}

func detectIpv6RangeFormat(v *Ipv6Range) bool {
if !formatDetectors["ipv6"](v.First) {
return false
}
if !formatDetectors["ipv6"](v.Last) {
return false
}
return true
}

func (v *IpRange) UnmarshalJSON(data []byte) error {
// Try Ipv4Range
{
var candidate Ipv4Range
if err := json.Unmarshal(data, &candidate); err == nil {
if detectIpv4RangeFormat(&candidate) {
v.Value = &candidate
return nil
}
}
}
// Try Ipv6Range
{
var candidate Ipv6Range
if err := json.Unmarshal(data, &candidate); err == nil {
if detectIpv6RangeFormat(&candidate) {
v.Value = &candidate
return nil
}
}
}
return fmt.Errorf("no variant matched for IpRange")
}
```

Note that we only use the `format` and `pattern` fields for variant type detection, not for
validation. In the future, we may consider validating based on `format` and/or `pattern` during
unmarshalling, marshalling, or both. For now, we trust the API to send valid data and error when
receiving bad data.

**Usage examples:**

```go
// Reading an IP range from the API
poolRange, _ := client.IpPoolRangeList(ctx, params)
for _, item := range poolRange.Items {
switch v := item.Range.Value.(type) {
case *oxide.Ipv4Range:
fmt.Printf("IPv4: %s - %s\n", v.First, v.Last)
case *oxide.Ipv6Range:
fmt.Printf("IPv6: %s - %s\n", v.First, v.Last)
}
}
```

```go
// Creating an IP range
ipRange := oxide.IpRange{Value: &oxide.Ipv4Range{
First: "192.168.1.1",
Last: "192.168.1.100",
}}
```

**Fallback behavior:**

If we cannot distinguish between variant types, we fall back to generating `interface{}`. This
happens when:

- Variants are not wrapped in `allOf` with a single `$ref`
- Not all variants have regex patterns (for pattern-based discrimination)
- Not all variants have format-constrained fields (for format-based discrimination)
95 changes: 95 additions & 0 deletions internal/generate/templates/type.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ type {{.Name}} interface {
{{.VariantMarker.Method}}()
}

{{else if eq .Type "marker_only"}}
func ({{.Name}}) {{.VariantMarker.Method}}() {}

{{else if .Fields}}
type {{.Name}} {{.Type}} {
{{- range .Fields}}
Expand All @@ -19,6 +22,7 @@ type {{.Name}} {{.Type}} {
func ({{.Name}}) {{.VariantMarker.Method}}() {}
{{- end}}
{{- if .Variants}}
{{- if eq .Variants.UnionType "tagged"}}

func (v {{.Name}}) {{.Variants.DiscriminatorMethod}}() {{.Variants.DiscriminatorType}} {
switch v.{{.Variants.ValueFieldName}}.(type) {
Expand Down Expand Up @@ -84,6 +88,97 @@ func (v {{$.Name}}) As{{.TypeSuffix}}() (*{{.TypeName}}, bool) {
return val, ok
}
{{- end}}
{{- else if eq .Variants.UnionType "format"}}

func (v *{{.Name}}) UnmarshalJSON(data []byte) error {
{{- range .Variants.Variants}}
// Try {{.TypeName}}
{
var candidate {{.TypeName}}
if err := json.Unmarshal(data, &candidate); err == nil {
if detect{{.TypeName}}Format(&candidate) {
v.{{$.Variants.ValueFieldName}} = &candidate
return nil
}
}
}
{{- end}}
return fmt.Errorf("no variant matched for {{.Name}}")
}

func (v {{.Name}}) MarshalJSON() ([]byte, error) {
if v.{{.Variants.ValueFieldName}} == nil {
return []byte("null"), nil
}
return json.Marshal(v.{{.Variants.ValueFieldName}})
}

{{- range .Variants.Variants}}

func detect{{.TypeName}}Format(v *{{.TypeName}}) bool {
{{- if .FormatFields}}
{{- range .FormatFields}}
if !formatDetectors["{{.Format}}"](v.{{.Name}}) {
return false
}
{{- end}}
{{- else}}
_ = v // suppress unused warning
{{- end}}
return true
}
{{- end}}

{{- range .Variants.Variants}}

// As{{.TypeSuffix}} attempts to convert the {{$.Name}} to a {{.TypeName}}.
// Returns the variant and true if the conversion succeeded, nil and false otherwise.
func (v {{$.Name}}) As{{.TypeSuffix}}() (*{{.TypeName}}, bool) {
val, ok := v.{{$.Variants.ValueFieldName}}.(*{{.TypeName}})
return val, ok
}
{{- end}}
{{- else if eq .Variants.UnionType "pattern"}}

var (
{{- range .Variants.Variants}}
{{.TypeName | lower}}Pattern = regexp.MustCompile(`{{.Pattern}}`)
{{- end}}
)

func (v *{{.Name}}) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}

{{- range .Variants.Variants}}
if {{.TypeName | lower}}Pattern.MatchString(s) {
val := {{.TypeName}}(s)
v.{{$.Variants.ValueFieldName}} = &val
return nil
}
{{- end}}
return fmt.Errorf("no pattern matched for {{.Name}}")
}

func (v {{.Name}}) MarshalJSON() ([]byte, error) {
if v.{{.Variants.ValueFieldName}} == nil {
return []byte("null"), nil
}
return json.Marshal(v.{{.Variants.ValueFieldName}})
}

{{- range .Variants.Variants}}

// As{{.TypeSuffix}} attempts to convert the {{$.Name}} to a {{.TypeName}}.
// Returns the variant and true if the conversion succeeded, nil and false otherwise.
func (v {{$.Name}}) As{{.TypeSuffix}}() (*{{.TypeName}}, bool) {
val, ok := v.{{$.Variants.ValueFieldName}}.(*{{.TypeName}})
return val, ok
}
{{- end}}
{{- end}}
{{- end}}

{{else if and (eq .Type "struct") .VariantMarker}}
Expand Down
Loading