Skip to content

Commit e70edf7

Browse files
mromaszewicznatsukagamiclaude
committed
feat: add configurable type mapping for OpenAPI primitive types
Supersedes oapi-codegen#428, since the original author's branch is gone and his PR only exists in the ref log. Added a TypeMapping configuration that lets users override the default OpenAPI type/format to Go type mappings. User-specified mappings are merged on top of built-in defaults, so only overrides need to be specified. For example, mapping string/date-time to a civil.DateTime or changing the default integer type to int64. Changes: - New TypeMapping, FormatMapping, SimpleTypeSpec types with Merge() and Resolve() methods (pkg/codegen/typemapping.go) - TypeMapping field added to Configuration struct - Merged type mapping stored on globalState, initialized in Generate() - Replaced hardcoded switch statements in oapiSchemaToGoType() with map-based lookups via globalState.typeMapping - Updated configuration-schema.json with type-mapping property and reusable $defs for format-mapping and simple-type-spec - Unit tests for merge, resolve, and default mapping completeness Co-Authored-By: natsukagami <natsukagami@gmail.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 01d4fc0 commit e70edf7

File tree

6 files changed

+277
-49
lines changed

6 files changed

+277
-49
lines changed

configuration-schema.json

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,25 @@
260260
}
261261
}
262262
},
263+
"type-mapping": {
264+
"type": "object",
265+
"additionalProperties": false,
266+
"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.",
267+
"properties": {
268+
"integer": {
269+
"$ref": "#/$defs/format-mapping"
270+
},
271+
"number": {
272+
"$ref": "#/$defs/format-mapping"
273+
},
274+
"boolean": {
275+
"$ref": "#/$defs/format-mapping"
276+
},
277+
"string": {
278+
"$ref": "#/$defs/format-mapping"
279+
}
280+
}
281+
},
263282
"import-mapping": {
264283
"type": "object",
265284
"additionalProperties": {
@@ -294,5 +313,43 @@
294313
"required": [
295314
"package",
296315
"output"
297-
]
316+
],
317+
"$defs": {
318+
"simple-type-spec": {
319+
"type": "object",
320+
"additionalProperties": false,
321+
"description": "Specifies a Go type and optional import path",
322+
"properties": {
323+
"type": {
324+
"type": "string",
325+
"description": "The Go type to use (e.g. \"int64\", \"time.Time\", \"github.com/shopspring/decimal.Decimal\")"
326+
},
327+
"import": {
328+
"type": "string",
329+
"description": "The Go import path required for this type (e.g. \"time\", \"encoding/json\")"
330+
}
331+
},
332+
"required": [
333+
"type"
334+
]
335+
},
336+
"format-mapping": {
337+
"type": "object",
338+
"additionalProperties": false,
339+
"description": "Maps an OpenAPI type's formats to Go types",
340+
"properties": {
341+
"default": {
342+
"$ref": "#/$defs/simple-type-spec",
343+
"description": "The default Go type when no format is specified or the format is unrecognized"
344+
},
345+
"formats": {
346+
"type": "object",
347+
"description": "Format-specific Go type overrides (e.g. \"int32\": {\"type\": \"int32\"}, \"double\": {\"type\": \"float64\"})",
348+
"additionalProperties": {
349+
"$ref": "#/$defs/simple-type-spec"
350+
}
351+
}
352+
}
353+
}
354+
}
298355
}

pkg/codegen/codegen.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ var globalState struct {
5252
// initialismsMap stores initialisms as "lower(initialism) -> initialism" map.
5353
// List of initialisms was taken from https://staticcheck.io/docs/configuration/options/#initialisms.
5454
initialismsMap map[string]string
55+
// typeMapping is the merged type mapping (defaults + user overrides).
56+
typeMapping TypeMapping
5557
}
5658

5759
// 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) {
140142
globalState.options = opts
141143
globalState.spec = spec
142144
globalState.importMapping = constructImportMapping(opts.ImportMapping)
145+
if opts.TypeMapping != nil {
146+
globalState.typeMapping = DefaultTypeMapping.Merge(*opts.TypeMapping)
147+
} else {
148+
globalState.typeMapping = DefaultTypeMapping
149+
}
143150

144151
filterOperationsByTag(spec, opts)
145152
filterOperationsByOperationID(spec, opts)

pkg/codegen/configuration.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ type Configuration struct {
2323
OutputOptions OutputOptions `yaml:"output-options,omitempty"`
2424
// ImportMapping specifies the golang package path for each external reference
2525
ImportMapping map[string]string `yaml:"import-mapping,omitempty"`
26+
// TypeMapping allows customizing OpenAPI type/format to Go type mappings.
27+
// User-specified mappings are merged on top of the defaults.
28+
TypeMapping *TypeMapping `yaml:"type-mapping,omitempty"`
2629
// AdditionalImports defines any additional Go imports to add to the generated code
2730
AdditionalImports []AdditionalImport `yaml:"additional-imports,omitempty"`
2831
// NoVCSVersionOverride allows overriding the version of the application for cases where no Version Control System (VCS) is available when building, for instance when using a Nix derivation.

pkg/codegen/schema.go

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -629,62 +629,26 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem
629629
setSkipOptionalPointerForContainerType(outSchema)
630630

631631
} else if t.Is("integer") {
632-
// We default to int if format doesn't ask for something else.
633-
switch f {
634-
case "int64",
635-
"int32",
636-
"int16",
637-
"int8",
638-
"int",
639-
"uint64",
640-
"uint32",
641-
"uint16",
642-
"uint8",
643-
"uint":
644-
outSchema.GoType = f
645-
default:
646-
outSchema.GoType = "int"
647-
}
632+
spec := globalState.typeMapping.Integer.Resolve(f)
633+
outSchema.GoType = spec.Type
648634
outSchema.DefineViaAlias = true
649635
} else if t.Is("number") {
650-
// We default to float for "number"
651-
switch f {
652-
case "double":
653-
outSchema.GoType = "float64"
654-
case "float", "":
655-
outSchema.GoType = "float32"
656-
default:
657-
return fmt.Errorf("invalid number format: %s", f)
658-
}
636+
spec := globalState.typeMapping.Number.Resolve(f)
637+
outSchema.GoType = spec.Type
659638
outSchema.DefineViaAlias = true
660639
} else if t.Is("boolean") {
661-
if f != "" {
662-
return fmt.Errorf("invalid format (%s) for boolean", f)
663-
}
664-
outSchema.GoType = "bool"
640+
spec := globalState.typeMapping.Boolean.Resolve(f)
641+
outSchema.GoType = spec.Type
665642
outSchema.DefineViaAlias = true
666643
} else if t.Is("string") {
667-
// Special case string formats here.
668-
switch f {
669-
case "byte":
670-
outSchema.GoType = "[]byte"
644+
spec := globalState.typeMapping.String.Resolve(f)
645+
outSchema.GoType = spec.Type
646+
// Preserve special behaviors for specific types
647+
if outSchema.GoType == "[]byte" {
671648
setSkipOptionalPointerForContainerType(outSchema)
672-
case "email":
673-
outSchema.GoType = "openapi_types.Email"
674-
case "date":
675-
outSchema.GoType = "openapi_types.Date"
676-
case "date-time":
677-
outSchema.GoType = "time.Time"
678-
case "json":
679-
outSchema.GoType = "json.RawMessage"
649+
}
650+
if outSchema.GoType == "json.RawMessage" {
680651
outSchema.SkipOptionalPointer = true
681-
case "uuid":
682-
outSchema.GoType = "openapi_types.UUID"
683-
case "binary":
684-
outSchema.GoType = "openapi_types.File"
685-
default:
686-
// All unrecognized formats are simply a regular string.
687-
outSchema.GoType = "string"
688652
}
689653
outSchema.DefineViaAlias = true
690654
} else {

pkg/codegen/typemapping.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package codegen
2+
3+
// SimpleTypeSpec defines the Go type for an OpenAPI type/format combination,
4+
// along with any import required to use it.
5+
type SimpleTypeSpec struct {
6+
Type string `yaml:"type" json:"type"`
7+
Import string `yaml:"import,omitempty" json:"import,omitempty"`
8+
}
9+
10+
// FormatMapping defines the default Go type and format-specific overrides
11+
// for an OpenAPI type.
12+
type FormatMapping struct {
13+
Default SimpleTypeSpec `yaml:"default" json:"default"`
14+
Formats map[string]SimpleTypeSpec `yaml:"formats,omitempty" json:"formats,omitempty"`
15+
}
16+
17+
// TypeMapping defines the mapping from OpenAPI types to Go types.
18+
type TypeMapping struct {
19+
Integer FormatMapping `yaml:"integer,omitempty" json:"integer,omitempty"`
20+
Number FormatMapping `yaml:"number,omitempty" json:"number,omitempty"`
21+
Boolean FormatMapping `yaml:"boolean,omitempty" json:"boolean,omitempty"`
22+
String FormatMapping `yaml:"string,omitempty" json:"string,omitempty"`
23+
}
24+
25+
// Merge returns a new TypeMapping with user overrides applied on top of base.
26+
func (base TypeMapping) Merge(user TypeMapping) TypeMapping {
27+
return TypeMapping{
28+
Integer: base.Integer.merge(user.Integer),
29+
Number: base.Number.merge(user.Number),
30+
Boolean: base.Boolean.merge(user.Boolean),
31+
String: base.String.merge(user.String),
32+
}
33+
}
34+
35+
func (base FormatMapping) merge(user FormatMapping) FormatMapping {
36+
result := FormatMapping{
37+
Default: base.Default,
38+
Formats: make(map[string]SimpleTypeSpec),
39+
}
40+
41+
// Copy base formats
42+
for k, v := range base.Formats {
43+
result.Formats[k] = v
44+
}
45+
46+
// Override with user default if specified
47+
if user.Default.Type != "" {
48+
result.Default = user.Default
49+
}
50+
51+
// Override/add user formats
52+
for k, v := range user.Formats {
53+
result.Formats[k] = v
54+
}
55+
56+
return result
57+
}
58+
59+
// Resolve returns the SimpleTypeSpec for a given format string.
60+
// If the format has a specific mapping, that is returned; otherwise the default is used.
61+
func (fm FormatMapping) Resolve(format string) SimpleTypeSpec {
62+
if format != "" {
63+
if spec, ok := fm.Formats[format]; ok {
64+
return spec
65+
}
66+
}
67+
return fm.Default
68+
}
69+
70+
// DefaultTypeMapping provides the default OpenAPI type/format to Go type mappings.
71+
var DefaultTypeMapping = TypeMapping{
72+
Integer: FormatMapping{
73+
Default: SimpleTypeSpec{Type: "int"},
74+
Formats: map[string]SimpleTypeSpec{
75+
"int": {Type: "int"},
76+
"int8": {Type: "int8"},
77+
"int16": {Type: "int16"},
78+
"int32": {Type: "int32"},
79+
"int64": {Type: "int64"},
80+
"uint": {Type: "uint"},
81+
"uint8": {Type: "uint8"},
82+
"uint16": {Type: "uint16"},
83+
"uint32": {Type: "uint32"},
84+
"uint64": {Type: "uint64"},
85+
},
86+
},
87+
Number: FormatMapping{
88+
Default: SimpleTypeSpec{Type: "float32"},
89+
Formats: map[string]SimpleTypeSpec{
90+
"float": {Type: "float32"},
91+
"double": {Type: "float64"},
92+
},
93+
},
94+
Boolean: FormatMapping{
95+
Default: SimpleTypeSpec{Type: "bool"},
96+
},
97+
String: FormatMapping{
98+
Default: SimpleTypeSpec{Type: "string"},
99+
Formats: map[string]SimpleTypeSpec{
100+
"byte": {Type: "[]byte"},
101+
"email": {Type: "openapi_types.Email"},
102+
"date": {Type: "openapi_types.Date"},
103+
"date-time": {Type: "time.Time", Import: "time"},
104+
"json": {Type: "json.RawMessage", Import: "encoding/json"},
105+
"uuid": {Type: "openapi_types.UUID"},
106+
"binary": {Type: "openapi_types.File"},
107+
},
108+
},
109+
}

pkg/codegen/typemapping_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package codegen
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestFormatMapping_Resolve(t *testing.T) {
10+
fm := FormatMapping{
11+
Default: SimpleTypeSpec{Type: "int"},
12+
Formats: map[string]SimpleTypeSpec{
13+
"int32": {Type: "int32"},
14+
"int64": {Type: "int64"},
15+
},
16+
}
17+
18+
assert.Equal(t, "int", fm.Resolve("").Type)
19+
assert.Equal(t, "int32", fm.Resolve("int32").Type)
20+
assert.Equal(t, "int64", fm.Resolve("int64").Type)
21+
assert.Equal(t, "int", fm.Resolve("unknown-format").Type)
22+
}
23+
24+
func TestTypeMapping_Merge(t *testing.T) {
25+
base := DefaultTypeMapping
26+
27+
user := TypeMapping{
28+
Integer: FormatMapping{
29+
Default: SimpleTypeSpec{Type: "int64"},
30+
},
31+
String: FormatMapping{
32+
Formats: map[string]SimpleTypeSpec{
33+
"date-time": {Type: "civil.DateTime", Import: "cloud.google.com/go/civil"},
34+
},
35+
},
36+
}
37+
38+
merged := base.Merge(user)
39+
40+
// Integer default overridden
41+
assert.Equal(t, "int64", merged.Integer.Default.Type)
42+
// Integer formats still inherited from base
43+
assert.Equal(t, "int32", merged.Integer.Formats["int32"].Type)
44+
45+
// String date-time overridden
46+
assert.Equal(t, "civil.DateTime", merged.String.Formats["date-time"].Type)
47+
assert.Equal(t, "cloud.google.com/go/civil", merged.String.Formats["date-time"].Import)
48+
// String default still inherited from base
49+
assert.Equal(t, "string", merged.String.Default.Type)
50+
// Other string formats still inherited
51+
assert.Equal(t, "openapi_types.UUID", merged.String.Formats["uuid"].Type)
52+
53+
// Number and Boolean unchanged
54+
assert.Equal(t, "float32", merged.Number.Default.Type)
55+
assert.Equal(t, "bool", merged.Boolean.Default.Type)
56+
}
57+
58+
func TestDefaultTypeMapping_Completeness(t *testing.T) {
59+
// Verify all the default mappings match what was previously hardcoded
60+
dm := DefaultTypeMapping
61+
62+
// Integer
63+
assert.Equal(t, "int", dm.Integer.Resolve("").Type)
64+
assert.Equal(t, "int32", dm.Integer.Resolve("int32").Type)
65+
assert.Equal(t, "int64", dm.Integer.Resolve("int64").Type)
66+
assert.Equal(t, "uint32", dm.Integer.Resolve("uint32").Type)
67+
assert.Equal(t, "int", dm.Integer.Resolve("unknown").Type)
68+
69+
// Number
70+
assert.Equal(t, "float32", dm.Number.Resolve("").Type)
71+
assert.Equal(t, "float32", dm.Number.Resolve("float").Type)
72+
assert.Equal(t, "float64", dm.Number.Resolve("double").Type)
73+
assert.Equal(t, "float32", dm.Number.Resolve("unknown").Type)
74+
75+
// Boolean
76+
assert.Equal(t, "bool", dm.Boolean.Resolve("").Type)
77+
78+
// String
79+
assert.Equal(t, "string", dm.String.Resolve("").Type)
80+
assert.Equal(t, "[]byte", dm.String.Resolve("byte").Type)
81+
assert.Equal(t, "openapi_types.Email", dm.String.Resolve("email").Type)
82+
assert.Equal(t, "openapi_types.Date", dm.String.Resolve("date").Type)
83+
assert.Equal(t, "time.Time", dm.String.Resolve("date-time").Type)
84+
assert.Equal(t, "json.RawMessage", dm.String.Resolve("json").Type)
85+
assert.Equal(t, "openapi_types.UUID", dm.String.Resolve("uuid").Type)
86+
assert.Equal(t, "openapi_types.File", dm.String.Resolve("binary").Type)
87+
assert.Equal(t, "string", dm.String.Resolve("unknown").Type)
88+
}

0 commit comments

Comments
 (0)