diff --git a/DESIGN.md b/DESIGN.md index 46a5ea9..a206c76 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: @@ -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) diff --git a/internal/generate/templates/type.go.tpl b/internal/generate/templates/type.go.tpl index a3352d1..e0c60ee 100644 --- a/internal/generate/templates/type.go.tpl +++ b/internal/generate/templates/type.go.tpl @@ -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}} @@ -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) { @@ -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}} diff --git a/internal/generate/types.go b/internal/generate/types.go index a5bad38..08fbe45 100644 --- a/internal/generate/types.go +++ b/internal/generate/types.go @@ -28,7 +28,10 @@ func enumStringTypes() map[string][]string { var ( typeTemplate = template.Must( template.New("type.go.tpl"). - Funcs(template.FuncMap{"splitDocString": splitDocString}). + Funcs(template.FuncMap{ + "splitDocString": splitDocString, + "lower": strings.ToLower, + }). ParseFiles("./templates/type.go.tpl"), ) enumTemplate = template.Must( @@ -79,6 +82,8 @@ type VariantMarker struct { // VariantConfig holds configuration for tagged union types type VariantConfig struct { + // UnionType indicates how variants are distinguished (tagged, format, pattern). + UnionType UnionType // Discriminator is the JSON property name for the discriminator (e.g., "type") Discriminator string // DiscriminatorMethod is the Go method name that returns the discriminator (e.g., "Type") @@ -95,6 +100,18 @@ type VariantConfig struct { Variants []Variant } +// UnionType describes how to distinguish variants in a oneOf. +type UnionType string + +const ( + // UnionTagged uses a discriminator property (e.g., "type": "v4"). + UnionTagged UnionType = "tagged" + // UnionFormat uses format fields (e.g., format: "ipv4" vs "ipv6"). + UnionFormat UnionType = "format" + // UnionPattern uses regex patterns on string types. + UnionPattern UnionType = "pattern" +) + // Variant represents a single variant in a tagged union (oneOf with discriminator). type Variant struct { // DiscriminatorValue is the raw JSON tag value (e.g., "ip_net"). @@ -103,6 +120,20 @@ type Variant struct { TypeSuffix string // TypeName is the full Go type name for this variant (e.g., "RouteDestinationIpNet"). TypeName string + // Format is set for format-based discrimination (e.g., "ipv4", "ipv6"). + Format string + // Pattern is set for pattern-based discrimination (regex). + Pattern string + // FormatFields holds fields with format constraints for validation. + FormatFields []FormatField +} + +// FormatField describes a field with a format constraint for validation. +type FormatField struct { + // Name is the Go field name. + Name string + // Format is the OpenAPI format (e.g., "ipv4", "ipv6"). + Format string } // Render renders the TypeTemplate to a Go type. @@ -789,6 +820,11 @@ func createAllOf( } func createOneOf(s *openapi3.Schema, name, typeName string) ([]TypeTemplate, []EnumTemplate) { + // Check for untagged union (format/pattern discriminated) first. + if analysis := analyzeUntaggedUnion(s); analysis != nil { + return createUntaggedUnionOneOf(s, name, typeName, analysis) + } + // First pass: identify discriminator key and find properties with multiple types across // variants. discriminatorKeys := map[string]struct{}{} @@ -961,6 +997,7 @@ func createInterfaceOneOf( Type: "struct", Fields: wrapperFields, Variants: &VariantConfig{ + UnionType: UnionTagged, Discriminator: discriminatorKey, DiscriminatorMethod: strcase.ToCamel(discriminatorKey), DiscriminatorType: discriminatorType, @@ -1058,6 +1095,215 @@ func createFlatOneOf( return typeTpls, enumTpls } +// UntaggedUnionAnalysis holds analysis results for untagged union detection. +type UntaggedUnionAnalysis struct { + // Type is the discrimination type (format or pattern). + Type UnionType + // Variants holds the variant information. + Variants []UntaggedVariantInfo +} + +// UntaggedVariantInfo holds information about a variant in an untagged union. +type UntaggedVariantInfo struct { + // RefName is the referenced type name (e.g., "Ipv4Range"). + RefName string + // Title is the variant title from the schema (e.g., "v4"). + Title string + // Format is set for format discrimination (e.g., "ipv4"). + Format string + // Pattern is set for pattern discrimination. + Pattern string + // FormatFields holds fields with format constraints. + FormatFields []FormatField +} + +// analyzeUntaggedUnion checks if a oneOf schema represents an untagged union +// with format or pattern discrimination. +// +// An untagged union is a oneOf where: +// 1. Each variant is an allOf with a single $ref (wrapper pattern) +// 2. The referenced types can be discriminated by format or pattern +// +// Returns nil if the schema is not an untagged union. +func analyzeUntaggedUnion(s *openapi3.Schema) *UntaggedUnionAnalysis { + if len(s.OneOf) < 2 { + return nil + } + + var variants []UntaggedVariantInfo + + for _, variantRef := range s.OneOf { + // Check if this variant is an allOf wrapper with a single $ref + if variantRef.Value.AllOf == nil || len(variantRef.Value.AllOf) != 1 { + return nil + } + + innerRef := variantRef.Value.AllOf[0] + if innerRef.Ref == "" { + return nil + } + + refName := strings.TrimPrefix(innerRef.Ref, "#/components/schemas/") + title := variantRef.Value.Title + + // Get the underlying schema to check for format/pattern + underlyingSchema := innerRef.Value + if underlyingSchema == nil { + return nil + } + + variant := UntaggedVariantInfo{ + RefName: refName, + Title: title, + } + + // Check if it's a string type with pattern (e.g., Ipv4Net) + if underlyingSchema.Type.Is("string") { + if underlyingSchema.Pattern != "" { + variant.Pattern = underlyingSchema.Pattern + } + // Check format on string type + if underlyingSchema.Format != "" { + variant.Format = underlyingSchema.Format + } + } + + // Check if it's an object type with format-constrained fields (e.g., Ipv4Range) + if underlyingSchema.Type.Is("object") && len(underlyingSchema.Properties) > 0 { + var formatFields []FormatField + for propName, propRef := range underlyingSchema.Properties { + if propRef.Value != nil && propRef.Value.Format != "" { + formatFields = append(formatFields, FormatField{ + Name: strcase.ToCamel(propName), + Format: propRef.Value.Format, + }) + } + } + if len(formatFields) > 0 { + // Sort for deterministic output + sort.Slice(formatFields, func(i, j int) bool { + return formatFields[i].Name < formatFields[j].Name + }) + variant.FormatFields = formatFields + // Use the first format field to determine the variant's format + variant.Format = formatFields[0].Format + } + } + + variants = append(variants, variant) + } + + if len(variants) == 0 { + return nil + } + + // Determine discrimination type + var discType UnionType + + // Check if all variants have patterns (pattern discrimination) + allHavePatterns := true + for _, v := range variants { + if v.Pattern == "" { + allHavePatterns = false + break + } + } + if allHavePatterns { + discType = UnionPattern + } + + // Check if all variants have formats (format discrimination) + allHaveFormats := true + for _, v := range variants { + if v.Format == "" && len(v.FormatFields) == 0 { + allHaveFormats = false + break + } + } + if allHaveFormats && discType == "" { + discType = UnionFormat + } + + if discType == "" { + return nil + } + + return &UntaggedUnionAnalysis{ + Type: discType, + Variants: variants, + } +} + +// createUntaggedUnionOneOf creates types for an untagged union (format/pattern discriminated). +func createUntaggedUnionOneOf( + s *openapi3.Schema, + name, typeName string, + analysis *UntaggedUnionAnalysis, +) ([]TypeTemplate, []EnumTemplate) { + typeTpls := make([]TypeTemplate, 0) + + // Build the interface type. + interfaceName := toLowerFirstLetter(typeName) + "Variant" + markerMethod := "is" + typeName + "Variant" + interfaceTpl := TypeTemplate{ + Description: fmt.Sprintf("// %s is implemented by %s variants.", interfaceName, typeName), + Name: interfaceName, + Type: "interface", + VariantMarker: &VariantMarker{Method: markerMethod}, + } + typeTpls = append(typeTpls, interfaceTpl) + + // Build variant info for the wrapper type + var variants []Variant + for _, v := range analysis.Variants { + variantTypeName := strcase.ToCamel(v.RefName) + variants = append(variants, Variant{ + TypeSuffix: variantTypeName, + TypeName: variantTypeName, + Format: v.Format, + Pattern: v.Pattern, + FormatFields: v.FormatFields, + }) + + // Create a type template to add the marker method to the referenced type. + // We need to emit a marker method for the existing type. + markerTpl := TypeTemplate{ + Name: variantTypeName, + Type: "marker_only", + VariantMarker: &VariantMarker{ + Method: markerMethod, + InterfaceType: interfaceName, + }, + } + typeTpls = append(typeTpls, markerTpl) + } + + // Create the wrapper struct with only the interface-typed value field. + wrapperFields := []TypeField{ + { + Name: "Value", + Type: interfaceName, + MarshalKey: "value", + }, + } + + wrapperTpl := TypeTemplate{ + Description: formatTypeDescription(typeName, s), + Name: typeName, + Type: "struct", + Fields: wrapperFields, + Variants: &VariantConfig{ + UnionType: analysis.Type, + ValueFieldName: "Value", + VariantType: interfaceName, + Variants: variants, + }, + } + typeTpls = append(typeTpls, wrapperTpl) + + return typeTpls, nil +} + func getObjectType(s *openapi3.Schema) string { // TODO: Support enums of other types if s.Type.Is("string") && len(s.Enum) > 0 { diff --git a/internal/generate/types_test.go b/internal/generate/types_test.go index fe1dceb..30a4bcc 100644 --- a/internal/generate/types_test.go +++ b/internal/generate/types_test.go @@ -564,6 +564,7 @@ func Test_createOneOf(t *testing.T) { {Name: "Value", Type: "intOrStringVariant", MarshalKey: "value"}, }, Variants: &VariantConfig{ + UnionType: UnionTagged, Discriminator: "type", DiscriminatorMethod: "Type", DiscriminatorType: "IntOrStringType", @@ -711,3 +712,176 @@ func Test_createAllOf(t *testing.T) { }) } } + +func Test_analyzeUntaggedUnion(t *testing.T) { + tests := []struct { + name string + schema *openapi3.Schema + want *UntaggedUnionAnalysis + }{ + { + name: "pattern-based discrimination (IpNet style)", + schema: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + { + Value: &openapi3.Schema{ + Title: "v4", + AllOf: openapi3.SchemaRefs{ + { + Ref: "#/components/schemas/Ipv4Net", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Pattern: `^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$`, + }, + }, + }, + }, + }, + { + Value: &openapi3.Schema{ + Title: "v6", + AllOf: openapi3.SchemaRefs{ + { + Ref: "#/components/schemas/Ipv6Net", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Pattern: `^[0-9a-fA-F:]+/[0-9]+$`, + }, + }, + }, + }, + }, + }, + }, + want: &UntaggedUnionAnalysis{ + Type: UnionPattern, + Variants: []UntaggedVariantInfo{ + { + RefName: "Ipv4Net", + Title: "v4", + Pattern: `^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$`, + }, + {RefName: "Ipv6Net", Title: "v6", Pattern: `^[0-9a-fA-F:]+/[0-9]+$`}, + }, + }, + }, + { + name: "format-based discrimination (IpRange style)", + schema: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + { + Value: &openapi3.Schema{ + Title: "v4", + AllOf: openapi3.SchemaRefs{ + { + Ref: "#/components/schemas/Ipv4Range", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "first": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "ipv4", + }, + }, + "last": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "ipv4", + }, + }, + }, + }, + }, + }, + }, + }, + { + Value: &openapi3.Schema{ + Title: "v6", + AllOf: openapi3.SchemaRefs{ + { + Ref: "#/components/schemas/Ipv6Range", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "first": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "ipv6", + }, + }, + "last": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "ipv6", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: &UntaggedUnionAnalysis{ + Type: UnionFormat, + Variants: []UntaggedVariantInfo{ + { + RefName: "Ipv4Range", + Title: "v4", + Format: "ipv4", + FormatFields: []FormatField{ + {Name: "First", Format: "ipv4"}, + {Name: "Last", Format: "ipv4"}, + }, + }, + { + RefName: "Ipv6Range", + Title: "v6", + Format: "ipv6", + FormatFields: []FormatField{ + {Name: "First", Format: "ipv6"}, + {Name: "Last", Format: "ipv6"}, + }, + }, + }, + }, + }, + { + name: "not an untagged union - regular oneOf", + schema: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "type": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"a"}, + }, + }, + "value": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }, + }, + }, + }, + want: nil, + }, + { + name: "nil oneOf", + schema: &openapi3.Schema{}, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := analyzeUntaggedUnion(tc.schema) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/oxide/format_detectors.go b/oxide/format_detectors.go new file mode 100644 index 0000000..6257d42 --- /dev/null +++ b/oxide/format_detectors.go @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package oxide + +import "net" + +// formatDetectors maps OpenAPI format strings to detection functions. +// Used by generated code to discriminate untagged union variants. +var formatDetectors = map[string]func(string) bool{ + "ipv4": detectIPv4Format, + "ipv6": detectIPv6Format, +} + +func detectIPv4Format(s string) bool { + ip := net.ParseIP(s) + return ip != nil && ip.To4() != nil +} + +func detectIPv6Format(s string) bool { + ip := net.ParseIP(s) + return ip != nil && ip.To4() == nil +} diff --git a/oxide/golden_test.go b/oxide/golden_test.go index 6845697..215589f 100644 --- a/oxide/golden_test.go +++ b/oxide/golden_test.go @@ -42,6 +42,11 @@ func TestGoldenRoundTrip(t *testing.T) { fixture: "testdata/recordings/loopback_addresses_response.json", test: testRoundTrip[LoopbackAddressResultsPage], }, + { + name: "ip_pool_range_list_response", + fixture: "testdata/recordings/ip_pool_range_list_response.json", + test: testRoundTrip[IpPoolRangeResultsPage], + }, } for _, tt := range tests { diff --git a/oxide/helpers.go b/oxide/helpers.go index 3ef6f68..dae0b2b 100644 --- a/oxide/helpers.go +++ b/oxide/helpers.go @@ -6,7 +6,10 @@ package oxide // This file contains hand-written helper methods for generated types. -import "fmt" +import ( + "encoding/json" + "fmt" +) // String helpers for oneOf types whose variants are all string or string-like (Name, NameOrId, // etc.). @@ -99,6 +102,67 @@ func (v RouteTarget) String() string { } } +// NewIpNet creates an IpNet from a string value (e.g., "192.168.1.0/24" or "fd00::/64"). +// The string is parsed to determine whether it's an IPv4 or IPv6 network. +func NewIpNet(value string) (IpNet, error) { + var ipNet IpNet + if err := json.Unmarshal([]byte(`"`+value+`"`), &ipNet); err != nil { + return IpNet{}, fmt.Errorf("invalid IP network %q: %w", value, err) + } + return ipNet, nil +} + +// MustIpNet creates an IpNet from a string value, panicking on error. +// Use this only for known-good values. +func MustIpNet(value string) IpNet { + ipNet, err := NewIpNet(value) + if err != nil { + panic(err) + } + return ipNet +} + +// String returns the string representation of the IpNet. +func (v IpNet) String() string { + if v.Value == nil { + return "" + } + switch val := v.Value.(type) { + case *Ipv4Net: + return string(*val) + case *Ipv6Net: + return string(*val) + default: + return fmt.Sprintf("%v", val) + } +} + +// NewIpRange creates an IpRange from first and last IP strings. +// The IPs are parsed to determine whether they're IPv4 or IPv6. +func NewIpRange(first, last string) (IpRange, error) { + data := fmt.Sprintf(`{"first":%q,"last":%q}`, first, last) + var ipRange IpRange + if err := json.Unmarshal([]byte(data), &ipRange); err != nil { + return IpRange{}, fmt.Errorf("invalid IP range %q-%q: %w", first, last, err) + } + return ipRange, nil +} + +// String returns the string representation of the IpRange. +func (v IpRange) String() string { + if v.Value == nil { + return "" + } + switch val := v.Value.(type) { + case *Ipv4Range: + return fmt.Sprintf("%s-%s", val.First, val.Last) + case *Ipv6Range: + return fmt.Sprintf("%s-%s", val.First, val.Last) + default: + return fmt.Sprintf("%v", val) + } +} + // Constructor helpers for oneOf types whose variants are all string or string-like. // NewRouteDestination creates a RouteDestination from a type constant and string value. @@ -107,7 +171,11 @@ func NewRouteDestination(t RouteDestinationType, value string) (RouteDestination case RouteDestinationTypeIp: return RouteDestination{Value: &RouteDestinationIp{Value: value}}, nil case RouteDestinationTypeIpNet: - return RouteDestination{Value: &RouteDestinationIpNet{Value: IpNet(value)}}, nil + ipNet, err := NewIpNet(value) + if err != nil { + return RouteDestination{}, err + } + return RouteDestination{Value: &RouteDestinationIpNet{Value: ipNet}}, nil case RouteDestinationTypeVpc: return RouteDestination{Value: &RouteDestinationVpc{Value: Name(value)}}, nil case RouteDestinationTypeSubnet: @@ -160,8 +228,12 @@ func NewVpcFirewallRuleHostFilter( case VpcFirewallRuleHostFilterTypeIp: return VpcFirewallRuleHostFilter{Value: &VpcFirewallRuleHostFilterIp{Value: value}}, nil case VpcFirewallRuleHostFilterTypeIpNet: + ipNet, err := NewIpNet(value) + if err != nil { + return VpcFirewallRuleHostFilter{}, err + } return VpcFirewallRuleHostFilter{ - Value: &VpcFirewallRuleHostFilterIpNet{Value: IpNet(value)}, + Value: &VpcFirewallRuleHostFilterIpNet{Value: ipNet}, }, nil default: return VpcFirewallRuleHostFilter{}, fmt.Errorf( @@ -187,7 +259,11 @@ func NewVpcFirewallRuleTarget( case VpcFirewallRuleTargetTypeIp: return VpcFirewallRuleTarget{Value: &VpcFirewallRuleTargetIp{Value: value}}, nil case VpcFirewallRuleTargetTypeIpNet: - return VpcFirewallRuleTarget{Value: &VpcFirewallRuleTargetIpNet{Value: IpNet(value)}}, nil + ipNet, err := NewIpNet(value) + if err != nil { + return VpcFirewallRuleTarget{}, err + } + return VpcFirewallRuleTarget{Value: &VpcFirewallRuleTargetIpNet{Value: ipNet}}, nil default: return VpcFirewallRuleTarget{}, fmt.Errorf("unknown VpcFirewallRuleTargetType: %s", t) } diff --git a/oxide/testdata/main.go b/oxide/testdata/main.go index 57b23e0..7ca77f8 100644 --- a/oxide/testdata/main.go +++ b/oxide/testdata/main.go @@ -54,6 +54,7 @@ func main() { recordTimeseriesQuery(host, token, testdataDir) recordDiskList(host, token, project, testdataDir) recordLoopbackAddresses(host, token, testdataDir) + recordIpPoolRanges(host, token, testdataDir) } func recordTimeseriesQuery(host, token, testdataDir string) { @@ -119,6 +120,52 @@ func recordLoopbackAddresses(host, token, testdataDir string) { } } +func recordIpPoolRanges(host, token, testdataDir string) { + fmt.Println("Recording IP pool ranges response...") + + // Fetch ranges from specific pools to get both IPv4 and IPv6 coverage + pools := []string{"fake-address", "fake-address-v6"} + var allItems []any + + for _, poolName := range pools { + url := fmt.Sprintf("%s/v1/system/ip-pools/%s/ranges?limit=1", host, poolName) + data, err := doRequest("GET", url, token, "") + if err != nil { + log.Printf("Warning: IP pool ranges for %s failed: %v", poolName, err) + continue + } + + var resp struct { + Items []any `json:"items"` + } + if err := json.Unmarshal(data, &resp); err != nil { + log.Printf("Warning: failed to parse IP pool ranges response for %s: %v", poolName, err) + continue + } + allItems = append(allItems, resp.Items...) + } + + if len(allItems) == 0 { + log.Printf("Warning: no IP pool ranges found, skipping recording") + return + } + + // Combine into a single response + combined := map[string]any{ + "items": allItems, + } + data, err := json.Marshal(combined) + if err != nil { + log.Printf("Warning: failed to marshal combined response: %v", err) + return + } + + if err := saveFixture(testdataDir, "ip_pool_range_list_response.json", data); err != nil { + log.Printf("Warning: %v", err) + return + } +} + // doRequest makes a request to the configured nexus instance. We use the standard library here // and not our own sdk because we're generating test files to verify the generated code. func doRequest(method, url, token, body string) ([]byte, error) { diff --git a/oxide/testdata/recordings/disk_list_response.json b/oxide/testdata/recordings/disk_list_response.json index 09e09b1..3df3306 100644 --- a/oxide/testdata/recordings/disk_list_response.json +++ b/oxide/testdata/recordings/disk_list_response.json @@ -1 +1 @@ -{"items":[{"block_size":512,"description":"boot","device_path":"/mnt/boot","disk_type":"distributed","id":"9caa44a1-2683-4f52-a5ca-8a5ee96c7362","image_id":"14e36227-0984-484e-8c94-baab1a6be648","name":"boot","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":21474836480,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-09-25T15:11:27.776013Z","time_modified":"2025-09-25T15:11:27.776013Z"},{"block_size":512,"description":"Created as a boot disk for builder-omni","device_path":"/mnt/builder-omni-omnios-bloody-20250124-d6fb1a","disk_type":"distributed","id":"810c075b-27d0-41b1-b259-7551fed1a004","image_id":"7e7c352c-f4aa-4d8b-a34d-7e7c6eeb615a","name":"builder-omni-omnios-bloody-20250124-d6fb1a","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1098437885952,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-11-27T02:51:41.697116Z","time_modified":"2025-11-27T02:51:41.697116Z"},{"block_size":512,"description":"Created as a boot disk for builder-omni","device_path":"/mnt/builder-omni-omnios-r151056-cloud-228ca2","disk_type":"distributed","id":"2c310fbf-a0bf-4c31-8a3d-a4e38feb53c7","image_id":"6e989035-2319-4980-88a3-31fe7112d87f","name":"builder-omni-omnios-r151056-cloud-228ca2","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1073741824000,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-12-05T20:57:41.635747Z","time_modified":"2025-12-05T20:57:41.635747Z"},{"block_size":512,"description":"Created as a boot disk for ch-builder-omni","device_path":"/mnt/ch-builder-omni-omnios-cloud-1693471113-aadda2","disk_type":"distributed","id":"d1c47f4b-a998-42aa-a273-3b7b87e3e780","image_id":"04ffb229-6c78-4bc4-baa3-b4f07f16bea3","name":"ch-builder-omni-omnios-cloud-1693471113-aadda2","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1073741824000,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-11-27T02:21:32.352141Z","time_modified":"2025-11-27T02:21:32.352141Z"},{"block_size":4096,"description":"data","device_path":"/mnt/data","disk_type":"distributed","id":"84a528d2-b2d5-4420-ae39-3b4643d46a92","image_id":null,"name":"data","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":214748364800,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-09-25T15:11:27.728556Z","time_modified":"2025-09-25T15:11:27.728556Z"}],"next_page":"eyJ2IjoidjEiLCJwYWdlX3N0YXJ0Ijp7InNvcnRfYnkiOiJuYW1lX2FzY2VuZGluZyIsInByb2plY3QiOiJjYXJwIiwibGFzdF9zZWVuIjoiZGF0YSJ9fQ=="} \ No newline at end of file +{"items":[{"block_size":512,"description":"Created as a boot disk for helios","device_path":"/mnt/helios-helios-9c80da","disk_type":"distributed","id":"566fbfa4-d2ae-4622-8cb0-d8aeca83e1eb","image_id":"61087de2-f66c-4257-8fd2-624a137a7a7e","name":"helios-helios-9c80da","project_id":"dc7be448-efc4-4141-8e9b-60ad829eb570","size":10737418240,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-07-16T18:22:38.493292Z","time_modified":"2025-07-16T18:22:38.493292Z"}],"next_page":"eyJ2IjoidjEiLCJwYWdlX3N0YXJ0Ijp7InNvcnRfYnkiOiJuYW1lX2FzY2VuZGluZyIsInByb2plY3QiOiJhY2tiYXIiLCJsYXN0X3NlZW4iOiJoZWxpb3MtaGVsaW9zLTljODBkYSJ9fQ=="} \ No newline at end of file diff --git a/oxide/testdata/recordings/ip_pool_range_list_response.json b/oxide/testdata/recordings/ip_pool_range_list_response.json new file mode 100644 index 0000000..9e84324 --- /dev/null +++ b/oxide/testdata/recordings/ip_pool_range_list_response.json @@ -0,0 +1 @@ +{"items":[{"id":"c3f126d5-cb46-466d-879d-b8237b0a3a26","ip_pool_id":"8b924114-a9f4-4634-b6a4-956ff640807a","range":{"first":"192.168.30.2","last":"192.168.30.3"},"time_created":"2023-08-31T05:28:29.740339Z"},{"id":"b27c4bcb-5d3f-494f-8630-cad248bec63f","ip_pool_id":"1d3dcd84-6a5d-421c-a64f-e806fad7826f","range":{"first":"fd89:8b5f:4b89::","last":"fd89:8b5f:4b89:0:ffff:ffff:ffff:ffff"},"time_created":"2026-01-16T01:21:13.400946Z"}]} \ No newline at end of file diff --git a/oxide/testdata/recordings/timeseries_query_response.json b/oxide/testdata/recordings/timeseries_query_response.json index 6f39820..bb8503f 100644 --- a/oxide/testdata/recordings/timeseries_query_response.json +++ b/oxide/testdata/recordings/timeseries_query_response.json @@ -1 +1 @@ -{"tables":[{"name":"hardware_component:voltage","timeseries":[{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"c0cea24f-ab91-4026-8593-870d64c34673"},"hubris_archive_id":{"type":"string","value":"29806c00ad5fc171"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-12T21:50:24.195794351Z","2026-01-12T21:50:25.192639350Z","2026-01-12T21:50:26.193419069Z","2026-01-12T21:50:27.193322592Z","2026-01-12T21:50:28.290379572Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1.001953125,1.001953125]}}]}},{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"eb645e8f-4228-43fa-9a55-97feabf8ab66"},"hubris_archive_id":{"type":"string","value":"29806c00ad5fc171"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-12T21:50:24.492202137Z","2026-01-12T21:50:25.562686874Z","2026-01-12T21:50:26.493035980Z","2026-01-12T21:50:27.492070298Z","2026-01-12T21:50:28.709615454Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1.001953125,1.001953125]}}]}}]}]} \ No newline at end of file +{"tables":[{"name":"hardware_component:voltage","timeseries":[{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"814d0c1b-f408-46d8-984f-d3456820978f"},"hubris_archive_id":{"type":"string","value":"434a800050c285f3"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-28T16:21:33.912722890Z","2026-01-28T16:21:34.913301015Z","2026-01-28T16:21:35.911973582Z","2026-01-28T16:21:36.911963027Z","2026-01-28T16:21:38.704977320Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1.001953125,1.001953125]}}]}},{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"98961a83-1397-4c82-8bd1-d515cf6d885d"},"hubris_archive_id":{"type":"string","value":"434a800050c285f3"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-28T16:21:34.201841323Z","2026-01-28T16:21:35.201875689Z","2026-01-28T16:21:36.202135611Z","2026-01-28T16:21:37.202592806Z","2026-01-28T16:21:38.631106021Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1,1.001953125]}}]}}]}]} \ No newline at end of file diff --git a/oxide/types.go b/oxide/types.go index b2c0a2e..2052079 100644 --- a/oxide/types.go +++ b/oxide/types.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "time" ) @@ -5099,8 +5100,63 @@ type InternetGatewayResultsPage struct { NextPage string `json:"next_page,omitempty" yaml:"next_page,omitempty"` } +// ipNetVariant is implemented by IpNet variants. +type ipNetVariant interface { + isIpNetVariant() +} + +func (Ipv4Net) isIpNetVariant() {} + +func (Ipv6Net) isIpNetVariant() {} + // IpNet is the type definition for a IpNet. -type IpNet interface{} +type IpNet struct { + Value ipNetVariant `json:"value,omitempty" yaml:"value,omitempty"` +} + +var ( + ipv4netPattern = regexp.MustCompile(`^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$`) + ipv6netPattern = regexp.MustCompile(`^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$`) +) + +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") +} + +func (v IpNet) MarshalJSON() ([]byte, error) { + if v.Value == nil { + return []byte("null"), nil + } + return json.Marshal(v.Value) +} + +// AsIpv4Net attempts to convert the IpNet to a Ipv4Net. +// Returns the variant and true if the conversion succeeded, nil and false otherwise. +func (v IpNet) AsIpv4Net() (*Ipv4Net, bool) { + val, ok := v.Value.(*Ipv4Net) + return val, ok +} + +// AsIpv6Net attempts to convert the IpNet to a Ipv6Net. +// Returns the variant and true if the conversion succeeded, nil and false otherwise. +func (v IpNet) AsIpv6Net() (*Ipv6Net, bool) { + val, ok := v.Value.(*Ipv6Net) + return val, ok +} // IpPool is a collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be // allocated within that silo @@ -5275,8 +5331,84 @@ type IpPoolUtilization struct { Remaining float64 `json:"remaining" yaml:"remaining"` } +// ipRangeVariant is implemented by IpRange variants. +type ipRangeVariant interface { + isIpRangeVariant() +} + +func (Ipv4Range) isIpRangeVariant() {} + +func (Ipv6Range) isIpRangeVariant() {} + // IpRange is the type definition for a IpRange. -type IpRange interface{} +type IpRange struct { + Value ipRangeVariant `json:"value,omitempty" yaml:"value,omitempty"` +} + +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") +} + +func (v IpRange) MarshalJSON() ([]byte, error) { + if v.Value == nil { + return []byte("null"), nil + } + return json.Marshal(v.Value) +} + +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 +} + +// AsIpv4Range attempts to convert the IpRange to a Ipv4Range. +// Returns the variant and true if the conversion succeeded, nil and false otherwise. +func (v IpRange) AsIpv4Range() (*Ipv4Range, bool) { + val, ok := v.Value.(*Ipv4Range) + return val, ok +} + +// AsIpv6Range attempts to convert the IpRange to a Ipv6Range. +// Returns the variant and true if the conversion succeeded, nil and false otherwise. +func (v IpRange) AsIpv6Range() (*Ipv6Range, bool) { + val, ok := v.Value.(*Ipv6Range) + return val, ok +} // IpVersion is the IP address version. type IpVersion string