Skip to content
Merged
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.3

require (
github.com/getkin/kin-openapi v0.133.0
github.com/google/go-cmp v0.7.0
github.com/iancoleman/strcase v0.3.0
github.com/pelletier/go-toml v1.9.5
github.com/stretchr/testify v1.11.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
Expand Down
4 changes: 2 additions & 2 deletions internal/generate/templates/type.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
type {{.Name}} {{.Type}} {
{{- range .Fields}}
{{- if .Description}}
{{splitDocString .Description}}
{{.Description}}
{{- end}}
{{.Name}} {{.Type}} {{.SerializationInfo}}
{{.Name}} {{.GoType}} {{.StructTag}}
{{- end}}
}

Expand Down
198 changes: 132 additions & 66 deletions internal/generate/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import (
// are not duplicated in createStringEnum()
var collectEnumStringTypes = enumStringTypes()

func enumStringTypes() map[string][]string {
return map[string][]string{}
}

var (
typeTemplate = template.Must(
template.New("type.go.tpl").
Expand All @@ -38,10 +42,6 @@ var (
)
)

func enumStringTypes() map[string][]string {
return map[string][]string{}
}

// renderTemplate executes a template with the given data and returns the result.
func renderTemplate(tmpl *template.Template, data any) string {
var buf bytes.Buffer
Expand Down Expand Up @@ -70,10 +70,100 @@ func (t TypeTemplate) Render() string {

// TypeField holds the information for each type field.
type TypeField struct {
Description string
Name string
Type string
SerializationInfo string
Schema *openapi3.SchemaRef
Name string
Type string
MarshalKey string
Required bool

// FallbackDescription generates a generic description for the field when the Schema doesn't have one.
// TODO: Drop this, since generated descriptions don't contain useful information.
FallbackDescription bool

// OmitDirective overrides the derived omit directive for the field.
// TODO: Drop this; we should set omit directives consistently rather than using overrides.
OmitDirective string
}

// Description returns the formatted description comment for this field.
func (f TypeField) Description() string {
if f.Schema == nil {
return ""
}
if f.Schema.Value.Description != "" {
desc := fmt.Sprintf("// %s is %s", f.Name, toLowerFirstLetter(
strings.ReplaceAll(f.Schema.Value.Description, "\n", "\n// ")))
return splitDocString(desc)
}
if f.FallbackDescription {
return splitDocString(fmt.Sprintf("// %s is the type definition for a %s.", f.Name, f.Name))
}
return ""
}

// StructTag returns the JSON/YAML struct tags for this field.
// Configure json/yaml struct tags. By default, omit empty/zero
// values, but retain them for required fields.
//
// TODO: Use `omitzero` rather than `omitempty` on all relevant
// fields: https://github.com/oxidecomputer/oxide.go/issues/290
func (f TypeField) StructTag() string {
var omitDirective string
switch {
case f.OmitDirective != "":
omitDirective = f.OmitDirective
case f.Schema == nil:
omitDirective = "omitempty"
case f.Required || isNullableArray(f.Schema):
omitDirective = ""
case slices.Contains(omitzeroTypes(), f.Type):
omitDirective = "omitzero"
default:
omitDirective = "omitempty"
}

tagValue := f.MarshalKey
if omitDirective != "" {
tagValue = f.MarshalKey + "," + omitDirective
}

return fmt.Sprintf("`json:\"%s\" yaml:\"%s\"`", tagValue, tagValue)
}

// IsPointer returns whether this field should be a pointer type.
//
// Note: Primitive type pointer logic (int, bool, time) is handled in
// schemaValueToGoType() because those types can appear in nested contexts
// (map values, array items) that don't go through TypeField.
func (f TypeField) IsPointer() bool {
if f.Schema == nil {
return false
}

v := f.Schema.Value

// Required + nullable fields should be pointers (Omicron API pattern):
// they can be set to a null value, but they must not be omitted.
// The SDK presents these fields as optional and serializes them to
// `null` if not provided.
if f.Required && v.Nullable {
return true
}

// Check hardcoded nullable exceptions (upstream API workarounds)
if slices.Contains(nullable(), f.Type) {
return true
}

return false
}

// GoType returns the Go type for this field, with pointer prefix if needed.
func (f TypeField) GoType() string {
if f.IsPointer() && !strings.HasPrefix(f.Type, "*") {
return "*" + f.Type
}
return f.Type
}

// EnumTemplate holds the information for enum types.
Expand Down Expand Up @@ -171,18 +261,18 @@ func constructParamTypes(paths map[string]*openapi3.PathItem) []TypeTemplate {
}

paramName := strcase.ToCamel(p.Value.Name)
paramType := convertToValidGoType("", "", p.Value.Schema)
field := TypeField{
Name: paramName,
Type: convertToValidGoType("", "", p.Value.Schema),
Name: paramName,
Type: paramType,
MarshalKey: p.Value.Name,
OmitDirective: "omitempty",
}

if p.Value.Required {
requiredFields = requiredFields + fmt.Sprintf("\n// - %s", paramName)
}

serInfo := fmt.Sprintf("`json:\"%s,omitempty\" yaml:\"%s,omitempty\"`", p.Value.Name, p.Value.Name)
field.SerializationInfo = serInfo

fields = append(fields, field)
}
if o.RequestBody != nil {
Expand All @@ -193,17 +283,19 @@ func constructParamTypes(paths map[string]*openapi3.PathItem) []TypeTemplate {
// TODO: Handle other mime types in a more idiomatic way
if mt != "application/json" {
field = TypeField{
Name: "Body",
Type: "io.Reader",
SerializationInfo: "`json:\"body,omitempty\" yaml:\"body,omitempty\"`",
Name: "Body",
Type: "io.Reader",
MarshalKey: "body",
Schema: nil, // no schema for non-JSON body
}
break
}

field = TypeField{
Name: "Body",
Type: "*" + convertToValidGoType("", "", r.Schema),
SerializationInfo: "`json:\"body,omitempty\" yaml:\"body,omitempty\"`",
Name: "Body",
Type: "*" + convertToValidGoType("", "", r.Schema),
MarshalKey: "body",
Schema: nil, // Body uses special serialization
}
}
// Body is always a required field
Expand Down Expand Up @@ -355,7 +447,7 @@ func constructEnums(enumStrCollection map[string][]string) []EnumTemplate {
return enumCollection
}

// writeTypes iterates over the templates, constructs the different types and writes to file
// writeTypes iterates over the templates, constructs the different types and writes to file.
func writeTypes(f *os.File, typeCollection []TypeTemplate, typeValidationCollection []ValidationTemplate, enumCollection []EnumTemplate) {
for _, tt := range typeCollection {
fmt.Fprint(f, tt.Render())
Expand Down Expand Up @@ -505,44 +597,15 @@ func createTypeObject(schema *openapi3.Schema, name, typeName, description strin
}
}

// Omicron includes fields that are both required and nullable:
// they can be set to a null value, but they must not be
// omitted. The sdk should present these fields to the user as
// optional, and serialize them to `null` if not provided.
isRequired := slices.Contains(required, k)
isRequiredNullable := v.Value.Nullable && isRequired
if slices.Contains(nullable(), typeName) || isRequiredNullable {
// We may have already decided to use a pointer. For
// example, convertToValidGoType always uses pointers
// for ints and bools. Prefix the type with "*", unless
// we've already made it a pointer upstream.
if !strings.HasPrefix(typeName, "*") {
typeName = fmt.Sprintf("*%s", typeName)
}
field := TypeField{
Name: strcase.ToCamel(k),
Type: typeName,
MarshalKey: k,
Schema: v,
Required: isRequired,
}

field := TypeField{}
if v.Value.Description != "" {
desc := fmt.Sprintf("// %s is %s", strcase.ToCamel(k), toLowerFirstLetter(strings.ReplaceAll(v.Value.Description, "\n", "\n// ")))
field.Description = desc
}

field.Name = strcase.ToCamel(k)
field.Type = typeName

// Configure json/yaml struct tags. By default, omit empty/zero
// values, but retain them for required fields.
//
// TODO: Use `omitzero` rather than `omitempty` on all relevant
// fields: https://github.com/oxidecomputer/oxide.go/issues/290
serInfo := fmt.Sprintf("`json:\"%s,omitempty\" yaml:\"%s,omitempty\"`", k, k)
if isNullableArray(v) || isRequired {
serInfo = fmt.Sprintf("`json:\"%s\" yaml:\"%s\"`", k, k)
} else if slices.Contains(omitzeroTypes(), typeName) {
serInfo = fmt.Sprintf("`json:\"%s,omitzero\" yaml:\"%s,omitzero\"`", k, k)
}

field.SerializationInfo = serInfo
// Note: pointer prefix is applied by TypeField.GoType() based on IsPointer()

fields = append(fields, field)

Expand Down Expand Up @@ -712,21 +775,24 @@ func createOneOf(s *openapi3.Schema, name, typeName string) ([]TypeTemplate, []E

// Avoids duplication for every enum
if !containsMatchFirstWord(parsedProperties, propertyName) {
field := TypeField{
Description: formatTypeDescription(propertyName, p.Value),
Name: propertyName,
Type: propertyType,
SerializationInfo: fmt.Sprintf("`json:\"%s,omitempty\" yaml:\"%s,omitempty\"`", prop, prop),
}

// We set the type of a field as "any" if every element of the oneOf property isn't the same
if slices.Contains(genericTypes, prop) {
field.Type = "any"
propertyType = "any"
}

// Check if the field is nullable and use omitzero instead of omitempty.
// Determine omit directive: nullable fields in oneOf use omitzero.
var omitDirective string
if p.Value != nil && p.Value.Nullable {
field.SerializationInfo = fmt.Sprintf("`json:\"%s,omitzero\" yaml:\"%s,omitzero\"`", prop, prop)
omitDirective = "omitzero"
}

field := TypeField{
Name: propertyName,
Type: propertyType,
MarshalKey: prop,
Schema: p,
FallbackDescription: true,
OmitDirective: omitDirective,
}

fields = append(fields, field)
Expand Down
Loading