Skip to content

Commit 192fe7c

Browse files
feat: add OpenAPI document localization functionality and CLI command (#48)
1 parent 4b5b9fa commit 192fe7c

37 files changed

+2387
-0
lines changed

openapi/bundle.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,11 @@ func handleReference(ref references.Reference, parentLocation, targetLocation st
803803
return "", nil // Invalid reference, skip
804804
}
805805

806+
// For URLs, don't do any path manipulation - return as-is
807+
if classification.Type == utils.ReferenceTypeURL {
808+
return r, classification
809+
}
810+
806811
if parentLocation != "" {
807812
relPath, err := filepath.Rel(filepath.Dir(parentLocation), targetLocation)
808813
if err == nil {

openapi/cmd/README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ OpenAPI specifications define REST APIs in a standard format. These commands hel
1717
- [`join`](#join)
1818
- [`optimize`](#optimize)
1919
- [`bootstrap`](#bootstrap)
20+
- [`localize`](#localize)
2021
- [Common Options](#common-options)
2122
- [Output Formats](#output-formats)
2223
- [Examples](#examples)
@@ -456,6 +457,111 @@ What bootstrap creates:
456457

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

460+
### `localize`
461+
462+
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.
463+
464+
```bash
465+
# Localize to a target directory with path-based naming (default)
466+
openapi spec localize ./spec.yaml ./localized/
467+
468+
# Localize with counter-based naming for conflicts
469+
openapi spec localize --naming counter ./spec.yaml ./localized/
470+
471+
# Localize with explicit path-based naming
472+
openapi spec localize --naming path ./spec.yaml ./localized/
473+
```
474+
475+
**Naming Strategies:**
476+
477+
- `path` (default): Uses file path-based naming like `schemas-address.yaml` for conflicts
478+
- `counter`: Uses counter-based suffixes like `address_1.yaml` for conflicts
479+
480+
What localization does:
481+
482+
- Copies all external reference files to the target directory
483+
- Creates a new version of the main document with updated references
484+
- Leaves the original document and files completely untouched
485+
- Creates a portable, self-contained document bundle
486+
- Handles circular references and naming conflicts intelligently
487+
- Supports both file-based and URL-based external references
488+
489+
**Before localization:**
490+
491+
```yaml
492+
# main.yaml
493+
paths:
494+
/users:
495+
get:
496+
responses:
497+
'200':
498+
content:
499+
application/json:
500+
schema:
501+
$ref: "./components.yaml#/components/schemas/User"
502+
503+
# components.yaml
504+
components:
505+
schemas:
506+
User:
507+
properties:
508+
address:
509+
$ref: "./schemas/address.yaml#/Address"
510+
511+
# schemas/address.yaml
512+
Address:
513+
type: object
514+
properties:
515+
street: {type: string}
516+
```
517+
518+
**After localization (in target directory):**
519+
520+
```yaml
521+
# localized/main.yaml
522+
paths:
523+
/users:
524+
get:
525+
responses:
526+
'200':
527+
content:
528+
application/json:
529+
schema:
530+
$ref: "components.yaml#/components/schemas/User"
531+
532+
# localized/components.yaml
533+
components:
534+
schemas:
535+
User:
536+
properties:
537+
address:
538+
$ref: "schemas-address.yaml#/Address"
539+
540+
# localized/schemas-address.yaml
541+
Address:
542+
type: object
543+
properties:
544+
street: {type: string}
545+
```
546+
547+
**Benefits of localization:**
548+
549+
- Creates portable document bundles for easy distribution
550+
- Simplifies deployment by packaging all dependencies together
551+
- Enables offline development without external file dependencies
552+
- Improves version control by keeping all related files together
553+
- Ensures all dependencies are available in CI/CD pipelines
554+
- Facilitates documentation generation with complete file sets
555+
556+
**Use Localize when:**
557+
558+
- You need to package an API specification for distribution
559+
- You want to create a self-contained bundle for deployment
560+
- You're preparing specifications for offline use or air-gapped environments
561+
- You need to ensure all dependencies are available in build pipelines
562+
- You want to simplify file management for complex multi-file specifications
563+
- You're creating documentation packages that include all referenced files
564+
459565
## Common Options
460566

461567
All commands support these common options:

openapi/cmd/localize.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/speakeasy-api/openapi/openapi"
11+
"github.com/speakeasy-api/openapi/system"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var localizeCmd = &cobra.Command{
16+
Use: "localize <file> <target-directory>",
17+
Short: "Localize an OpenAPI specification by copying external references to a target directory",
18+
Long: `Localize an OpenAPI specification by copying all external reference files to a target directory
19+
and creating a new version of the document with references rewritten to point to the localized files.
20+
21+
The original document is left untouched - all files (including the main document) are copied
22+
to the target directory with updated references, creating a portable document bundle.
23+
24+
Why use Localize?
25+
26+
- Create portable document bundles: Copy all external dependencies into a single directory
27+
- Simplify deployment: Package all API definition files together for easy distribution
28+
- Offline development: Work with API definitions without external file dependencies
29+
- Version control: Keep all related files in the same repository structure
30+
- CI/CD pipelines: Ensure all dependencies are available in build environments
31+
- Documentation generation: Bundle all files needed for complete API documentation
32+
33+
What you'll get:
34+
35+
Before localization:
36+
main.yaml:
37+
paths:
38+
/users:
39+
get:
40+
responses:
41+
'200':
42+
content:
43+
application/json:
44+
schema:
45+
$ref: "./components.yaml#/components/schemas/User"
46+
47+
components.yaml:
48+
components:
49+
schemas:
50+
User:
51+
properties:
52+
address:
53+
$ref: "./schemas/address.yaml#/Address"
54+
55+
After localization (files copied to target directory):
56+
target/main.yaml:
57+
paths:
58+
/users:
59+
get:
60+
responses:
61+
'200':
62+
content:
63+
application/json:
64+
schema:
65+
$ref: "components.yaml#/components/schemas/User"
66+
67+
target/components.yaml:
68+
components:
69+
schemas:
70+
User:
71+
properties:
72+
address:
73+
$ref: "schemas-address.yaml#/Address"
74+
75+
target/schemas-address.yaml:
76+
Address:
77+
type: object
78+
properties:
79+
street: {type: string}`,
80+
Args: cobra.ExactArgs(2),
81+
Run: runLocalize,
82+
}
83+
84+
var (
85+
localizeNamingStrategy string
86+
)
87+
88+
func init() {
89+
localizeCmd.Flags().StringVar(&localizeNamingStrategy, "naming", "path", "Naming strategy for external files: 'path' (path-based) or 'counter' (counter-based)")
90+
}
91+
92+
func runLocalize(cmd *cobra.Command, args []string) {
93+
ctx := cmd.Context()
94+
inputFile := args[0]
95+
targetDirectory := args[1]
96+
97+
if err := localizeOpenAPI(ctx, inputFile, targetDirectory); err != nil {
98+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
99+
os.Exit(1)
100+
}
101+
}
102+
103+
func localizeOpenAPI(ctx context.Context, inputFile, targetDirectory string) error {
104+
cleanInputFile := filepath.Clean(inputFile)
105+
cleanTargetDir := filepath.Clean(targetDirectory)
106+
107+
fmt.Printf("Localizing OpenAPI document: %s\n", cleanInputFile)
108+
fmt.Printf("Target directory: %s\n", cleanTargetDir)
109+
110+
// Read the input file
111+
f, err := os.Open(cleanInputFile)
112+
if err != nil {
113+
return fmt.Errorf("failed to open input file: %w", err)
114+
}
115+
defer f.Close()
116+
117+
// Parse the OpenAPI document
118+
doc, validationErrors, err := openapi.Unmarshal(ctx, f)
119+
if err != nil {
120+
return fmt.Errorf("failed to unmarshal OpenAPI document: %w", err)
121+
}
122+
if doc == nil {
123+
return errors.New("failed to parse OpenAPI document: document is nil")
124+
}
125+
126+
// Report validation errors if any
127+
if len(validationErrors) > 0 {
128+
fmt.Printf("⚠️ Found %d validation errors in original document:\n", len(validationErrors))
129+
for i, validationErr := range validationErrors {
130+
fmt.Printf(" %d. %s\n", i+1, validationErr.Error())
131+
}
132+
fmt.Println()
133+
}
134+
135+
// Determine naming strategy
136+
var namingStrategy openapi.LocalizeNamingStrategy
137+
switch localizeNamingStrategy {
138+
case "path":
139+
namingStrategy = openapi.LocalizeNamingPathBased
140+
case "counter":
141+
namingStrategy = openapi.LocalizeNamingCounter
142+
default:
143+
return fmt.Errorf("invalid naming strategy: %s (must be 'path' or 'counter')", localizeNamingStrategy)
144+
}
145+
146+
// Create target directory if it doesn't exist
147+
if err := os.MkdirAll(cleanTargetDir, 0750); err != nil {
148+
return fmt.Errorf("failed to create target directory: %w", err)
149+
}
150+
151+
// Set up localize options
152+
opts := openapi.LocalizeOptions{
153+
ResolveOptions: openapi.ResolveOptions{
154+
TargetLocation: cleanInputFile,
155+
VirtualFS: &system.FileSystem{},
156+
},
157+
TargetDirectory: cleanTargetDir,
158+
VirtualFS: &system.FileSystem{},
159+
NamingStrategy: namingStrategy,
160+
}
161+
162+
// Perform localization (this modifies the doc in memory but doesn't affect the original file)
163+
if err := openapi.Localize(ctx, doc, opts); err != nil {
164+
return fmt.Errorf("failed to localize document: %w", err)
165+
}
166+
167+
// Write the updated document to the target directory
168+
outputFile := filepath.Join(cleanTargetDir, filepath.Base(cleanInputFile))
169+
// Clean the output file path to prevent directory traversal
170+
cleanOutputFile := filepath.Clean(outputFile)
171+
outFile, err := os.Create(cleanOutputFile)
172+
if err != nil {
173+
return fmt.Errorf("failed to create output file: %w", err)
174+
}
175+
defer outFile.Close()
176+
177+
if err := openapi.Marshal(ctx, doc, outFile); err != nil {
178+
return fmt.Errorf("failed to write localized document: %w", err)
179+
}
180+
181+
fmt.Printf("📄 Localized document written to: %s\n", cleanOutputFile)
182+
fmt.Printf("✅ Localization completed successfully - original document unchanged\n")
183+
184+
return nil
185+
}
186+
187+
// GetLocalizeCommand returns the localize command for external use
188+
func GetLocalizeCommand() *cobra.Command {
189+
return localizeCmd
190+
}

openapi/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ func Apply(rootCmd *cobra.Command) {
1212
rootCmd.AddCommand(joinCmd)
1313
rootCmd.AddCommand(bootstrapCmd)
1414
rootCmd.AddCommand(optimizeCmd)
15+
rootCmd.AddCommand(localizeCmd)
1516
}

0 commit comments

Comments
 (0)