Skip to content

Commit c6bc7c5

Browse files
mromaszewicznatsukagamiclaude
authored
feat: add configurable type mapping for OpenAPI primitive types (oapi-codegen#2223)
* 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> * Move TypeMapping to OutputOptions * Add example for type-mapping * Address PR feedback Move type-mapping examples to output-options --------- Co-authored-by: natsukagami <natsukagami@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 99615d0 commit c6bc7c5

File tree

11 files changed

+329
-49
lines changed

11 files changed

+329
-49
lines changed

configuration-schema.json

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,25 @@
257257
"type": "boolean",
258258
"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.",
259259
"default": false
260+
},
261+
"type-mapping": {
262+
"type": "object",
263+
"additionalProperties": false,
264+
"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.",
265+
"properties": {
266+
"integer": {
267+
"$ref": "#/$defs/format-mapping"
268+
},
269+
"number": {
270+
"$ref": "#/$defs/format-mapping"
271+
},
272+
"boolean": {
273+
"$ref": "#/$defs/format-mapping"
274+
},
275+
"string": {
276+
"$ref": "#/$defs/format-mapping"
277+
}
278+
}
260279
}
261280
}
262281
},
@@ -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
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# yaml-language-server: $schema=../../configuration-schema.json
2+
package: typemapping
3+
generate:
4+
models: true
5+
output-options:
6+
skip-prune: true
7+
type-mapping:
8+
number:
9+
default:
10+
type: int64
11+
formats:
12+
date:
13+
type: CustomDateHandler
14+
output: typemapping.gen.go
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package typemapping
2+
3+
type CustomDateHandler struct{}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package typemapping
2+
3+
// The configuration in this directory overrides the default handling of
4+
// "type: number" from producing an `int` to producing an `int64`, and we
5+
// override `type: string, format: date` to be a custom type in this package.
6+
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
openapi: "3.0.1"
2+
info:
3+
version: 1.0.0
4+
title: Type mapping test
5+
paths: {}
6+
components:
7+
schemas:
8+
EmployeeDatabaseRecord:
9+
type: object
10+
required:
11+
- ID
12+
- DateHired
13+
properties:
14+
ID:
15+
type: number
16+
DateHired:
17+
type: number
18+
format: date

examples/output-options/type-mapping/typemapping.gen.go

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.OutputOptions.TypeMapping != nil {
146+
globalState.typeMapping = DefaultTypeMapping.Merge(*opts.OutputOptions.TypeMapping)
147+
} else {
148+
globalState.typeMapping = DefaultTypeMapping
149+
}
143150

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

pkg/codegen/configuration.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ type OutputOptions struct {
308308
// "RequestBody"). Without this, the codegen will error on duplicate type
309309
// names, requiring manual resolution via x-go-name.
310310
ResolveTypeNameCollisions bool `yaml:"resolve-type-name-collisions,omitempty"`
311+
312+
// TypeMapping allows customizing OpenAPI type/format to Go type mappings.
313+
// User-specified mappings are merged on top of the defaults.
314+
TypeMapping *TypeMapping `yaml:"type-mapping,omitempty"`
311315
}
312316

313317
func (oo OutputOptions) Validate() map[string]string {

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+
}

0 commit comments

Comments
 (0)