-
Notifications
You must be signed in to change notification settings - Fork 6
Model single-discriminator, single-value enums as interfaces. #359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
How to review this PR:
|
fbd978e to
35af0be
Compare
35af0be to
b02946b
Compare
lgfa29
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The design and output code LGTM. Just left some minor comments.
| } | ||
| ``` | ||
|
|
||
| If any property had different types across variants, it would become `any`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to make sure I understand this part, would PrivateIpStack be an example of this? Where the same field (value) can be one of many types?
If so, would we be able to provide at least some helper methods for now so users don't have to marshal and unmarshal bytes themselves?
DESIGN.md
Outdated
| `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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I would lean more towards this approach. Strong typing can be annoying at times, but I think in general it's a net positive.
a7c8b05 to
4974b9f
Compare
This patch updates code generation for `oneOf` types, specifically for the case when we have exactly one discriminator field and exactly one type-varying value field. In this case, we define an interface type for the `oneOf`, and a struct wrapper for each of its variant types. Variant structs implement an empty marker method to implement the interface. We also implement custom UnmarshalJSON and MarshalJSON methods for the parent type. Note: We intentionally don't change the handling of other `oneOf` types here, but may update them in the future.
4974b9f to
da86172
Compare
Restructure intermediate representations of tagged unions for clarity.
6c8a823 to
6a746b0
Compare
6a746b0 to
b06d653
Compare
sudomateo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left some comments and questions for the design. I'm onboard with these changes overall. I wish Go had better support for these types of types.
| The discriminator field is not stored in the struct. We don't want users to be able to set its value | ||
| so that it doesn't match the type of the value field. Instead, we expose a public method named after | ||
| the discriminator that returns the discriminator value. | ||
|
|
||
| We also implement custom `MarshalJSON` and `UnmarshalJSON` methods for the main type. To unmarshal, | ||
| we check the discriminator field in the JSON to determine which concrete type to use for | ||
| unmarshalling the value. To marshal, we call the `Type()` method to determine which discriminator to | ||
| emit. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not storing the discriminator field in the struct makes sense but the natural next question is how do we know what type the raw bytes are when unmarshalling. You answer that here but I think updating the wording to note that we explicitly read the discriminator from the JSON first before then deciding to read the rest of the JSON would be helpful. Perhaps this information is elsewhere already but I'm reading this linearly and making comments as I go.
| } | ||
| ``` | ||
|
|
||
| ### Discriminator with multiple value fields |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this our desired end goal for such types or just a stopgap to maintain the current behavior today before we have time to revisit this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a stopgap. I think it would be reasonable to change this to an interface type as well, but I didn't want to overcomplicate this initial PR. I'm down to explore that possibility here if you want, or maybe start a second PR branching off this one so we don't make this too hard to review.
Some of our generated enum variants are effectively all strings. For example,
RouteDestinationIp is a struct with a string Value field; RouteDestinationIpNet
is a struct with an IpNet Value field, which is also effectively a string;
RouteDestinationVpc is a struct with a Name Value field, which is also a
string; etc. If an sdk user wants to reduce a RouteDestination to a string,
e.g. in the terraform provider, they have to switch over all variants:
```go
switch v := dest.Value.(type) {
case *oxide.RouteDestinationIp:
return v.Value
case *oxide.RouteDestinationIpNet:
if s, ok := v.Value.(string); ok {
return s
}
return fmt.Sprintf("%v", v.Value), nil
case *oxide.RouteDestinationVpc:
return string(v.Value)
...
```
To save users the trouble of writing code like this for oneOf types whose
variants are all string-like, this patch includes oxide/helpers.go, which
provides String() methods for the relevant types. Note that we wrote these
methods "manually" (i.e. by prompting an llm) rather than adding them to the
generated code because only a few types exhibit this behavior, and we don't
want to complicate the generation logic unnecessarily.
576f4dc to
94d718d
Compare
|
Updates:
From here, I think we have two options:
Honestly, I could go either way on this. The blast radius of this change is actually fairly small, so I'm not sure the extra overhead of another long-lived branch is worth it. But it's also not much work to maintain, so I'm not going to argue against it either. |
|
I spent some time working through how to best approach this and ultimately came back to wrapper structs plus these marker interfaces. I wrote up an implementation on the plane, with the notable differences from the approach thus far being the following.
Overall I think this is a clean approach that applies to all enums types, whether each variant is the same type or different types. Click to expandpackage main
import "fmt"
// Exported wrapper struct with unexported interface field. The unexported
// interface field forces external clients to go through a constructor to
// construct this type.
type IPStack struct {
value IPStackInterface
}
// UnmarshalJSON remains as discussed. Peek at the discriminator and unmarshal
// accordingly.
func (i *IPStack) UnmarshalJSON(b []byte) error {
return nil
}
// MarshalJSON remains as discussed.
func (i *IPStack) MarshalJSON() ([]byte, error) {
return nil, nil
}
// This helper method prevents clients from needing to do excessing type
// assertions. It's not any better or worse than type assertions really but it's
// another option and we can generate this.
func (i *IPStack) AsV4() (*IPStackV4, bool) {
if i.value == nil {
return nil, false
}
v4, ok := i.value.(*IPStackV4)
return v4, ok
}
// This helper method prevents clients from needing to do excessing type
// assertions. It's not any better or worse than type assertions really but it's
// another option and we can generate this.
func (i *IPStack) AsV6() (*IPStackV6, bool) {
if i.value == nil {
return nil, false
}
v6, ok := i.value.(*IPStackV6)
return v6, ok
}
// Marker interface so each variant can be linked to the [IPStack] "enum".
type IPStackInterface interface {
isIPStack()
}
// The V4 variant.
type IPStackV4 struct {
IP string
Foo string
}
func (i *IPStackV4) isIPStack() {}
// The V6 variant.
type IPStackV6 struct {
IP string
Bar string
}
func (i *IPStackV6) isIPStack() {}
func main() {
stacks := make([]IPStack, 0)
v4stack := IPStack{
value: &IPStackV4{
IP: "1.1.1.1",
Foo: "V4",
},
}
v6stack := IPStack{
value: &IPStackV6{
IP: "::1",
Bar: "V6",
},
}
// Just showing that both variants can be treated the same inside some
// collection. This is obvious since the wrapper struct is, well, a struct but I
// wanted to showcase it.
stacks = append(stacks, v4stack, v6stack)
for _, stack := range stacks {
printIPStack(stack)
}
}
// This showcases how a client would access the variants should they choose to
// do so. We could provide helper accessor methods to access popular fields from
// the variants but it's probably just better to have these `As*` helpers to get
// the entire concrete struct for the variant that way the caller has access to
// all the fields.
func printIPStack(i IPStack) {
if stack, ok := i.AsV4(); ok {
fmt.Printf("V4 - %v, %v\n", stack.IP, stack.Foo)
return
}
if stack, ok := i.AsV6(); ok {
fmt.Printf("V6 - %v, %v\n", stack.IP, stack.Bar)
return
}
} |
In #359 (comment) I showed how we could implement
I think we can merge this into |
lgfa29
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Sorry for the late reply. I just realized that I got distracted and forgot to actually push the button 🤦)
Changes LGTM, just left some minor questions/thoughts/commentrs that I think are safe to ignore. And I also agree that it seems OK to merge this to main.
| // Variant wrapper types (one per discriminator value) | ||
| type PrivateIpStackV4 struct { | ||
| Value PrivateIpv4Stack `json:"value"` | ||
| } | ||
| func (PrivateIpStackV4) isPrivateIpStackVariant() {} | ||
|
|
||
| type PrivateIpStackV6 struct { | ||
| Value PrivateIpv6Stack `json:"value"` | ||
| } | ||
| func (PrivateIpStackV6) isPrivateIpStackVariant() {} | ||
|
|
||
| type PrivateIpStackDualStack struct { | ||
| Value PrivateIpStackValue `json:"value"` | ||
| } | ||
| func (PrivateIpStackDualStack) isPrivateIpStackVariant() {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably outside of the scope for this PR, but another challenge I faced while using the SDK is some confusion in the generated type names.
For example, PrivateIpStackValue is missing from this code block, but I'm assuming it looks something like this:
type PrivateIpStackValue struct {
V4 PrivateIpv4Stack
V6 PrivateIpv6Stack
}But in isolation, PrivateIpStackValue is a very generic name that doesn't communicate a whole lot. Something like PrivateIpDualStack would probably be better, and match its sibling variants name pattern (PrivateIpv4Stack and PrivateIpv6Stack).
The other variants also have a naming issue, with PrivateIpStackV4 being way too close to PrivateIpv4Stack. Maybe in this case we could add a *Variant suffix to make the variant struct more distinct and add an additional signal that this is a variant out of many options?
Putting it all together, these changes would make the generated code look something like this:
// Variant wrapper types (one per discriminator value)
type PrivateIpStackV4Variant struct {
Value PrivateIpv4Stack `json:"value"`
}
func (PrivateIpStackV4Variant) isPrivateIpStackVariant() {}
type PrivateIpStackV6Variant struct {
Value PrivateIpv6Stack `json:"value"`
}
func (PrivateIpStackV6Variant) isPrivateIpStackVariant() {}
type PrivateIpStackDualStackVariant struct {
Value PrivateIpDualStack `json:"value"`
}
func (PrivateIpStackDualStackVariant) isPrivateIpStackVariant() {}
type PrivateIpDualStack struct {
V4 PrivateIpv4Stack
V6 PrivateIpv6Stack
}But I'm not sure how much of this would be possible to auto-generate based on the spec alone.
| so that it doesn't match the type of the value field. Instead, we expose a public method named after | ||
| the discriminator that returns the discriminator value. | ||
|
|
||
| We also implement custom `MarshalJSON` and `UnmarshalJSON` methods for the main type. To unmarshal, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't technically need it for our APIs, but would we need the YAML equivalent for feature completeness?
Add `As$Variant()` helpers for each variant type to the oneOf wrapper structs. These return ($Variant, ok), using a familiar go idiom. h/t @sudomateo
|
Ok, I'm calling this good enough for the initial implementation! There are other cases I want to handle separately, as noted in DESIGN.md, but I'm going to merge this and start updating our client applications. |
Update to use oxidecomputer/oxide.go#359 to work through how the changes will affect the provider. Marking as a draft while we finish the upstream PR. Overall, some of the code got more verbose, because we have to `switch` over all known variant types, but we also dropped the json marshal/unmarshal hack, and type safety is improved. ----- ### Pull request checklist - [ ] Add changelog entry for this change.
This patch updates code generation for
oneOftypes, specifically for the case when we have exactly one discriminator field and exactly one type-varying value field. In this case, we define an interface type for theoneOf, and a struct wrapper for each of its variant types. Variant structs implement an empty marker method to implement the interface. We also implement custom UnmarshalJSON and MarshalJSON methods for the parent type.Note: We intentionally don't handle other
oneOftypes here, but may change them in the future.