Skip to content

Commit 63de366

Browse files
Add support for external references
1 parent e778d93 commit 63de366

File tree

17 files changed

+483
-57
lines changed

17 files changed

+483
-57
lines changed

experimental/README.md

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,51 @@ name-substitutions:
167167
foo: MyCustomFoo # "foo" becomes "MyCustomFoo" instead of "Foo"
168168
property-names:
169169
bar: MyCustomBar # "bar" field becomes "MyCustomBar"
170+
171+
# Import mapping: resolve external $refs to Go packages
172+
import-mapping:
173+
# Map external spec files to their Go package paths
174+
../common/api.yaml: github.com/org/project/common
175+
https://example.com/specs/shared.yaml: github.com/org/shared
176+
# Use "-" to indicate types should stay in the current package
177+
./local-types.yaml: "-"
178+
```
179+
180+
### External References
181+
182+
When your OpenAPI spec references schemas from other files:
183+
184+
```yaml
185+
# In your spec
186+
components:
187+
schemas:
188+
Order:
189+
properties:
190+
customer:
191+
$ref: '../common/api.yaml#/components/schemas/Customer'
192+
```
193+
194+
Configure import-mapping to tell oapi-codegen where to find those types:
195+
196+
```yaml
197+
import-mapping:
198+
../common/api.yaml: github.com/org/project/common
199+
```
200+
201+
The generated code will import the external package and reference types with a hashed alias:
202+
203+
```go
204+
import (
205+
ext_a1b2c3d4 "github.com/org/project/common"
206+
)
207+
208+
type Order struct {
209+
Customer *ext_a1b2c3d4.Customer `json:"customer,omitempty"`
210+
}
170211
```
171212

213+
**Important:** You must generate types for the external spec separately. The import-mapping only tells oapi-codegen how to reference types that already exist in another package.
214+
172215
Use with `-config`:
173216
```bash
174217
go run ./cmd/oapi-codegen -config config.yaml openapi.yaml
@@ -191,15 +234,34 @@ go run ./cmd/oapi-codegen -config config.yaml openapi.yaml
191234
| `string` | `email` | `Email` (custom type) |
192235
| `string` | `binary` | `File` (custom type) |
193236

194-
### Name Override Limitations
237+
### Preprocessing with OpenAPI Overlays
238+
239+
For advanced customizations (renaming types, modifying schemas, adding extensions), preprocess your spec using the [OpenAPI Overlay Specification](https://learn.openapis.org/overlay/):
240+
241+
```yaml
242+
# overlay.yaml
243+
overlay: 1.0.0
244+
info:
245+
title: My customizations
246+
version: 1.0.0
247+
actions:
248+
- target: $.components.schemas.Cat
249+
update:
250+
x-go-name: Kitty
251+
- target: $.components.schemas.Pet.properties.id
252+
update:
253+
type: string
254+
format: uuid
255+
```
256+
257+
Apply with [speakeasy-api/openapi-overlay](https://github.com/speakeasy-api/openapi-overlay):
258+
```bash
259+
go install github.com/speakeasy-api/openapi-overlay@latest
260+
openapi-overlay apply --overlay=overlay.yaml --spec=openapi.yaml > modified.yaml
261+
go run ./cmd/oapi-codegen -package myapi modified.yaml
262+
```
195263

196-
> **Note:** The current `name-substitutions` system only overrides individual name *parts* during conversion, not full generated type names.
197-
>
198-
> For example, if you have a schema at `#/components/schemas/Cat`:
199-
> - Setting `type-names: {Cat: Kitty}` will produce `KittySchemaComponent` (stable) and `Kitty` (short)
200-
> - You cannot currently override the full stable name `CatSchemaComponent` to something completely different
201-
>
202-
> Full type name overrides (by schema path or generated name) are not yet implemented.
264+
Or use [libopenapi](https://pb33f.io/libopenapi/overlays/) programmatically if you need to integrate into a build pipeline.
203265

204266
## Development
205267

experimental/cmd/oapi-codegen/main.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/pb33f/libopenapi"
11+
"github.com/pb33f/libopenapi/datamodel"
1112
"gopkg.in/yaml.v3"
1213

1314
"github.com/oapi-codegen/oapi-codegen/experimental/internal/codegen"
@@ -40,7 +41,20 @@ func main() {
4041
os.Exit(1)
4142
}
4243

43-
doc, err := libopenapi.NewDocument(specData)
44+
// Get the absolute path of the spec file's directory for resolving external refs
45+
absSpecPath, err := filepath.Abs(specPath)
46+
if err != nil {
47+
fmt.Fprintf(os.Stderr, "error getting absolute path: %v\n", err)
48+
os.Exit(1)
49+
}
50+
specDir := filepath.Dir(absSpecPath)
51+
52+
// Configure libopenapi with BasePath to resolve external references
53+
docConfig := datamodel.NewDocumentConfiguration()
54+
docConfig.BasePath = specDir
55+
docConfig.AllowFileReferences = true
56+
57+
doc, err := libopenapi.NewDocumentWithConfiguration(specData, docConfig)
4458
if err != nil {
4559
fmt.Fprintf(os.Stderr, "error parsing spec: %v\n", err)
4660
os.Exit(1)

experimental/internal/codegen/codegen.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ func Generate(doc libopenapi.Document, cfg Configuration) (string, error) {
2626
ComputeSchemaNames(schemas, converter)
2727

2828
// Pass 3: Generate Go code
29-
gen := NewTypeGenerator(cfg.TypeMapping, converter)
29+
importResolver := NewImportResolver(cfg.ImportMapping)
30+
gen := NewTypeGenerator(cfg.TypeMapping, converter, importResolver)
3031
gen.IndexSchemas(schemas)
3132

3233
output := NewOutput(cfg.PackageName)
33-
output.AddImport("encoding/json", "")
34-
output.AddImport("fmt", "")
34+
// Note: encoding/json and fmt imports are added by generateType when needed
3535

3636
// Generate types for each schema
3737
for _, desc := range schemas {
@@ -113,6 +113,9 @@ func generateStructType(gen *TypeGenerator, desc *SchemaDescriptor) string {
113113

114114
// Check if we need additionalProperties handling
115115
if gen.HasAdditionalProperties(desc) {
116+
// Mixed properties need encoding/json and fmt for marshal/unmarshal
117+
gen.AddJSONImports()
118+
116119
addPropsType := gen.AdditionalPropertiesType(desc)
117120
structCode := GenerateStructWithAdditionalProps(desc.StableName, fields, addPropsType, doc)
118121

@@ -310,6 +313,7 @@ func generateAllOfType(gen *TypeGenerator, desc *SchemaDescriptor) string {
310313
var code string
311314
if len(unionFields) > 0 {
312315
// Has union members - need custom marshal/unmarshal
316+
gen.AddJSONImports()
313317
code = generateAllOfStructWithUnions(desc.StableName, finalFields, unionFields, doc)
314318
} else {
315319
// Simple case - just flattened fields
@@ -479,6 +483,9 @@ func generateAnyOfType(gen *TypeGenerator, desc *SchemaDescriptor) string {
479483
return ""
480484
}
481485

486+
// Union types need encoding/json and fmt for marshal/unmarshal
487+
gen.AddJSONImports()
488+
482489
doc := extractDescription(desc.Schema)
483490
structCode := GenerateUnionType(desc.StableName, members, false, doc)
484491
marshalCode := GenerateUnionMarshalAnyOf(desc.StableName, members)
@@ -501,6 +508,9 @@ func generateOneOfType(gen *TypeGenerator, desc *SchemaDescriptor) string {
501508
return ""
502509
}
503510

511+
// Union types need encoding/json and fmt for marshal/unmarshal
512+
gen.AddJSONImports()
513+
504514
doc := extractDescription(desc.Schema)
505515
structCode := GenerateUnionType(desc.StableName, members, true, doc)
506516
marshalCode := GenerateUnionMarshalOneOf(desc.StableName, members)

experimental/internal/codegen/configuration.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package codegen
22

3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"sort"
7+
)
8+
39
type Configuration struct {
410
// PackageName which will be used in all generated files
511
PackageName string `yaml:"package"`
@@ -11,10 +17,76 @@ type Configuration struct {
1117
NameMangling NameMangling `yaml:"name-mangling,omitempty"`
1218
// NameSubstitutions allows direct overrides of generated names
1319
NameSubstitutions NameSubstitutions `yaml:"name-substitutions,omitempty"`
20+
// ImportMapping maps external spec file paths to Go package import paths.
21+
// Example: {"../common/api.yaml": "github.com/org/project/common"}
22+
// Use "-" as the value to indicate types should be in the current package.
23+
ImportMapping map[string]string `yaml:"import-mapping,omitempty"`
1424
}
1525

1626
// ApplyDefaults merges user configuration on top of default values.
1727
func (c *Configuration) ApplyDefaults() {
1828
c.TypeMapping = DefaultTypeMapping.Merge(c.TypeMapping)
1929
c.NameMangling = DefaultNameMangling().Merge(c.NameMangling)
2030
}
31+
32+
// ExternalImport represents an external package import with its alias.
33+
type ExternalImport struct {
34+
Alias string // Short alias for use in generated code (e.g., "ext_a1b2c3")
35+
Path string // Full import path (e.g., "github.com/org/project/common")
36+
}
37+
38+
// ImportResolver resolves external references to Go package imports.
39+
type ImportResolver struct {
40+
mapping map[string]ExternalImport // spec file path -> import info
41+
}
42+
43+
// NewImportResolver creates an ImportResolver from the configuration's import mapping.
44+
func NewImportResolver(importMapping map[string]string) *ImportResolver {
45+
resolver := &ImportResolver{
46+
mapping: make(map[string]ExternalImport),
47+
}
48+
49+
for specPath, pkgPath := range importMapping {
50+
if pkgPath == "-" {
51+
// "-" means current package, no import needed
52+
resolver.mapping[specPath] = ExternalImport{Alias: "", Path: ""}
53+
} else {
54+
resolver.mapping[specPath] = ExternalImport{
55+
Alias: hashImportAlias(pkgPath),
56+
Path: pkgPath,
57+
}
58+
}
59+
}
60+
61+
return resolver
62+
}
63+
64+
// Resolve looks up an external spec file path and returns its import info.
65+
// Returns nil if the path is not in the mapping.
66+
func (r *ImportResolver) Resolve(specPath string) *ExternalImport {
67+
if imp, ok := r.mapping[specPath]; ok {
68+
return &imp
69+
}
70+
return nil
71+
}
72+
73+
// AllImports returns all external imports sorted by alias.
74+
func (r *ImportResolver) AllImports() []ExternalImport {
75+
var imports []ExternalImport
76+
for _, imp := range r.mapping {
77+
if imp.Path != "" { // Skip current package markers
78+
imports = append(imports, imp)
79+
}
80+
}
81+
sort.Slice(imports, func(i, j int) bool {
82+
return imports[i].Alias < imports[j].Alias
83+
})
84+
return imports
85+
}
86+
87+
// hashImportAlias generates a short, deterministic alias from an import path.
88+
// Uses first 8 characters of SHA256 hash prefixed with "ext_".
89+
func hashImportAlias(importPath string) string {
90+
h := sha256.Sum256([]byte(importPath))
91+
return "ext_" + hex.EncodeToString(h[:])[:8]
92+
}

experimental/internal/codegen/schema.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ func (d *SchemaDescriptor) IsReference() bool {
6464
return d.Ref != ""
6565
}
6666

67+
// IsExternalReference returns true if this is a reference to an external file.
68+
// External refs have the format: file.yaml#/path/to/schema
69+
func (d *SchemaDescriptor) IsExternalReference() bool {
70+
if d.Ref == "" {
71+
return false
72+
}
73+
// External refs contain # but don't start with it
74+
return !strings.HasPrefix(d.Ref, "#") && strings.Contains(d.Ref, "#")
75+
}
76+
77+
// ParseExternalRef splits an external reference into its file path and internal path.
78+
// For "common/api.yaml#/components/schemas/Pet", returns ("common/api.yaml", "#/components/schemas/Pet").
79+
// Returns empty strings if not an external ref.
80+
func (d *SchemaDescriptor) ParseExternalRef() (filePath, internalPath string) {
81+
if !d.IsExternalReference() {
82+
return "", ""
83+
}
84+
parts := strings.SplitN(d.Ref, "#", 2)
85+
if len(parts) != 2 {
86+
return "", ""
87+
}
88+
return parts[0], "#" + parts[1]
89+
}
90+
6791
// IsComponentSchema returns true if this schema is defined in #/components/schemas
6892
func (d *SchemaDescriptor) IsComponentSchema() bool {
6993
return len(d.Path) >= 2 && d.Path[0] == "components" && d.Path[1] == "schemas"

experimental/internal/codegen/test/comprehensive/output/comprehensive.gen.go

Lines changed: 39 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package: externalref
2+
output: internal/codegen/test/external_ref/spec.gen.go
3+
import-mapping:
4+
./packagea/spec.yaml: github.com/oapi-codegen/oapi-codegen/experimental/internal/codegen/test/external_ref/packagea
5+
./packageb/spec.yaml: github.com/oapi-codegen/oapi-codegen/experimental/internal/codegen/test/external_ref/packageb

0 commit comments

Comments
 (0)