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
217 changes: 209 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}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The case we're considering here is string fields with either format or pattern in the openapi spec. At the moment, that's how all untagged unions work in nexus, although obviously this could change in the future. In the case where different variants use different types, we could think about attempting to unmarshal the data into each variant, and accepting the first variant type that works.

Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Design Notes

## Goals and non-goals

The code generation logic in internal/ is developed and tested against the Nexus API and its OpenAPI
specification. Nexus is built with Rust and uses crates like serde, schemars, and dropshot to build
its OpenAPI spec, so we focus on the patterns used by those tools. We don't aim to support all
OpenAPI features or patterns as of this writing.

For example, Nexus represents tagged unions using inline object definitions, but represents untagged
unions with references to schemas defined elsewhere in the spec. Different tools might generate
OpenAPI spec files where tagged unions are instead represented as schema references, or untagged
unions as inline objects, but we don't support those cases because they can't occur in Nexus.

## oneOf Type Generation

### Overview
Expand Down Expand Up @@ -258,10 +270,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.

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`**
**Example: `IpNet` (pattern-based)**

In Rust, `IpNet` is defined as:

Expand All @@ -284,13 +307,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
// 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")
}
```

**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
type IpNet interface{}
// Creating an IP range
ipRange := oxide.IpRange{Value: &oxide.Ipv4Range{
First: "192.168.1.1",
Last: "192.168.1.100",
}}
```

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.
**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)
69 changes: 5 additions & 64 deletions internal/generate/templates/type.go.tpl
Original file line number Diff line number Diff line change
@@ -1,79 +1,25 @@
{{splitDocString .Description}}
{{- if eq .Type "interface"}}
type {{.Name}} interface {
{{.VariantMarker.Method}}()
{{.VariantMarker}}()
}

{{else if .Fields}}
type {{.Name}} {{.Type}} {
{{else if eq .Type "struct"}}
type {{.Name}} struct {
{{- range .Fields}}
{{- if .Description}}
{{.Description}}
{{- end}}
{{.Name}} {{.GoType}} {{.StructTag}}
{{- end}}
}

{{- if .VariantMarker}}

func ({{.Name}}) {{.VariantMarker.Method}}() {}
func ({{.Name}}) {{.VariantMarker}}() {}
{{- end}}
{{- if .Variants}}

func (v {{.Name}}) {{.Variants.DiscriminatorMethod}}() {{.Variants.DiscriminatorType}} {
switch v.{{.Variants.ValueFieldName}}.(type) {
{{- range .Variants.Variants}}
case *{{.TypeName}}:
return {{$.Variants.DiscriminatorType}}{{.TypeSuffix}}
{{- end}}
default:
return ""
}
}

func (v *{{.Name}}) UnmarshalJSON(data []byte) error {
type discriminator struct {
Type string `json:"{{.Variants.Discriminator}}"`
}
var d discriminator
if err := json.Unmarshal(data, &d); err != nil {
return err
}

var value {{.Variants.VariantType}}
switch d.Type {
{{- range .Variants.Variants}}
case "{{.DiscriminatorValue}}":
value = &{{.TypeName}}{}
{{- end}}
default:
return fmt.Errorf("unknown variant %q, expected {{range $i, $v := .Variants.Variants}}{{if $i}} or {{end}}'{{.DiscriminatorValue}}'{{end}}", d.Type)
}
if err := json.Unmarshal(data, value); err != nil {
return err
}
v.{{.Variants.ValueFieldName}} = value
return nil
}

func (v {{.Name}}) MarshalJSON() ([]byte, error) {
m := make(map[string]any)
m["{{.Variants.Discriminator}}"] = v.{{.Variants.DiscriminatorMethod}}()
if v.{{.Variants.ValueFieldName}} != nil {
valueBytes, err := json.Marshal(v.{{.Variants.ValueFieldName}})
if err != nil {
return nil, err
}
var valueMap map[string]any
if err := json.Unmarshal(valueBytes, &valueMap); err != nil {
return nil, err
}
for k, val := range valueMap {
m[k] = val
}
}
return json.Marshal(m)
}
{{.Variants.RenderMethods .Name}}

{{- range .Variants.Variants}}

Expand All @@ -86,11 +32,6 @@ func (v {{$.Name}}) As{{.TypeSuffix}}() (*{{.TypeName}}, bool) {
{{- end}}
{{- end}}

{{else if and (eq .Type "struct") .VariantMarker}}
type {{.Name}} struct{}

func ({{.Name}}) {{.VariantMarker.Method}}() {}

{{else}}
type {{.Name}} {{.Type}}
{{end}}
51 changes: 51 additions & 0 deletions internal/generate/templates/union_string.go.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{- /* Variables for pattern-based string variants */ -}}
{{- $hasPatterns := false -}}
{{- range .Variants -}}
{{- if .Pattern -}}
{{- $hasPatterns = true -}}
{{- end -}}
{{- end -}}
{{- if $hasPatterns}}
var (
{{- range .Variants}}
{{- if .Pattern}}
{{.TypeName | lower}}Pattern = regexp.MustCompile(`{{.Pattern}}`)
{{- end}}
{{- end}}
)
{{- end}}

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

{{- range .Variants}}
if detect{{.TypeName}}(s) {
val := {{.TypeName}}(s)
v.{{$.ValueFieldName}} = &val
return nil
}
{{- end}}
return fmt.Errorf("no variant matched for {{.TypeName}}")
}

func (v {{.TypeName}}) MarshalJSON() ([]byte, error) {
return json.Marshal(v.{{.ValueFieldName}})
}

{{- range .Variants}}

func detect{{.TypeName}}(s string) bool {
{{- if .Pattern}}
return {{.TypeName | lower}}Pattern.MatchString(s)
{{- else if .Format}}
return formatDetectors["{{.Format}}"](s)
{{- else}}
return false
{{- end}}
}

func ({{.TypeName}}) {{$.MarkerMethod}}() {}
{{- end}}
Loading