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
5 changes: 5 additions & 0 deletions openapi/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,11 @@ func handleReference(ref references.Reference, parentLocation, targetLocation st
return "", nil // Invalid reference, skip
}

// For URLs, don't do any path manipulation - return as-is
if classification.Type == utils.ReferenceTypeURL {
return r, classification
}

if parentLocation != "" {
relPath, err := filepath.Rel(filepath.Dir(parentLocation), targetLocation)
if err == nil {
Expand Down
106 changes: 106 additions & 0 deletions openapi/cmd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ OpenAPI specifications define REST APIs in a standard format. These commands hel
- [`join`](#join)
- [`optimize`](#optimize)
- [`bootstrap`](#bootstrap)
- [`localize`](#localize)
- [Common Options](#common-options)
- [Output Formats](#output-formats)
- [Examples](#examples)
Expand Down Expand Up @@ -456,6 +457,111 @@ What bootstrap creates:

The generated document serves as both a template for new APIs and a learning resource for OpenAPI best practices.

### `localize`

Localize an OpenAPI specification by copying all external reference files to a target directory and creating a new version of the document with references rewritten to point to the localized files.

```bash
# Localize to a target directory with path-based naming (default)
openapi spec localize ./spec.yaml ./localized/

# Localize with counter-based naming for conflicts
openapi spec localize --naming counter ./spec.yaml ./localized/

# Localize with explicit path-based naming
openapi spec localize --naming path ./spec.yaml ./localized/
```

**Naming Strategies:**

- `path` (default): Uses file path-based naming like `schemas-address.yaml` for conflicts
- `counter`: Uses counter-based suffixes like `address_1.yaml` for conflicts

What localization does:

- Copies all external reference files to the target directory
- Creates a new version of the main document with updated references
- Leaves the original document and files completely untouched
- Creates a portable, self-contained document bundle
- Handles circular references and naming conflicts intelligently
- Supports both file-based and URL-based external references

**Before localization:**

```yaml
# main.yaml
paths:
/users:
get:
responses:
'200':
content:
application/json:
schema:
$ref: "./components.yaml#/components/schemas/User"

# components.yaml
components:
schemas:
User:
properties:
address:
$ref: "./schemas/address.yaml#/Address"

# schemas/address.yaml
Address:
type: object
properties:
street: {type: string}
```

**After localization (in target directory):**

```yaml
# localized/main.yaml
paths:
/users:
get:
responses:
'200':
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/User"

# localized/components.yaml
components:
schemas:
User:
properties:
address:
$ref: "schemas-address.yaml#/Address"

# localized/schemas-address.yaml
Address:
type: object
properties:
street: {type: string}
```

**Benefits of localization:**

- Creates portable document bundles for easy distribution
- Simplifies deployment by packaging all dependencies together
- Enables offline development without external file dependencies
- Improves version control by keeping all related files together
- Ensures all dependencies are available in CI/CD pipelines
- Facilitates documentation generation with complete file sets

**Use Localize when:**

- You need to package an API specification for distribution
- You want to create a self-contained bundle for deployment
- You're preparing specifications for offline use or air-gapped environments
- You need to ensure all dependencies are available in build pipelines
- You want to simplify file management for complex multi-file specifications
- You're creating documentation packages that include all referenced files

## Common Options

All commands support these common options:
Expand Down
190 changes: 190 additions & 0 deletions openapi/cmd/localize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"

"github.com/speakeasy-api/openapi/openapi"
"github.com/speakeasy-api/openapi/system"
"github.com/spf13/cobra"
)

var localizeCmd = &cobra.Command{
Use: "localize <file> <target-directory>",
Short: "Localize an OpenAPI specification by copying external references to a target directory",
Long: `Localize an OpenAPI specification by copying all external reference files to a target directory
and creating a new version of the document with references rewritten to point to the localized files.

The original document is left untouched - all files (including the main document) are copied
to the target directory with updated references, creating a portable document bundle.

Why use Localize?

- Create portable document bundles: Copy all external dependencies into a single directory
- Simplify deployment: Package all API definition files together for easy distribution
- Offline development: Work with API definitions without external file dependencies
- Version control: Keep all related files in the same repository structure
- CI/CD pipelines: Ensure all dependencies are available in build environments
- Documentation generation: Bundle all files needed for complete API documentation

What you'll get:

Before localization:
main.yaml:
paths:
/users:
get:
responses:
'200':
content:
application/json:
schema:
$ref: "./components.yaml#/components/schemas/User"

components.yaml:
components:
schemas:
User:
properties:
address:
$ref: "./schemas/address.yaml#/Address"

After localization (files copied to target directory):
target/main.yaml:
paths:
/users:
get:
responses:
'200':
content:
application/json:
schema:
$ref: "components.yaml#/components/schemas/User"

target/components.yaml:
components:
schemas:
User:
properties:
address:
$ref: "schemas-address.yaml#/Address"

target/schemas-address.yaml:
Address:
type: object
properties:
street: {type: string}`,
Args: cobra.ExactArgs(2),
Run: runLocalize,
}

var (
localizeNamingStrategy string
)

func init() {
localizeCmd.Flags().StringVar(&localizeNamingStrategy, "naming", "path", "Naming strategy for external files: 'path' (path-based) or 'counter' (counter-based)")
}

func runLocalize(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
inputFile := args[0]
targetDirectory := args[1]

if err := localizeOpenAPI(ctx, inputFile, targetDirectory); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

func localizeOpenAPI(ctx context.Context, inputFile, targetDirectory string) error {
cleanInputFile := filepath.Clean(inputFile)
cleanTargetDir := filepath.Clean(targetDirectory)

fmt.Printf("Localizing OpenAPI document: %s\n", cleanInputFile)
fmt.Printf("Target directory: %s\n", cleanTargetDir)

// Read the input file
f, err := os.Open(cleanInputFile)
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer f.Close()

// Parse the OpenAPI document
doc, validationErrors, err := openapi.Unmarshal(ctx, f)
if err != nil {
return fmt.Errorf("failed to unmarshal OpenAPI document: %w", err)
}
if doc == nil {
return errors.New("failed to parse OpenAPI document: document is nil")
}

// Report validation errors if any
if len(validationErrors) > 0 {
fmt.Printf("⚠️ Found %d validation errors in original document:\n", len(validationErrors))
for i, validationErr := range validationErrors {
fmt.Printf(" %d. %s\n", i+1, validationErr.Error())
}
fmt.Println()
}

// Determine naming strategy
var namingStrategy openapi.LocalizeNamingStrategy
switch localizeNamingStrategy {
case "path":
namingStrategy = openapi.LocalizeNamingPathBased
case "counter":
namingStrategy = openapi.LocalizeNamingCounter
default:
return fmt.Errorf("invalid naming strategy: %s (must be 'path' or 'counter')", localizeNamingStrategy)
}

// Create target directory if it doesn't exist
if err := os.MkdirAll(cleanTargetDir, 0750); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}

// Set up localize options
opts := openapi.LocalizeOptions{
ResolveOptions: openapi.ResolveOptions{
TargetLocation: cleanInputFile,
VirtualFS: &system.FileSystem{},
},
TargetDirectory: cleanTargetDir,
VirtualFS: &system.FileSystem{},
NamingStrategy: namingStrategy,
}

// Perform localization (this modifies the doc in memory but doesn't affect the original file)
if err := openapi.Localize(ctx, doc, opts); err != nil {
return fmt.Errorf("failed to localize document: %w", err)
}

// Write the updated document to the target directory
outputFile := filepath.Join(cleanTargetDir, filepath.Base(cleanInputFile))
// Clean the output file path to prevent directory traversal
cleanOutputFile := filepath.Clean(outputFile)
outFile, err := os.Create(cleanOutputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()

if err := openapi.Marshal(ctx, doc, outFile); err != nil {
return fmt.Errorf("failed to write localized document: %w", err)
}

fmt.Printf("📄 Localized document written to: %s\n", cleanOutputFile)
fmt.Printf("✅ Localization completed successfully - original document unchanged\n")

return nil
}

// GetLocalizeCommand returns the localize command for external use
func GetLocalizeCommand() *cobra.Command {
return localizeCmd
}
1 change: 1 addition & 0 deletions openapi/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ func Apply(rootCmd *cobra.Command) {
rootCmd.AddCommand(joinCmd)
rootCmd.AddCommand(bootstrapCmd)
rootCmd.AddCommand(optimizeCmd)
rootCmd.AddCommand(localizeCmd)
}
Loading
Loading