Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

### Programmatic Access
- **Runtime package** - Public API for working with generated types
Expand Down
9 changes: 9 additions & 0 deletions cmd/oapi-codegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ func main() {
}
}

// Set BasePath from spec file path for resolving external $ref references
if cfg.BasePath == "" && !strings.HasPrefix(specPath, "http://") && !strings.HasPrefix(specPath, "https://") {
absPath, err := filepath.Abs(specPath)
if err != nil {
errExit("Error resolving spec file path: %v", err)
}
cfg.BasePath = filepath.Dir(absPath)
}

cfg = cfg.WithDefaults()

// If no config file was provided and input is a URL, output to stdout
Expand Down
4 changes: 4 additions & 0 deletions configuration-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"type": "object",
"description": "UserContext is the map of user-provided context values to be used in templates user overrides.",
"additionalProperties": true
},
"base-path": {
"type": "string",
"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."
}
},
"required": [],
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ overlay:

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

### External File References

#### `base-path`
**Type:** `string` | **Default:** auto-detected from spec file path

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.

```yaml
base-path: ./specs
```

This enables splitting large specs across multiple files:

```yaml
# specs/api.yaml
components:
schemas:
User:
type: object
properties:
address:
$ref: './common.yaml#/components/schemas/Address'
```

### Output Settings

#### `output.use-single-file`
Expand Down
66 changes: 66 additions & 0 deletions pkg/codegen/codegen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"embed"
"go/format"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -489,3 +490,68 @@ func TestRawContentTypesGenerateByteSlice(t *testing.T) {
_, err = format.Source([]byte(code))
require.NoError(t, err, "Generated code should compile without syntax errors")
}

func TestExternalFileRefResolution(t *testing.T) {
testdataDir, err := filepath.Abs("testdata")
require.NoError(t, err)

specPath := filepath.Join(testdataDir, "external-ref-api.yaml")
contents, err := os.ReadFile(specPath)
require.NoError(t, err)

t.Run("fails without BasePath", func(t *testing.T) {
cfg := NewDefaultConfiguration()
cfg.BasePath = ""

_, err := CreateDocument(contents, cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not exist in the specification")
})

t.Run("succeeds with BasePath", func(t *testing.T) {
cfg := NewDefaultConfiguration()
cfg.BasePath = testdataDir

doc, err := CreateDocument(contents, cfg)
require.NoError(t, err)

model, errs := doc.BuildV3Model()
require.Empty(t, errs)

getUserResp := model.Model.Paths.PathItems.GetOrZero("/users/{id}").Get.Responses.Codes.GetOrZero("200")
require.NotNil(t, getUserResp)

jsonContent := getUserResp.Content.GetOrZero("application/json")
require.NotNil(t, jsonContent)

schema := jsonContent.Schema.Schema()
require.NotNil(t, schema)

addressProp := schema.Properties.GetOrZero("address")
require.NotNil(t, addressProp)

resolvedSchema := addressProp.Schema()
require.NotNil(t, resolvedSchema)
assert.True(t, resolvedSchema.Properties.Len() > 0, "Address schema should have properties after ref resolution")

streetProp := resolvedSchema.Properties.GetOrZero("street")
assert.NotNil(t, streetProp, "Address should have a 'street' property")

cityProp := resolvedSchema.Properties.GetOrZero("city")
assert.NotNil(t, cityProp, "Address should have a 'city' property")
})

t.Run("end-to-end code generation with external refs", func(t *testing.T) {
cfg := NewDefaultConfiguration()
cfg.BasePath = testdataDir

code, err := Generate(contents, cfg)
require.NoError(t, err)
assert.NotEmpty(t, code)

combined := code.GetCombined()
assert.Contains(t, combined, "Address")
assert.Contains(t, combined, "Street")
assert.Contains(t, combined, "City")
})
}
10 changes: 10 additions & 0 deletions pkg/codegen/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type Configuration struct {

UserTemplates map[string]string `yaml:"user-templates,omitempty"`
UserContext map[string]any `yaml:"user-context,omitempty"`

// 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.
BasePath string `yaml:"base-path,omitempty"`
}

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

// Overwrite BasePath
if other.BasePath != "" {
o.BasePath = other.BasePath
}

return o
}

Expand Down
23 changes: 23 additions & 0 deletions pkg/codegen/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,26 @@ func TestConfiguration_Merge(t *testing.T) {
assert.Equal(t, "Client", result.Client.Name)
})
}

func TestBasePathOverwriteWith(t *testing.T) {
t.Run("overwrites empty BasePath", func(t *testing.T) {
base := Configuration{}
other := Configuration{BasePath: "/some/path"}
result := base.OverwriteWith(other)
assert.Equal(t, "/some/path", result.BasePath)
})

t.Run("overwrites existing BasePath", func(t *testing.T) {
base := Configuration{BasePath: "/old/path"}
other := Configuration{BasePath: "/new/path"}
result := base.OverwriteWith(other)
assert.Equal(t, "/new/path", result.BasePath)
})

t.Run("does not overwrite when other is empty", func(t *testing.T) {
base := Configuration{BasePath: "/keep/this"}
other := Configuration{}
result := base.OverwriteWith(other)
assert.Equal(t, "/keep/this", result.BasePath)
})
}
20 changes: 18 additions & 2 deletions pkg/codegen/openapi_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package codegen

import (
"fmt"
"path/filepath"
"strings"
"unicode"

Expand All @@ -21,7 +22,7 @@ import (
)

func CreateDocument(docContents []byte, cfg Configuration) (libopenapi.Document, error) {
doc, err := LoadDocumentFromContents(docContents)
doc, err := LoadDocumentFromContents(docContents, cfg.BasePath)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -56,10 +57,25 @@ func CreateDocument(docContents []byte, cfg Configuration) (libopenapi.Document,
return doc, nil
}

func LoadDocumentFromContents(contents []byte) (libopenapi.Document, error) {
// LoadDocumentFromContents creates a libopenapi Document from raw bytes.
// An optional basePath can be provided to resolve relative $ref file references.
// If provided, basePath should be an absolute path to the directory containing the spec file.
func LoadDocumentFromContents(contents []byte, basePath ...string) (libopenapi.Document, error) {
docConfig := &datamodel.DocumentConfiguration{
SkipCircularReferenceCheck: true,
}
if len(basePath) > 0 && basePath[0] != "" {
bp := basePath[0]
if !filepath.IsAbs(bp) {
var err error
bp, err = filepath.Abs(bp)
if err != nil {
return nil, fmt.Errorf("error resolving base path %q: %w", basePath[0], err)
}
}
docConfig.BasePath = bp
docConfig.AllowFileReferences = true
}
doc, err := libopenapi.NewDocumentWithConfiguration(contents, docConfig)
if err != nil {
return fixDocument(contents, err, docConfig)
Expand Down
26 changes: 26 additions & 0 deletions pkg/codegen/testdata/external-ref-api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
openapi: "3.0.0"
info:
title: API
version: 1.0.0
paths:
/users/{id}:
get:
operationId: GetUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: User
content:
application/json:
schema:
type: object
properties:
name:
type: string
address:
$ref: "./external-ref-common.yaml#/components/schemas/Address"
14 changes: 14 additions & 0 deletions pkg/codegen/testdata/external-ref-common.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
openapi: "3.0.0"
info:
title: Common Schemas
version: 1.0.0
paths: {}
components:
schemas:
Address:
type: object
properties:
street:
type: string
city:
type: string
Loading