diff --git a/configuration-schema.json b/configuration-schema.json index 8ff58d94b6..43694f9658 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -257,6 +257,25 @@ "type": "boolean", "description": "When set to true, automatically renames types that collide across different OpenAPI component sections (schemas, parameters, requestBodies, responses, headers) by appending a suffix based on the component section (e.g., 'Parameter', 'Response', 'RequestBody'). Without this, the codegen will error on duplicate type names, requiring manual resolution via x-go-name.", "default": false + }, + "type-mapping": { + "type": "object", + "additionalProperties": false, + "description": "TypeMapping allows customizing OpenAPI type/format to Go type mappings. User-specified mappings are merged on top of the defaults, so you only need to specify the types you want to override.", + "properties": { + "integer": { + "$ref": "#/$defs/format-mapping" + }, + "number": { + "$ref": "#/$defs/format-mapping" + }, + "boolean": { + "$ref": "#/$defs/format-mapping" + }, + "string": { + "$ref": "#/$defs/format-mapping" + } + } } } }, @@ -294,5 +313,43 @@ "required": [ "package", "output" - ] + ], + "$defs": { + "simple-type-spec": { + "type": "object", + "additionalProperties": false, + "description": "Specifies a Go type and optional import path", + "properties": { + "type": { + "type": "string", + "description": "The Go type to use (e.g. \"int64\", \"time.Time\", \"github.com/shopspring/decimal.Decimal\")" + }, + "import": { + "type": "string", + "description": "The Go import path required for this type (e.g. \"time\", \"encoding/json\")" + } + }, + "required": [ + "type" + ] + }, + "format-mapping": { + "type": "object", + "additionalProperties": false, + "description": "Maps an OpenAPI type's formats to Go types", + "properties": { + "default": { + "$ref": "#/$defs/simple-type-spec", + "description": "The default Go type when no format is specified or the format is unrecognized" + }, + "formats": { + "type": "object", + "description": "Format-specific Go type overrides (e.g. \"int32\": {\"type\": \"int32\"}, \"double\": {\"type\": \"float64\"})", + "additionalProperties": { + "$ref": "#/$defs/simple-type-spec" + } + } + } + } + } } diff --git a/examples/output-options/type-mapping/config.yaml b/examples/output-options/type-mapping/config.yaml new file mode 100644 index 0000000000..7a1af00d54 --- /dev/null +++ b/examples/output-options/type-mapping/config.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: typemapping +generate: + models: true +output-options: + skip-prune: true + type-mapping: + number: + default: + type: int64 + formats: + date: + type: CustomDateHandler +output: typemapping.gen.go diff --git a/examples/output-options/type-mapping/customdate.go b/examples/output-options/type-mapping/customdate.go new file mode 100644 index 0000000000..fda97a2d3e --- /dev/null +++ b/examples/output-options/type-mapping/customdate.go @@ -0,0 +1,3 @@ +package typemapping + +type CustomDateHandler struct{} diff --git a/examples/output-options/type-mapping/generate.go b/examples/output-options/type-mapping/generate.go new file mode 100644 index 0000000000..8344a10709 --- /dev/null +++ b/examples/output-options/type-mapping/generate.go @@ -0,0 +1,6 @@ +package typemapping + +// The configuration in this directory overrides the default handling of +// "type: number" from producing an `int` to producing an `int64`, and we +// override `type: string, format: date` to be a custom type in this package. +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/examples/output-options/type-mapping/spec.yaml b/examples/output-options/type-mapping/spec.yaml new file mode 100644 index 0000000000..2c8f63487a --- /dev/null +++ b/examples/output-options/type-mapping/spec.yaml @@ -0,0 +1,18 @@ +openapi: "3.0.1" +info: + version: 1.0.0 + title: Type mapping test +paths: {} +components: + schemas: + EmployeeDatabaseRecord: + type: object + required: + - ID + - DateHired + properties: + ID: + type: number + DateHired: + type: number + format: date diff --git a/examples/output-options/type-mapping/typemapping.gen.go b/examples/output-options/type-mapping/typemapping.gen.go new file mode 100644 index 0000000000..744cab7f9c --- /dev/null +++ b/examples/output-options/type-mapping/typemapping.gen.go @@ -0,0 +1,10 @@ +// Package typemapping provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package typemapping + +// EmployeeDatabaseRecord defines model for EmployeeDatabaseRecord. +type EmployeeDatabaseRecord struct { + DateHired CustomDateHandler `json:"DateHired"` + ID int64 `json:"ID"` +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 355de4f7a7..0f38b6ff7f 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -52,6 +52,8 @@ var globalState struct { // initialismsMap stores initialisms as "lower(initialism) -> initialism" map. // List of initialisms was taken from https://staticcheck.io/docs/configuration/options/#initialisms. initialismsMap map[string]string + // typeMapping is the merged type mapping (defaults + user overrides). + typeMapping TypeMapping } // goImport represents a go package to be imported in the generated code @@ -140,6 +142,11 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { globalState.options = opts globalState.spec = spec globalState.importMapping = constructImportMapping(opts.ImportMapping) + if opts.OutputOptions.TypeMapping != nil { + globalState.typeMapping = DefaultTypeMapping.Merge(*opts.OutputOptions.TypeMapping) + } else { + globalState.typeMapping = DefaultTypeMapping + } filterOperationsByTag(spec, opts) filterOperationsByOperationID(spec, opts) diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index d3281fefec..4598bb04ad 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -308,6 +308,10 @@ type OutputOptions struct { // "RequestBody"). Without this, the codegen will error on duplicate type // names, requiring manual resolution via x-go-name. ResolveTypeNameCollisions bool `yaml:"resolve-type-name-collisions,omitempty"` + + // TypeMapping allows customizing OpenAPI type/format to Go type mappings. + // User-specified mappings are merged on top of the defaults. + TypeMapping *TypeMapping `yaml:"type-mapping,omitempty"` } func (oo OutputOptions) Validate() map[string]string { diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 7435fa224d..71f4fbab9b 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -629,62 +629,26 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem setSkipOptionalPointerForContainerType(outSchema) } else if t.Is("integer") { - // We default to int if format doesn't ask for something else. - switch f { - case "int64", - "int32", - "int16", - "int8", - "int", - "uint64", - "uint32", - "uint16", - "uint8", - "uint": - outSchema.GoType = f - default: - outSchema.GoType = "int" - } + spec := globalState.typeMapping.Integer.Resolve(f) + outSchema.GoType = spec.Type outSchema.DefineViaAlias = true } else if t.Is("number") { - // We default to float for "number" - switch f { - case "double": - outSchema.GoType = "float64" - case "float", "": - outSchema.GoType = "float32" - default: - return fmt.Errorf("invalid number format: %s", f) - } + spec := globalState.typeMapping.Number.Resolve(f) + outSchema.GoType = spec.Type outSchema.DefineViaAlias = true } else if t.Is("boolean") { - if f != "" { - return fmt.Errorf("invalid format (%s) for boolean", f) - } - outSchema.GoType = "bool" + spec := globalState.typeMapping.Boolean.Resolve(f) + outSchema.GoType = spec.Type outSchema.DefineViaAlias = true } else if t.Is("string") { - // Special case string formats here. - switch f { - case "byte": - outSchema.GoType = "[]byte" + spec := globalState.typeMapping.String.Resolve(f) + outSchema.GoType = spec.Type + // Preserve special behaviors for specific types + if outSchema.GoType == "[]byte" { setSkipOptionalPointerForContainerType(outSchema) - case "email": - outSchema.GoType = "openapi_types.Email" - case "date": - outSchema.GoType = "openapi_types.Date" - case "date-time": - outSchema.GoType = "time.Time" - case "json": - outSchema.GoType = "json.RawMessage" + } + if outSchema.GoType == "json.RawMessage" { outSchema.SkipOptionalPointer = true - case "uuid": - outSchema.GoType = "openapi_types.UUID" - case "binary": - outSchema.GoType = "openapi_types.File" - default: - // All unrecognized formats are simply a regular string. - outSchema.GoType = "string" } outSchema.DefineViaAlias = true } else { diff --git a/pkg/codegen/typemapping.go b/pkg/codegen/typemapping.go new file mode 100644 index 0000000000..f5e20b54ac --- /dev/null +++ b/pkg/codegen/typemapping.go @@ -0,0 +1,109 @@ +package codegen + +// SimpleTypeSpec defines the Go type for an OpenAPI type/format combination, +// along with any import required to use it. +type SimpleTypeSpec struct { + Type string `yaml:"type" json:"type"` + Import string `yaml:"import,omitempty" json:"import,omitempty"` +} + +// FormatMapping defines the default Go type and format-specific overrides +// for an OpenAPI type. +type FormatMapping struct { + Default SimpleTypeSpec `yaml:"default" json:"default"` + Formats map[string]SimpleTypeSpec `yaml:"formats,omitempty" json:"formats,omitempty"` +} + +// TypeMapping defines the mapping from OpenAPI types to Go types. +type TypeMapping struct { + Integer FormatMapping `yaml:"integer,omitempty" json:"integer,omitempty"` + Number FormatMapping `yaml:"number,omitempty" json:"number,omitempty"` + Boolean FormatMapping `yaml:"boolean,omitempty" json:"boolean,omitempty"` + String FormatMapping `yaml:"string,omitempty" json:"string,omitempty"` +} + +// Merge returns a new TypeMapping with user overrides applied on top of base. +func (base TypeMapping) Merge(user TypeMapping) TypeMapping { + return TypeMapping{ + Integer: base.Integer.merge(user.Integer), + Number: base.Number.merge(user.Number), + Boolean: base.Boolean.merge(user.Boolean), + String: base.String.merge(user.String), + } +} + +func (base FormatMapping) merge(user FormatMapping) FormatMapping { + result := FormatMapping{ + Default: base.Default, + Formats: make(map[string]SimpleTypeSpec), + } + + // Copy base formats + for k, v := range base.Formats { + result.Formats[k] = v + } + + // Override with user default if specified + if user.Default.Type != "" { + result.Default = user.Default + } + + // Override/add user formats + for k, v := range user.Formats { + result.Formats[k] = v + } + + return result +} + +// Resolve returns the SimpleTypeSpec for a given format string. +// If the format has a specific mapping, that is returned; otherwise the default is used. +func (fm FormatMapping) Resolve(format string) SimpleTypeSpec { + if format != "" { + if spec, ok := fm.Formats[format]; ok { + return spec + } + } + return fm.Default +} + +// DefaultTypeMapping provides the default OpenAPI type/format to Go type mappings. +var DefaultTypeMapping = TypeMapping{ + Integer: FormatMapping{ + Default: SimpleTypeSpec{Type: "int"}, + Formats: map[string]SimpleTypeSpec{ + "int": {Type: "int"}, + "int8": {Type: "int8"}, + "int16": {Type: "int16"}, + "int32": {Type: "int32"}, + "int64": {Type: "int64"}, + "uint": {Type: "uint"}, + "uint8": {Type: "uint8"}, + "uint16": {Type: "uint16"}, + "uint32": {Type: "uint32"}, + "uint64": {Type: "uint64"}, + }, + }, + Number: FormatMapping{ + Default: SimpleTypeSpec{Type: "float32"}, + Formats: map[string]SimpleTypeSpec{ + "float": {Type: "float32"}, + "double": {Type: "float64"}, + }, + }, + Boolean: FormatMapping{ + Default: SimpleTypeSpec{Type: "bool"}, + }, + String: FormatMapping{ + Default: SimpleTypeSpec{Type: "string"}, + Formats: map[string]SimpleTypeSpec{ + "byte": {Type: "[]byte"}, + "email": {Type: "openapi_types.Email"}, + "date": {Type: "openapi_types.Date"}, + "date-time": {Type: "time.Time", Import: "time"}, + "json": {Type: "json.RawMessage", Import: "encoding/json"}, + "uuid": {Type: "openapi_types.UUID"}, + "binary": {Type: "openapi_types.File"}, + }, + }, +} diff --git a/pkg/codegen/typemapping_test.go b/pkg/codegen/typemapping_test.go new file mode 100644 index 0000000000..ca593cfd5d --- /dev/null +++ b/pkg/codegen/typemapping_test.go @@ -0,0 +1,88 @@ +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatMapping_Resolve(t *testing.T) { + fm := FormatMapping{ + Default: SimpleTypeSpec{Type: "int"}, + Formats: map[string]SimpleTypeSpec{ + "int32": {Type: "int32"}, + "int64": {Type: "int64"}, + }, + } + + assert.Equal(t, "int", fm.Resolve("").Type) + assert.Equal(t, "int32", fm.Resolve("int32").Type) + assert.Equal(t, "int64", fm.Resolve("int64").Type) + assert.Equal(t, "int", fm.Resolve("unknown-format").Type) +} + +func TestTypeMapping_Merge(t *testing.T) { + base := DefaultTypeMapping + + user := TypeMapping{ + Integer: FormatMapping{ + Default: SimpleTypeSpec{Type: "int64"}, + }, + String: FormatMapping{ + Formats: map[string]SimpleTypeSpec{ + "date-time": {Type: "civil.DateTime", Import: "cloud.google.com/go/civil"}, + }, + }, + } + + merged := base.Merge(user) + + // Integer default overridden + assert.Equal(t, "int64", merged.Integer.Default.Type) + // Integer formats still inherited from base + assert.Equal(t, "int32", merged.Integer.Formats["int32"].Type) + + // String date-time overridden + assert.Equal(t, "civil.DateTime", merged.String.Formats["date-time"].Type) + assert.Equal(t, "cloud.google.com/go/civil", merged.String.Formats["date-time"].Import) + // String default still inherited from base + assert.Equal(t, "string", merged.String.Default.Type) + // Other string formats still inherited + assert.Equal(t, "openapi_types.UUID", merged.String.Formats["uuid"].Type) + + // Number and Boolean unchanged + assert.Equal(t, "float32", merged.Number.Default.Type) + assert.Equal(t, "bool", merged.Boolean.Default.Type) +} + +func TestDefaultTypeMapping_Completeness(t *testing.T) { + // Verify all the default mappings match what was previously hardcoded + dm := DefaultTypeMapping + + // Integer + assert.Equal(t, "int", dm.Integer.Resolve("").Type) + assert.Equal(t, "int32", dm.Integer.Resolve("int32").Type) + assert.Equal(t, "int64", dm.Integer.Resolve("int64").Type) + assert.Equal(t, "uint32", dm.Integer.Resolve("uint32").Type) + assert.Equal(t, "int", dm.Integer.Resolve("unknown").Type) + + // Number + assert.Equal(t, "float32", dm.Number.Resolve("").Type) + assert.Equal(t, "float32", dm.Number.Resolve("float").Type) + assert.Equal(t, "float64", dm.Number.Resolve("double").Type) + assert.Equal(t, "float32", dm.Number.Resolve("unknown").Type) + + // Boolean + assert.Equal(t, "bool", dm.Boolean.Resolve("").Type) + + // String + assert.Equal(t, "string", dm.String.Resolve("").Type) + assert.Equal(t, "[]byte", dm.String.Resolve("byte").Type) + assert.Equal(t, "openapi_types.Email", dm.String.Resolve("email").Type) + assert.Equal(t, "openapi_types.Date", dm.String.Resolve("date").Type) + assert.Equal(t, "time.Time", dm.String.Resolve("date-time").Type) + assert.Equal(t, "json.RawMessage", dm.String.Resolve("json").Type) + assert.Equal(t, "openapi_types.UUID", dm.String.Resolve("uuid").Type) + assert.Equal(t, "openapi_types.File", dm.String.Resolve("binary").Type) + assert.Equal(t, "string", dm.String.Resolve("unknown").Type) +}