Skip to content

Commit 5466c6c

Browse files
Support external file $ref resolution (#59)
1 parent adf901a commit 5466c6c

10 files changed

Lines changed: 195 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ on the real value-add for your organization.
4343
- **Flexible filtering** - Include/exclude by paths, tags, operation IDs, schema properties, or extensions
4444
- **Transitive pruning** - Automatically remove schemas that are only referenced by filtered-out properties
4545
- **[OpenAPI Overlays](https://doordash-oss.github.io/oapi-codegen-dd/overlays/)** - Modify specs without editing originals (add extensions, remove paths)
46+
- **External file `$ref` resolution** - Split specs across multiple files with relative `$ref` references (auto-detected from spec path, or set `base-path` in config)
4647

4748
### Programmatic Access
4849
- **Runtime package** - Public API for working with generated types

cmd/oapi-codegen/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ func main() {
7474
}
7575
}
7676

77+
// Set BasePath from spec file path for resolving external $ref references
78+
if cfg.BasePath == "" && !strings.HasPrefix(specPath, "http://") && !strings.HasPrefix(specPath, "https://") {
79+
absPath, err := filepath.Abs(specPath)
80+
if err != nil {
81+
errExit("Error resolving spec file path: %v", err)
82+
}
83+
cfg.BasePath = filepath.Dir(absPath)
84+
}
85+
7786
cfg = cfg.WithDefaults()
7887

7988
// If no config file was provided and input is a URL, output to stdout

configuration-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"type": "object",
6464
"description": "UserContext is the map of user-provided context values to be used in templates user overrides.",
6565
"additionalProperties": true
66+
},
67+
"base-path": {
68+
"type": "string",
69+
"description": "BasePath is the directory used to resolve relative $ref file references. When set, external file references like './common.yaml#/...' will be resolved relative to this directory. Typically set to the directory containing the spec file. When using the CLI, this is auto-detected from the spec file path."
6670
}
6771
},
6872
"required": [],

docs/configuration.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ overlay:
6363

6464
See [Overlays](overlays.md) for detailed documentation and examples.
6565

66+
### External File References
67+
68+
#### `base-path`
69+
**Type:** `string` | **Default:** auto-detected from spec file path
70+
71+
Directory used to resolve relative `$ref` file references. When using the CLI with a local spec file, this is automatically set to the spec file's parent directory. Set this explicitly when using the library programmatically or when the spec references files relative to a different directory.
72+
73+
```yaml
74+
base-path: ./specs
75+
```
76+
77+
This enables splitting large specs across multiple files:
78+
79+
```yaml
80+
# specs/api.yaml
81+
components:
82+
schemas:
83+
User:
84+
type: object
85+
properties:
86+
address:
87+
$ref: './common.yaml#/components/schemas/Address'
88+
```
89+
6690
### Output Settings
6791

6892
#### `output.use-single-file`

pkg/codegen/codegen_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"embed"
1515
"go/format"
1616
"os"
17+
"path/filepath"
1718
"testing"
1819

1920
"github.com/stretchr/testify/assert"
@@ -489,3 +490,68 @@ func TestRawContentTypesGenerateByteSlice(t *testing.T) {
489490
_, err = format.Source([]byte(code))
490491
require.NoError(t, err, "Generated code should compile without syntax errors")
491492
}
493+
494+
func TestExternalFileRefResolution(t *testing.T) {
495+
testdataDir, err := filepath.Abs("testdata")
496+
require.NoError(t, err)
497+
498+
specPath := filepath.Join(testdataDir, "external-ref-api.yaml")
499+
contents, err := os.ReadFile(specPath)
500+
require.NoError(t, err)
501+
502+
t.Run("fails without BasePath", func(t *testing.T) {
503+
cfg := NewDefaultConfiguration()
504+
cfg.BasePath = ""
505+
506+
_, err := CreateDocument(contents, cfg)
507+
require.Error(t, err)
508+
assert.Contains(t, err.Error(), "does not exist in the specification")
509+
})
510+
511+
t.Run("succeeds with BasePath", func(t *testing.T) {
512+
cfg := NewDefaultConfiguration()
513+
cfg.BasePath = testdataDir
514+
515+
doc, err := CreateDocument(contents, cfg)
516+
require.NoError(t, err)
517+
518+
model, errs := doc.BuildV3Model()
519+
require.Empty(t, errs)
520+
521+
getUserResp := model.Model.Paths.PathItems.GetOrZero("/users/{id}").Get.Responses.Codes.GetOrZero("200")
522+
require.NotNil(t, getUserResp)
523+
524+
jsonContent := getUserResp.Content.GetOrZero("application/json")
525+
require.NotNil(t, jsonContent)
526+
527+
schema := jsonContent.Schema.Schema()
528+
require.NotNil(t, schema)
529+
530+
addressProp := schema.Properties.GetOrZero("address")
531+
require.NotNil(t, addressProp)
532+
533+
resolvedSchema := addressProp.Schema()
534+
require.NotNil(t, resolvedSchema)
535+
assert.True(t, resolvedSchema.Properties.Len() > 0, "Address schema should have properties after ref resolution")
536+
537+
streetProp := resolvedSchema.Properties.GetOrZero("street")
538+
assert.NotNil(t, streetProp, "Address should have a 'street' property")
539+
540+
cityProp := resolvedSchema.Properties.GetOrZero("city")
541+
assert.NotNil(t, cityProp, "Address should have a 'city' property")
542+
})
543+
544+
t.Run("end-to-end code generation with external refs", func(t *testing.T) {
545+
cfg := NewDefaultConfiguration()
546+
cfg.BasePath = testdataDir
547+
548+
code, err := Generate(contents, cfg)
549+
require.NoError(t, err)
550+
assert.NotEmpty(t, code)
551+
552+
combined := code.GetCombined()
553+
assert.Contains(t, combined, "Address")
554+
assert.Contains(t, combined, "Street")
555+
assert.Contains(t, combined, "City")
556+
})
557+
}

pkg/codegen/configuration.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ type Configuration struct {
4747

4848
UserTemplates map[string]string `yaml:"user-templates,omitempty"`
4949
UserContext map[string]any `yaml:"user-context,omitempty"`
50+
51+
// BasePath is the directory used to resolve relative $ref file references.
52+
// When set, external file references like './common.yaml#/...' will be resolved
53+
// relative to this directory. Typically set to the directory containing the spec file.
54+
BasePath string `yaml:"base-path,omitempty"`
5055
}
5156

5257
// Merge combines two configurations, with the receiver (o) taking priority.
@@ -237,6 +242,11 @@ func (o Configuration) OverwriteWith(other Configuration) Configuration {
237242
o.UserContext = other.UserContext
238243
}
239244

245+
// Overwrite BasePath
246+
if other.BasePath != "" {
247+
o.BasePath = other.BasePath
248+
}
249+
240250
return o
241251
}
242252

pkg/codegen/configuration_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,26 @@ func TestConfiguration_Merge(t *testing.T) {
330330
assert.Equal(t, "Client", result.Client.Name)
331331
})
332332
}
333+
334+
func TestBasePathOverwriteWith(t *testing.T) {
335+
t.Run("overwrites empty BasePath", func(t *testing.T) {
336+
base := Configuration{}
337+
other := Configuration{BasePath: "/some/path"}
338+
result := base.OverwriteWith(other)
339+
assert.Equal(t, "/some/path", result.BasePath)
340+
})
341+
342+
t.Run("overwrites existing BasePath", func(t *testing.T) {
343+
base := Configuration{BasePath: "/old/path"}
344+
other := Configuration{BasePath: "/new/path"}
345+
result := base.OverwriteWith(other)
346+
assert.Equal(t, "/new/path", result.BasePath)
347+
})
348+
349+
t.Run("does not overwrite when other is empty", func(t *testing.T) {
350+
base := Configuration{BasePath: "/keep/this"}
351+
other := Configuration{}
352+
result := base.OverwriteWith(other)
353+
assert.Equal(t, "/keep/this", result.BasePath)
354+
})
355+
}

pkg/codegen/openapi_provider.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package codegen
1212

1313
import (
1414
"fmt"
15+
"path/filepath"
1516
"strings"
1617
"unicode"
1718

@@ -21,7 +22,7 @@ import (
2122
)
2223

2324
func CreateDocument(docContents []byte, cfg Configuration) (libopenapi.Document, error) {
24-
doc, err := LoadDocumentFromContents(docContents)
25+
doc, err := LoadDocumentFromContents(docContents, cfg.BasePath)
2526
if err != nil {
2627
return nil, err
2728
}
@@ -56,10 +57,25 @@ func CreateDocument(docContents []byte, cfg Configuration) (libopenapi.Document,
5657
return doc, nil
5758
}
5859

59-
func LoadDocumentFromContents(contents []byte) (libopenapi.Document, error) {
60+
// LoadDocumentFromContents creates a libopenapi Document from raw bytes.
61+
// An optional basePath can be provided to resolve relative $ref file references.
62+
// If provided, basePath should be an absolute path to the directory containing the spec file.
63+
func LoadDocumentFromContents(contents []byte, basePath ...string) (libopenapi.Document, error) {
6064
docConfig := &datamodel.DocumentConfiguration{
6165
SkipCircularReferenceCheck: true,
6266
}
67+
if len(basePath) > 0 && basePath[0] != "" {
68+
bp := basePath[0]
69+
if !filepath.IsAbs(bp) {
70+
var err error
71+
bp, err = filepath.Abs(bp)
72+
if err != nil {
73+
return nil, fmt.Errorf("error resolving base path %q: %w", basePath[0], err)
74+
}
75+
}
76+
docConfig.BasePath = bp
77+
docConfig.AllowFileReferences = true
78+
}
6379
doc, err := libopenapi.NewDocumentWithConfiguration(contents, docConfig)
6480
if err != nil {
6581
return fixDocument(contents, err, docConfig)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: API
4+
version: 1.0.0
5+
paths:
6+
/users/{id}:
7+
get:
8+
operationId: GetUser
9+
parameters:
10+
- name: id
11+
in: path
12+
required: true
13+
schema:
14+
type: string
15+
responses:
16+
"200":
17+
description: User
18+
content:
19+
application/json:
20+
schema:
21+
type: object
22+
properties:
23+
name:
24+
type: string
25+
address:
26+
$ref: "./external-ref-common.yaml#/components/schemas/Address"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Common Schemas
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
Address:
9+
type: object
10+
properties:
11+
street:
12+
type: string
13+
city:
14+
type: string

0 commit comments

Comments
 (0)