From f028063b96ec1d849f6a749006414304614761a0 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Mon, 15 Sep 2025 17:22:27 +1000 Subject: [PATCH] feat: add OpenAPI document localization functionality and CLI command --- openapi/bundle.go | 5 + openapi/cmd/README.md | 106 ++ openapi/cmd/localize.go | 190 ++++ openapi/cmd/root.go | 1 + openapi/localize.go | 964 ++++++++++++++++++ openapi/localize_test.go | 268 +++++ .../localize/input/api/components.yaml | 18 + .../testdata/localize/input/components.yaml | 39 + .../localize/input/schemas/address.yaml | 25 + .../localize/input/schemas/category.yaml | 15 + .../testdata/localize/input/schemas/geo.yaml | 26 + .../localize/input/shared/address.yaml | 25 + openapi/testdata/localize/input/spec.yaml | 78 ++ .../localize/output_counter/address.yaml | 25 + .../localize/output_counter/address_1.yaml | 25 + .../localize/output_counter/category.yaml | 15 + .../localize/output_counter/components.yaml | 39 + .../localize/output_counter/components_1.yaml | 18 + .../testdata/localize/output_counter/geo.yaml | 26 + .../localize/output_counter/metadata.yaml | 19 + .../localize/output_counter/openapi.yaml | 78 ++ .../output_counter/user-preferences.yaml | 19 + .../localize/output_counter/user-profile.yaml | 13 + .../localize/output_pathbased/address.yaml | 25 + .../output_pathbased/api-components.yaml | 18 + .../localize/output_pathbased/category.yaml | 15 + .../localize/output_pathbased/components.yaml | 39 + .../localize/output_pathbased/geo.yaml | 26 + .../localize/output_pathbased/metadata.yaml | 19 + .../localize/output_pathbased/openapi.yaml | 78 ++ .../output_pathbased/shared-address.yaml | 25 + .../output_pathbased/user-preferences.yaml | 19 + .../output_pathbased/user-profile.yaml | 13 + .../localize/remote/schemas/metadata.yaml | 19 + .../remote/schemas/user-preferences.yaml | 19 + .../localize/remote/schemas/user-profile.yaml | 13 + system/filesystem.go | 22 + 37 files changed, 2387 insertions(+) create mode 100644 openapi/cmd/localize.go create mode 100644 openapi/localize.go create mode 100644 openapi/localize_test.go create mode 100644 openapi/testdata/localize/input/api/components.yaml create mode 100644 openapi/testdata/localize/input/components.yaml create mode 100644 openapi/testdata/localize/input/schemas/address.yaml create mode 100644 openapi/testdata/localize/input/schemas/category.yaml create mode 100644 openapi/testdata/localize/input/schemas/geo.yaml create mode 100644 openapi/testdata/localize/input/shared/address.yaml create mode 100644 openapi/testdata/localize/input/spec.yaml create mode 100644 openapi/testdata/localize/output_counter/address.yaml create mode 100644 openapi/testdata/localize/output_counter/address_1.yaml create mode 100644 openapi/testdata/localize/output_counter/category.yaml create mode 100644 openapi/testdata/localize/output_counter/components.yaml create mode 100644 openapi/testdata/localize/output_counter/components_1.yaml create mode 100644 openapi/testdata/localize/output_counter/geo.yaml create mode 100644 openapi/testdata/localize/output_counter/metadata.yaml create mode 100644 openapi/testdata/localize/output_counter/openapi.yaml create mode 100644 openapi/testdata/localize/output_counter/user-preferences.yaml create mode 100644 openapi/testdata/localize/output_counter/user-profile.yaml create mode 100644 openapi/testdata/localize/output_pathbased/address.yaml create mode 100644 openapi/testdata/localize/output_pathbased/api-components.yaml create mode 100644 openapi/testdata/localize/output_pathbased/category.yaml create mode 100644 openapi/testdata/localize/output_pathbased/components.yaml create mode 100644 openapi/testdata/localize/output_pathbased/geo.yaml create mode 100644 openapi/testdata/localize/output_pathbased/metadata.yaml create mode 100644 openapi/testdata/localize/output_pathbased/openapi.yaml create mode 100644 openapi/testdata/localize/output_pathbased/shared-address.yaml create mode 100644 openapi/testdata/localize/output_pathbased/user-preferences.yaml create mode 100644 openapi/testdata/localize/output_pathbased/user-profile.yaml create mode 100644 openapi/testdata/localize/remote/schemas/metadata.yaml create mode 100644 openapi/testdata/localize/remote/schemas/user-preferences.yaml create mode 100644 openapi/testdata/localize/remote/schemas/user-profile.yaml diff --git a/openapi/bundle.go b/openapi/bundle.go index f43f8ce..058b34c 100644 --- a/openapi/bundle.go +++ b/openapi/bundle.go @@ -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 { diff --git a/openapi/cmd/README.md b/openapi/cmd/README.md index 9bff7aa..31d98a8 100644 --- a/openapi/cmd/README.md +++ b/openapi/cmd/README.md @@ -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) @@ -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: diff --git a/openapi/cmd/localize.go b/openapi/cmd/localize.go new file mode 100644 index 0000000..580d310 --- /dev/null +++ b/openapi/cmd/localize.go @@ -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 ", + 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 +} diff --git a/openapi/cmd/root.go b/openapi/cmd/root.go index fe441ab..ee20044 100644 --- a/openapi/cmd/root.go +++ b/openapi/cmd/root.go @@ -12,4 +12,5 @@ func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(joinCmd) rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(optimizeCmd) + rootCmd.AddCommand(localizeCmd) } diff --git a/openapi/localize.go b/openapi/localize.go new file mode 100644 index 0000000..ed372b0 --- /dev/null +++ b/openapi/localize.go @@ -0,0 +1,964 @@ +package openapi + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/speakeasy-api/openapi/internal/interfaces" + "github.com/speakeasy-api/openapi/internal/utils" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/speakeasy-api/openapi/system" + "gopkg.in/yaml.v3" +) + +// LocalizeNamingStrategy defines how external reference files should be named when localized. +type LocalizeNamingStrategy int + +const ( + // LocalizeNamingPathBased uses path-based naming like "schemas-address.yaml" for conflicts + LocalizeNamingPathBased LocalizeNamingStrategy = iota + // LocalizeNamingCounter uses counter-based suffixes like "address_1.yaml" for conflicts + LocalizeNamingCounter +) + +// LocalizeOptions represents the options available when localizing an OpenAPI document. +type LocalizeOptions struct { + // ResolveOptions are the options to use when resolving references during localization. + ResolveOptions ResolveOptions + // TargetDirectory is the directory where localized files will be written. + TargetDirectory string + // VirtualFS is the file system interface used for reading and writing files. + VirtualFS system.WritableVirtualFS + // NamingStrategy determines how external reference files are named when localized. + NamingStrategy LocalizeNamingStrategy +} + +// Localize transforms an OpenAPI document by copying all external reference files to a target directory +// and rewriting the references in the document to point to the localized files. +// This operation modifies the document in place. +// +// 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} +// +// Parameters: +// - ctx: Context for cancellation and timeout control +// - doc: The OpenAPI document to localize (modified in place) +// - opts: Configuration options for localization +// +// Returns: +// - error: Any error that occurred during localization +func Localize(ctx context.Context, doc *OpenAPI, opts LocalizeOptions) error { + if doc == nil { + return nil + } + + if opts.VirtualFS == nil { + opts.VirtualFS = &system.FileSystem{} + } + + if opts.TargetDirectory == "" { + return errors.New("target directory is required") + } + + // Storage for tracking external references and their localized names + localizeStorage := &localizeStorage{ + externalRefs: sequencedmap.New[string, string](), // original ref -> localized filename + usedFilenames: make(map[string]bool), // track used filenames to avoid conflicts + resolvedContent: make(map[string][]byte), // original ref -> file content + } + + // Phase 1: Discover and collect all external references + if err := discoverExternalReferences(ctx, doc, opts.ResolveOptions, localizeStorage); err != nil { + return fmt.Errorf("failed to discover external references: %w", err) + } + + // Phase 2: Generate conflict-free filenames for all external references + generateLocalizedFilenames(localizeStorage, opts.NamingStrategy) + + // Phase 3: Copy external files to target directory + if err := copyExternalFiles(ctx, localizeStorage, opts); err != nil { + return fmt.Errorf("failed to copy external files: %w", err) + } + + // Phase 4: Rewrite references in the document + if err := rewriteReferencesToLocalized(ctx, doc, localizeStorage); err != nil { + return fmt.Errorf("failed to rewrite references: %w", err) + } + + return nil +} + +type localizeStorage struct { + externalRefs *sequencedmap.Map[string, string] // original ref -> localized filename + usedFilenames map[string]bool // track used filenames to avoid conflicts + resolvedContent map[string][]byte // original ref -> file content +} + +// discoverExternalReferences walks through the document and collects all external references +func discoverExternalReferences(ctx context.Context, doc *OpenAPI, opts ResolveOptions, storage *localizeStorage) error { + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + return discoverSchemaReference(ctx, schema, opts, storage) + }, + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedExample: func(ref *ReferencedExample) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedLink: func(ref *ReferencedLink) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return discoverGenericReference(ctx, ref, opts, storage) + }, + }) + if err != nil { + return fmt.Errorf("failed to discover references at %s: %w", item.Location.ToJSONPointer().String(), err) + } + } + + return nil +} + +// discoverSchemaReference handles discovery of JSON schema references +func discoverSchemaReference(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], opts ResolveOptions, storage *localizeStorage) error { + if !schema.IsReference() { + return nil + } + + ref, classification := handleReference(schema.GetRef(), "", opts.TargetLocation) + if classification == nil || classification.IsFragment { + return nil // Skip internal references + } + + // Get the URI part (file path) from the reference + refObj := references.Reference(ref) + filePath := refObj.GetURI() + + // For URLs, use the full reference as the key, for file paths normalize + var normalizedFilePath string + if classification.Type == utils.ReferenceTypeURL { + normalizedFilePath = ref // Use the full URL as the key + } else { + normalizedFilePath = normalizeFilePath(filePath) + } + + // Always resolve the schema to enable recursive discovery + resolveOpts := oas3.ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: opts.TargetDocument, + TargetLocation: opts.TargetLocation, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + } + + if _, err := schema.Resolve(ctx, resolveOpts); err != nil { + return fmt.Errorf("failed to resolve external schema reference %s: %w", ref, err) + } + + // Only store the file content if we haven't processed this file before + if !storage.externalRefs.Has(normalizedFilePath) { + // Get the file content for this reference + content, err := getFileContentForReference(ctx, normalizedFilePath, opts) + if err != nil { + return fmt.Errorf("failed to get content for reference %s: %w", normalizedFilePath, err) + } + + storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase + storage.resolvedContent[normalizedFilePath] = content + } + + // Get the resolved schema and recursively discover references within it + resolvedSchema := schema.GetResolvedSchema() + if resolvedSchema != nil { + // Convert back to referenceable schema for recursive discovery + resolvedRefSchema := (*oas3.JSONSchema[oas3.Referenceable])(resolvedSchema) + + targetDocInfo := schema.GetReferenceResolutionInfo() + + // Recursively discover references within the resolved schema using oas3.Walk + for item := range oas3.Walk(ctx, resolvedRefSchema) { + err := item.Match(oas3.SchemaMatcher{ + Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error { + return discoverSchemaReference(ctx, s, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + }) + if err != nil { + return fmt.Errorf("failed to discover nested schema reference: %w", err) + } + } + } + + return nil +} + +// discoverGenericReference handles discovery of generic OpenAPI component references +func discoverGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ctx context.Context, ref *Reference[T, V, C], opts ResolveOptions, storage *localizeStorage) error { + if ref == nil || !ref.IsReference() { + return nil + } + + refStr, classification := handleReference(ref.GetReference(), "", opts.TargetLocation) + if classification == nil || classification.IsFragment { + return nil // Skip internal references + } + + // Get the URI part (file path) from the reference + refObj := references.Reference(refStr) + filePath := refObj.GetURI() + + // For URLs, use the full reference as the key, for file paths normalize + var normalizedFilePath string + if classification.Type == utils.ReferenceTypeURL { + normalizedFilePath = refStr // Use the full URL as the key + } else { + normalizedFilePath = normalizeFilePath(filePath) + } + + // Check if we've already processed this file + if storage.externalRefs.Has(normalizedFilePath) { + return nil + } + + // Resolve the external reference to ensure it's valid + _, err := ref.Resolve(ctx, opts) + if err != nil { + return fmt.Errorf("failed to resolve external reference %s: %w", refStr, err) + } + + // Get the file content for this reference + content, err := getFileContentForReference(ctx, normalizedFilePath, opts) + if err != nil { + return fmt.Errorf("failed to get content for reference %s: %w", normalizedFilePath, err) + } + + storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase + storage.resolvedContent[normalizedFilePath] = content + + // Get the resolved object and recursively discover references within it + resolvedValue := ref.GetObject() + if resolvedValue != nil { + targetDocInfo := ref.GetReferenceResolutionInfo() + + // Recursively discover references within the resolved object using Walk + for item := range Walk(ctx, resolvedValue) { + err := item.Match(Matcher{ + Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error { + return discoverSchemaReference(ctx, s, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedPathItem: func(r *ReferencedPathItem) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedParameter: func(r *ReferencedParameter) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedExample: func(r *ReferencedExample) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedRequestBody: func(r *ReferencedRequestBody) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedResponse: func(r *ReferencedResponse) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedHeader: func(r *ReferencedHeader) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedCallback: func(r *ReferencedCallback) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedLink: func(r *ReferencedLink) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + ReferencedSecurityScheme: func(r *ReferencedSecurityScheme) error { + return discoverGenericReference(ctx, r, ResolveOptions{ + RootDocument: opts.RootDocument, + TargetDocument: targetDocInfo.ResolvedDocument, + TargetLocation: targetDocInfo.AbsoluteReference, + VirtualFS: opts.VirtualFS, + HTTPClient: opts.HTTPClient, + }, storage) + }, + }) + if err != nil { + return fmt.Errorf("failed to discover nested reference: %w", err) + } + } + } + + return nil +} + +// getFileContentForReference retrieves the content of a file referenced by the given file path +func getFileContentForReference(ctx context.Context, filePath string, opts ResolveOptions) ([]byte, error) { + // First check if this is a URL or file path + classification, err := utils.ClassifyReference(filePath) + if err != nil { + return nil, fmt.Errorf("failed to classify reference %s: %w", filePath, err) + } + + var resolvedPath string + if classification.Type == utils.ReferenceTypeURL { + // For URLs, use the path as-is + resolvedPath = filePath + } else { + // For file paths, check if we need to resolve relative to a URL target location + resolvedPath = filePath + if !filepath.IsAbs(filePath) && opts.TargetLocation != "" { + // Check if target location is a URL + if targetClassification, targetErr := utils.ClassifyReference(opts.TargetLocation); targetErr == nil && targetClassification.Type == utils.ReferenceTypeURL { + // Resolve relative file path against URL target location + resolved := resolveRelativeReference(filePath, opts.TargetLocation) + // Re-classify the resolved reference + if resolvedClassification, resolvedErr := utils.ClassifyReference(resolved); resolvedErr == nil { + classification = resolvedClassification + resolvedPath = resolved + } + } else { + // Resolve relative to the target location directory (file path) + targetDir := filepath.Dir(opts.TargetLocation) + resolvedPath = filepath.Join(targetDir, filePath) + } + } + } + + switch classification.Type { + case utils.ReferenceTypeFilePath: + // Read from file system + file, err := opts.VirtualFS.Open(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to open file %s: %w", resolvedPath, err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", resolvedPath, err) + } + + return content, nil + + case utils.ReferenceTypeURL: + // Fetch content via HTTP + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolvedPath, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request for %s: %w", resolvedPath, err) + } + + resp, err := httpClient.Do(req) + if err != nil || resp == nil { + return nil, fmt.Errorf("failed to fetch URL %s: %w", resolvedPath, err) + } + defer resp.Body.Close() + + // Check if the response was successful + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP request failed with status %d for URL %s", resp.StatusCode, resolvedPath) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body from %s: %w", resolvedPath, err) + } + + return content, nil + + default: + return nil, fmt.Errorf("unsupported reference type for localization: %s", resolvedPath) + } +} + +// generateLocalizedFilenames creates conflict-free filenames for all external references +func generateLocalizedFilenames(storage *localizeStorage, strategy LocalizeNamingStrategy) { + // First pass: collect all base filenames to detect conflicts + baseFilenames := make(map[string][]string) // base filename -> list of full paths + for ref := range storage.externalRefs.All() { + refObj := references.Reference(ref) + filePath := refObj.GetURI() + baseFilename := filepath.Base(filePath) + baseFilenames[baseFilename] = append(baseFilenames[baseFilename], ref) + } + + // Second pass: assign filenames based on conflicts (using deterministic order from sequencedmap) + processedBaseNames := make(map[string]bool) // track which base names we've processed + + for ref := range storage.externalRefs.All() { + refObj := references.Reference(ref) + filePath := refObj.GetURI() + baseFilename := filepath.Base(filePath) + conflictingRefs := baseFilenames[baseFilename] + + var filename string + if len(conflictingRefs) == 1 { + // No conflicts, use simple filename + filename = baseFilename + } else { + // Has conflicts - for path-based naming, first file gets simple name, others get path prefix + if strategy == LocalizeNamingPathBased && !processedBaseNames[baseFilename] { + // First file with this base name gets the simple name + filename = baseFilename + processedBaseNames[baseFilename] = true + } else { + // Subsequent files or counter strategy get modified names + filename = generateLocalizedFilenameWithConflictDetection(ref, strategy, baseFilenames, storage.usedFilenames) + } + } + + storage.externalRefs.Set(ref, filename) + storage.usedFilenames[filename] = true + } +} + +// generateLocalizedFilenameWithConflictDetection creates a localized filename with proper conflict detection +func generateLocalizedFilenameWithConflictDetection(ref string, strategy LocalizeNamingStrategy, baseFilenames map[string][]string, usedFilenames map[string]bool) string { + // Get the file path from the reference + refObj := references.Reference(ref) + filePath := refObj.GetURI() + baseFilename := filepath.Base(filePath) + + // Check if there are conflicts for this base filename + conflictingRefs := baseFilenames[baseFilename] + hasConflicts := len(conflictingRefs) > 1 + + switch strategy { + case LocalizeNamingPathBased: + return generatePathBasedFilenameWithConflictDetection(filePath, hasConflicts, usedFilenames) + case LocalizeNamingCounter: + return generateCounterBasedFilename(filePath, usedFilenames) + default: + return generatePathBasedFilenameWithConflictDetection(filePath, hasConflicts, usedFilenames) + } +} + +// generatePathBasedFilenameWithConflictDetection creates filenames with smart conflict resolution +func generatePathBasedFilenameWithConflictDetection(filePath string, _ bool, usedFilenames map[string]bool) string { + // Check if this is a URL - if so, extract filename from URL path + if classification, err := utils.ClassifyReference(filePath); err == nil && classification.Type == utils.ReferenceTypeURL { + // For URLs, extract the filename from the URL path + if lastSlash := strings.LastIndex(filePath, "/"); lastSlash != -1 { + filename := filePath[lastSlash+1:] + // Remove any query parameters or fragments + if queryIdx := strings.Index(filename, "?"); queryIdx != -1 { + filename = filename[:queryIdx] + } + if fragIdx := strings.Index(filename, "#"); fragIdx != -1 { + filename = filename[:fragIdx] + } + return filename + } + // Fallback to a safe filename if we can't extract from URL + return "remote-schema.yaml" + } + + // Clean the path and get the base filename + cleanPath := filepath.Clean(filePath) + + // Remove leading "./" if present + cleanPath = strings.TrimPrefix(cleanPath, "./") + + // Handle parent directory references by replacing ".." with "parent" + cleanPath = strings.ReplaceAll(cleanPath, "..", "parent") + + // Get the directory and filename + dir := filepath.Dir(cleanPath) + filename := filepath.Base(cleanPath) + + // For path-based naming, always use directory prefix if there's a directory + // This ensures consistent naming regardless of processing order + var result string + if dir == "." || dir == "" { + // No directory, use simple filename + result = filename + } else { + // Replace path separators with hyphens + dirPart := strings.ReplaceAll(dir, string(filepath.Separator), "-") + dirPart = strings.ReplaceAll(dirPart, "/", "-") // Handle forward slashes on Windows + dirPart = strings.ReplaceAll(dirPart, "\\", "-") // Handle backslashes on Unix + + ext := filepath.Ext(filename) + baseName := strings.TrimSuffix(filename, ext) + result = dirPart + "-" + baseName + ext + } + + // Ensure uniqueness + originalResult := result + counter := 1 + for usedFilenames[result] { + ext := filepath.Ext(originalResult) + baseName := strings.TrimSuffix(originalResult, ext) + result = fmt.Sprintf("%s_%d%s", baseName, counter, ext) + counter++ + } + + return result +} + +// generateCounterBasedFilename creates filenames like "address_1.yaml" for conflicts +func generateCounterBasedFilename(filePath string, usedFilenames map[string]bool) string { + filename := filepath.Base(filePath) + + // Ensure uniqueness + result := filename + counter := 1 + for usedFilenames[result] { + ext := filepath.Ext(filename) + baseName := strings.TrimSuffix(filename, ext) + result = fmt.Sprintf("%s_%d%s", baseName, counter, ext) + counter++ + } + + return result +} + +// copyExternalFiles copies all external reference files to the target directory +func copyExternalFiles(_ context.Context, storage *localizeStorage, opts LocalizeOptions) error { + for ref, localizedFilename := range storage.externalRefs.All() { + content := storage.resolvedContent[ref] + + // Rewrite internal references within the copied file + updatedContent, err := rewriteInternalReferences(content, ref, storage) + if err != nil { + return fmt.Errorf("failed to rewrite internal references in %s: %w", ref, err) + } + + targetPath := filepath.Join(opts.TargetDirectory, localizedFilename) + + if err := opts.VirtualFS.WriteFile(targetPath, updatedContent, 0o644); err != nil { + return fmt.Errorf("failed to write localized file %s: %w", targetPath, err) + } + } + + return nil +} + +// rewriteInternalReferences updates references within a copied file to point to other localized files +func rewriteInternalReferences(content []byte, originalRef string, storage *localizeStorage) ([]byte, error) { + // Parse the YAML content + var node yaml.Node + if err := yaml.Unmarshal(content, &node); err != nil { + return nil, fmt.Errorf("failed to parse YAML content: %w", err) + } + + // Walk through the YAML and update references + if err := rewriteYAMLReferences(&node, originalRef, storage); err != nil { + return nil, fmt.Errorf("failed to rewrite YAML references: %w", err) + } + + // Marshal back to YAML + updatedContent, err := yaml.Marshal(&node) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated YAML: %w", err) + } + + return updatedContent, nil +} + +// rewriteYAMLReferences recursively walks through YAML nodes and updates $ref values +func rewriteYAMLReferences(node *yaml.Node, originalRef string, storage *localizeStorage) error { + if node == nil { + return nil + } + + switch node.Kind { + case yaml.DocumentNode: + for _, child := range node.Content { + if err := rewriteYAMLReferences(child, originalRef, storage); err != nil { + return err + } + } + case yaml.MappingNode: + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + // Check if this is a $ref key + if keyNode.Kind == yaml.ScalarNode && keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode { + // Update the reference value + updatedRef := rewriteReferenceValue(valueNode.Value, originalRef, storage) + valueNode.Value = updatedRef + } else { + // Recursively process the value + if err := rewriteYAMLReferences(valueNode, originalRef, storage); err != nil { + return err + } + } + } + case yaml.SequenceNode: + for _, child := range node.Content { + if err := rewriteYAMLReferences(child, originalRef, storage); err != nil { + return err + } + } + } + + return nil +} + +// rewriteReferenceValue updates a single reference value to point to localized files +func rewriteReferenceValue(refValue, originalRef string, storage *localizeStorage) string { + // If this is an internal reference (starts with #), leave it as-is + if strings.HasPrefix(refValue, "#") { + return refValue + } + + // Resolve the reference relative to the original file + resolvedRef := resolveRelativeReference(refValue, originalRef) + + // Extract the file path from the resolved reference + refObj := references.Reference(resolvedRef) + filePath := refObj.GetURI() + + // For URLs, use the full reference as the key, for file paths normalize + var normalizedFilePath string + if classification, err := utils.ClassifyReference(resolvedRef); err == nil && classification.Type == utils.ReferenceTypeURL { + normalizedFilePath = resolvedRef // Use the full URL as the key + } else { + normalizedFilePath = normalizeFilePath(filePath) + } + + // Check if we have a localized version of this file + if localizedFilename, exists := storage.externalRefs.Get(normalizedFilePath); exists { + // Build new reference with localized filename + if refObj.HasJSONPointer() { + return localizedFilename + "#" + string(refObj.GetJSONPointer()) + } + return localizedFilename + } + + // If not found by full URL, try to find by just the reference value itself + // This handles cases where the reference value is already a full URL + if localizedFilename, exists := storage.externalRefs.Get(refValue); exists { + // Build new reference with localized filename + refObj := references.Reference(refValue) + if refObj.HasJSONPointer() { + return localizedFilename + "#" + string(refObj.GetJSONPointer()) + } + return localizedFilename + } + + // If not found in our localized references, return as-is + return refValue +} + +// resolveRelativeReference resolves a relative reference against a base reference +func resolveRelativeReference(ref, baseRef string) string { + // Parse base reference to get the directory + baseRefObj := references.Reference(baseRef) + baseURI := baseRefObj.GetURI() + + // Parse the reference + refObj := references.Reference(ref) + refPath := refObj.GetURI() + + // Check if the base reference is a URL + if classification, err := utils.ClassifyReference(baseURI); err == nil && classification.Type == utils.ReferenceTypeURL { + // For URLs, use URL path joining instead of file path joining + var resolvedPath string + switch { + case strings.HasPrefix(refPath, "./"): + // Relative reference - resolve against base URL directory + baseDir := baseURI + if lastSlash := strings.LastIndex(baseURI, "/"); lastSlash != -1 { + baseDir = baseURI[:lastSlash+1] + } + resolvedPath = baseDir + strings.TrimPrefix(refPath, "./") + case strings.HasPrefix(refPath, "/"): + // Absolute path reference - use as-is relative to URL root + if idx := strings.Index(baseURI, "://"); idx != -1 { + if hostEnd := strings.Index(baseURI[idx+3:], "/"); hostEnd != -1 { + resolvedPath = baseURI[:idx+3+hostEnd] + refPath + } else { + resolvedPath = baseURI + refPath + } + } else { + resolvedPath = refPath + } + default: + // Simple filename - resolve against base URL directory + baseDir := baseURI + if lastSlash := strings.LastIndex(baseURI, "/"); lastSlash != -1 { + baseDir = baseURI[:lastSlash+1] + } + resolvedPath = baseDir + refPath + } + + // Add back the fragment if present + if refObj.HasJSONPointer() { + return resolvedPath + "#" + string(refObj.GetJSONPointer()) + } + + return resolvedPath + } else { + // For file paths, use the original file path logic + baseDir := filepath.Dir(baseURI) + + // Resolve the path + resolvedPath := filepath.Join(baseDir, refPath) + resolvedPath = filepath.Clean(resolvedPath) + + // Add back the fragment if present + if refObj.HasJSONPointer() { + return resolvedPath + "#" + string(refObj.GetJSONPointer()) + } + + return resolvedPath + } +} + +// rewriteReferencesToLocalized updates all references in the document to point to localized files +func rewriteReferencesToLocalized(ctx context.Context, doc *OpenAPI, storage *localizeStorage) error { + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + if schema.IsReference() { + ref := schema.GetRef() + refObj := ref + filePath := refObj.GetURI() + + // For URLs, use the full reference as the key, for file paths normalize + var normalizedFilePath string + if classification, err := utils.ClassifyReference(string(ref)); err == nil && classification.Type == utils.ReferenceTypeURL { + normalizedFilePath = string(ref) // Use the full URL as the key + } else { + normalizedFilePath = normalizeFilePath(filePath) + } + + if localizedFilename, exists := storage.externalRefs.Get(normalizedFilePath); exists { + // Build new reference with localized filename + if refObj.HasJSONPointer() { + newRef := localizedFilename + "#" + string(refObj.GetJSONPointer()) + *schema.GetLeft().Ref = references.Reference(newRef) + } else { + *schema.GetLeft().Ref = references.Reference(localizedFilename) + } + } + } + return nil + }, + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return updateGenericReference(ref, storage) + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return updateGenericReference(ref, storage) + }, + ReferencedExample: func(ref *ReferencedExample) error { + return updateGenericReference(ref, storage) + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return updateGenericReference(ref, storage) + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return updateGenericReference(ref, storage) + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return updateGenericReference(ref, storage) + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return updateGenericReference(ref, storage) + }, + ReferencedLink: func(ref *ReferencedLink) error { + return updateGenericReference(ref, storage) + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return updateGenericReference(ref, storage) + }, + }) + if err != nil { + return fmt.Errorf("failed to update reference at %s: %w", item.Location.ToJSONPointer().String(), err) + } + } + + return nil +} + +// updateGenericReference updates a generic reference to point to the localized filename +func updateGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ref *Reference[T, V, C], storage *localizeStorage) error { + if ref == nil || !ref.IsReference() { + return nil + } + + refObj := ref.GetReference() + filePath := refObj.GetURI() + + // For URLs, use the full reference as the key, for file paths normalize + var normalizedFilePath string + if classification, err := utils.ClassifyReference(string(refObj)); err == nil && classification.Type == utils.ReferenceTypeURL { + normalizedFilePath = string(refObj) // Use the full URL as the key + } else { + normalizedFilePath = normalizeFilePath(filePath) + } + + if localizedFilename, exists := storage.externalRefs.Get(normalizedFilePath); exists { + // Build new reference with localized filename + if refObj.HasJSONPointer() { + newRef := localizedFilename + "#" + string(refObj.GetJSONPointer()) + *ref.Reference = references.Reference(newRef) + } else { + *ref.Reference = references.Reference(localizedFilename) + } + } + + return nil +} + +// normalizeFilePath normalizes a file path for consistent handling +func normalizeFilePath(filePath string) string { + // Check if this is a URL - if so, don't apply file path normalization + if classification, err := utils.ClassifyReference(filePath); err == nil && classification.Type == utils.ReferenceTypeURL { + return filePath // Return URLs as-is + } + + // Clean and normalize the file path + cleanPath := filepath.Clean(filePath) + + // Remove leading "./" if present + cleanPath = strings.TrimPrefix(cleanPath, "./") + + return cleanPath +} diff --git a/openapi/localize_test.go b/openapi/localize_test.go new file mode 100644 index 0000000..747e135 --- /dev/null +++ b/openapi/localize_test.go @@ -0,0 +1,268 @@ +package openapi_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/system" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalize_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a mock HTTP server to serve remote schemas + server := createMockRemoteServer(t) + defer server.Close() + + // Load the input document + inputFile, err := os.Open("testdata/localize/input/spec.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Create a temporary directory for output + tempDir := t.TempDir() + + // Create custom HTTP client that redirects api.example.com to our test server + httpClient := createRedirectHTTPClient(server.URL) + + // Configure localization options + opts := openapi.LocalizeOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/localize/input/spec.yaml", + VirtualFS: &system.FileSystem{}, + HTTPClient: httpClient, + }, + TargetDirectory: tempDir, + VirtualFS: &system.FileSystem{}, + NamingStrategy: openapi.LocalizeNamingPathBased, + } + + // Localize all external references + err = openapi.Localize(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the localized main document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualMainYAML := buf.Bytes() + + // Load the expected main document output + expectedMainBytes, err := os.ReadFile("testdata/localize/output_pathbased/openapi.yaml") + require.NoError(t, err) + + // Compare the main document with expected output + assert.Equal(t, string(expectedMainBytes), string(actualMainYAML), "Localized main document should match expected output") + + // Verify that the expected files were created in the target directory + expectedFiles := []string{ + "components.yaml", // from ./components.yaml (first conflict file gets simple name) + "api-components.yaml", // from ./api/components.yaml (subsequent conflict file gets path prefix) + "address.yaml", // from ./schemas/address.yaml (first conflict file gets simple name) + "shared-address.yaml", // from ./shared/address.yaml (subsequent conflict file gets path prefix) + "category.yaml", // from ./schemas/category.yaml (no conflict) + "geo.yaml", // from ./schemas/geo.yaml (no conflict, referenced by address.yaml) + "user-profile.yaml", // from remote URL + "user-preferences.yaml", // from remote URL (referenced by user-profile.yaml) + "metadata.yaml", // from remote URL (referenced by user-profile.yaml) + } + + for _, expectedFile := range expectedFiles { + // Check that the file exists + actualFilePath := filepath.Join(tempDir, expectedFile) + _, err := os.Stat(actualFilePath) + require.NoError(t, err, "Expected file %s should exist in target directory", expectedFile) + + // Read the actual file content + actualContent, err := os.ReadFile(actualFilePath) + require.NoError(t, err) + + // Read the expected file content + expectedFilePath := filepath.Join("testdata/localize/output_pathbased", expectedFile) + expectedContent, err := os.ReadFile(expectedFilePath) + require.NoError(t, err) + + // Compare the content + assert.Equal(t, string(expectedContent), string(actualContent), "Localized file %s should match expected content", expectedFile) + } +} + +func TestLocalize_CounterBased_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a mock HTTP server to serve remote schemas + server := createMockRemoteServer(t) + defer server.Close() + + // Load the input document + inputFile, err := os.Open("testdata/localize/input/spec.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Create a temporary directory for output + tempDir := t.TempDir() + + // Create custom HTTP client that redirects api.example.com to our test server + httpClient := createRedirectHTTPClient(server.URL) + + // Configure localization options with counter-based naming + opts := openapi.LocalizeOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/localize/input/spec.yaml", + VirtualFS: &system.FileSystem{}, + HTTPClient: httpClient, + }, + TargetDirectory: tempDir, + VirtualFS: &system.FileSystem{}, + NamingStrategy: openapi.LocalizeNamingCounter, + } + + // Localize all external references + err = openapi.Localize(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the localized main document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualMainYAML := buf.Bytes() + + // Load the expected main document output + expectedMainBytes, err := os.ReadFile("testdata/localize/output_counter/openapi.yaml") + require.NoError(t, err) + + // Compare the main document with expected output + assert.Equal(t, string(expectedMainBytes), string(actualMainYAML), "Localized main document should match expected output") + + // Verify that the expected files were created in the target directory + expectedFiles := []string{ + "components.yaml", // from ./components.yaml (first conflict file gets simple name) + "components_1.yaml", // from ./api/components.yaml (subsequent conflict file gets counter suffix) + "address.yaml", // from ./schemas/address.yaml (first conflict file gets simple name) + "address_1.yaml", // from ./shared/address.yaml (subsequent conflict file gets counter suffix) + "category.yaml", // from ./schemas/category.yaml (no conflict) + "geo.yaml", // from ./schemas/geo.yaml (no conflict, referenced by address.yaml) + "user-profile.yaml", // from remote URL + "user-preferences.yaml", // from remote URL (referenced by user-profile.yaml) + "metadata.yaml", // from remote URL (referenced by user-profile.yaml) + } + + for _, expectedFile := range expectedFiles { + // Check that the file exists + actualFilePath := filepath.Join(tempDir, expectedFile) + _, err := os.Stat(actualFilePath) + require.NoError(t, err, "Expected file %s should exist in target directory", expectedFile) + + // Read the actual file content + actualContent, err := os.ReadFile(actualFilePath) + require.NoError(t, err) + + // Read the expected file content + expectedFilePath := filepath.Join("testdata/localize/output_counter", expectedFile) + expectedContent, err := os.ReadFile(expectedFilePath) + require.NoError(t, err) + + // Compare the content + assert.Equal(t, string(expectedContent), string(actualContent), "Localized file %s should match expected content", expectedFile) + } +} + +// createMockRemoteServer creates a mock HTTP server that serves remote schema files +func createMockRemoteServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + // Serve user-profile.yaml + mux.HandleFunc("/schemas/user-profile.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + content, err := os.ReadFile("testdata/localize/remote/schemas/user-profile.yaml") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = w.Write(content) + }) + + // Serve user-preferences.yaml + mux.HandleFunc("/schemas/user-preferences.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + content, err := os.ReadFile("testdata/localize/remote/schemas/user-preferences.yaml") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = w.Write(content) + }) + + // Serve metadata.yaml + mux.HandleFunc("/schemas/metadata.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + content, err := os.ReadFile("testdata/localize/remote/schemas/metadata.yaml") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = w.Write(content) + }) + + return httptest.NewServer(mux) +} + +// createRedirectHTTPClient creates an HTTP client that redirects api.example.com requests to the test server +func createRedirectHTTPClient(testServerURL string) *http.Client { + return &http.Client{ + Transport: &redirectTransport{ + testServerURL: testServerURL, + base: http.DefaultTransport, + }, + } +} + +// redirectTransport redirects api.example.com requests to the test server +type redirectTransport struct { + testServerURL string + base http.RoundTripper +} + +func (rt *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Check if this is an api.example.com request + if req.URL.Host == "api.example.com" { + // Replace the host with our test server + newURL := *req.URL + testURL := strings.TrimPrefix(rt.testServerURL, "http://") + newURL.Host = testURL + newURL.Scheme = "http" + + // Clone the request with the new URL + newReq := req.Clone(req.Context()) + newReq.URL = &newURL + + return rt.base.RoundTrip(newReq) + } + + // For all other requests, use the base transport + return rt.base.RoundTrip(req) +} diff --git a/openapi/testdata/localize/input/api/components.yaml b/openapi/testdata/localize/input/api/components.yaml new file mode 100644 index 0000000..2579e54 --- /dev/null +++ b/openapi/testdata/localize/input/api/components.yaml @@ -0,0 +1,18 @@ +components: + schemas: + ApiInfo: + type: object + required: + - version + - name + properties: + version: + type: string + description: API version + name: + type: string + description: API name + status: + type: string + enum: [active, deprecated, beta] + description: API status diff --git a/openapi/testdata/localize/input/components.yaml b/openapi/testdata/localize/input/components.yaml new file mode 100644 index 0000000..f29b307 --- /dev/null +++ b/openapi/testdata/localize/input/components.yaml @@ -0,0 +1,39 @@ +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: string + description: User ID + name: + type: string + description: User name + email: + type: string + format: email + description: User email address + address: + $ref: "./schemas/address.yaml#/Address" + Product: + type: object + required: + - id + - name + - price + properties: + id: + type: string + description: Product ID + name: + type: string + description: Product name + price: + type: number + format: float + description: Product price + category: + $ref: "./schemas/category.yaml#/Category" diff --git a/openapi/testdata/localize/input/schemas/address.yaml b/openapi/testdata/localize/input/schemas/address.yaml new file mode 100644 index 0000000..b1b366c --- /dev/null +++ b/openapi/testdata/localize/input/schemas/address.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - street + - city + - country + properties: + street: + type: string + description: Street address + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country code + postalCode: + type: string + description: Postal or ZIP code + geoLocation: + $ref: "./geo.yaml#/GeoLocation" + description: Geographic coordinates of the address diff --git a/openapi/testdata/localize/input/schemas/category.yaml b/openapi/testdata/localize/input/schemas/category.yaml new file mode 100644 index 0000000..acdf5b8 --- /dev/null +++ b/openapi/testdata/localize/input/schemas/category.yaml @@ -0,0 +1,15 @@ +Category: + type: object + required: + - id + - name + properties: + id: + type: string + description: Category ID + name: + type: string + description: Category name + parentCategory: + $ref: "#/Category" + description: Parent category for hierarchical categorization diff --git a/openapi/testdata/localize/input/schemas/geo.yaml b/openapi/testdata/localize/input/schemas/geo.yaml new file mode 100644 index 0000000..eb4613e --- /dev/null +++ b/openapi/testdata/localize/input/schemas/geo.yaml @@ -0,0 +1,26 @@ +GeoLocation: + type: object + required: + - latitude + - longitude + properties: + latitude: + type: number + format: float + minimum: -90 + maximum: 90 + description: Latitude coordinate + longitude: + type: number + format: float + minimum: -180 + maximum: 180 + description: Longitude coordinate + altitude: + type: number + format: float + description: Altitude in meters (optional) + accuracy: + type: number + format: float + description: GPS accuracy in meters (optional) \ No newline at end of file diff --git a/openapi/testdata/localize/input/shared/address.yaml b/openapi/testdata/localize/input/shared/address.yaml new file mode 100644 index 0000000..e641ee9 --- /dev/null +++ b/openapi/testdata/localize/input/shared/address.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - line1 + - city + - country + properties: + line1: + type: string + description: Address line 1 + line2: + type: string + description: Address line 2 (optional) + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country name + zipCode: + type: string + description: ZIP or postal code diff --git a/openapi/testdata/localize/input/spec.yaml b/openapi/testdata/localize/input/spec.yaml new file mode 100644 index 0000000..15059ec --- /dev/null +++ b/openapi/testdata/localize/input/spec.yaml @@ -0,0 +1,78 @@ +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 + description: A test API for integration testing +servers: + - url: https://api.example.com/v1 +paths: + /users: + get: + summary: List users + operationId: getUsers + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "./components.yaml#/components/schemas/User" + /products: + get: + summary: List products + operationId: getProducts + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "./components.yaml#/components/schemas/Product" + /api-info: + get: + summary: Get API information + operationId: getApiInfo + responses: + "200": + description: API information + content: + application/json: + schema: + $ref: "./api/components.yaml#/components/schemas/ApiInfo" + /billing-address: + get: + summary: Get billing address format + operationId: getBillingAddress + responses: + "200": + description: Billing address format + content: + application/json: + schema: + $ref: "./shared/address.yaml#/Address" + /user-profile: + get: + summary: Get user profile + operationId: getUserProfile + responses: + "200": + description: User profile information + content: + application/json: + schema: + $ref: "https://api.example.com/schemas/user-profile.yaml#/UserProfile" + /preferences: + get: + summary: Get user preferences + operationId: getUserPreferences + responses: + "200": + description: User preferences + content: + application/json: + schema: + $ref: "https://api.example.com/schemas/user-preferences.yaml#/UserPreferences" diff --git a/openapi/testdata/localize/output_counter/address.yaml b/openapi/testdata/localize/output_counter/address.yaml new file mode 100644 index 0000000..982a044 --- /dev/null +++ b/openapi/testdata/localize/output_counter/address.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - street + - city + - country + properties: + street: + type: string + description: Street address + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country code + postalCode: + type: string + description: Postal or ZIP code + geoLocation: + $ref: "./geo.yaml#/GeoLocation" + description: Geographic coordinates of the address diff --git a/openapi/testdata/localize/output_counter/address_1.yaml b/openapi/testdata/localize/output_counter/address_1.yaml new file mode 100644 index 0000000..c8fc99c --- /dev/null +++ b/openapi/testdata/localize/output_counter/address_1.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - line1 + - city + - country + properties: + line1: + type: string + description: Address line 1 + line2: + type: string + description: Address line 2 (optional) + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country name + zipCode: + type: string + description: ZIP or postal code diff --git a/openapi/testdata/localize/output_counter/category.yaml b/openapi/testdata/localize/output_counter/category.yaml new file mode 100644 index 0000000..5f281a3 --- /dev/null +++ b/openapi/testdata/localize/output_counter/category.yaml @@ -0,0 +1,15 @@ +Category: + type: object + required: + - id + - name + properties: + id: + type: string + description: Category ID + name: + type: string + description: Category name + parentCategory: + $ref: "#/Category" + description: Parent category for hierarchical categorization diff --git a/openapi/testdata/localize/output_counter/components.yaml b/openapi/testdata/localize/output_counter/components.yaml new file mode 100644 index 0000000..f12b990 --- /dev/null +++ b/openapi/testdata/localize/output_counter/components.yaml @@ -0,0 +1,39 @@ +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: string + description: User ID + name: + type: string + description: User name + email: + type: string + format: email + description: User email address + address: + $ref: "address.yaml#/Address" + Product: + type: object + required: + - id + - name + - price + properties: + id: + type: string + description: Product ID + name: + type: string + description: Product name + price: + type: number + format: float + description: Product price + category: + $ref: "category.yaml#/Category" diff --git a/openapi/testdata/localize/output_counter/components_1.yaml b/openapi/testdata/localize/output_counter/components_1.yaml new file mode 100644 index 0000000..65f6205 --- /dev/null +++ b/openapi/testdata/localize/output_counter/components_1.yaml @@ -0,0 +1,18 @@ +components: + schemas: + ApiInfo: + type: object + required: + - version + - name + properties: + version: + type: string + description: API version + name: + type: string + description: API name + status: + type: string + enum: [active, deprecated, beta] + description: API status diff --git a/openapi/testdata/localize/output_counter/geo.yaml b/openapi/testdata/localize/output_counter/geo.yaml new file mode 100644 index 0000000..c1a8aaa --- /dev/null +++ b/openapi/testdata/localize/output_counter/geo.yaml @@ -0,0 +1,26 @@ +GeoLocation: + type: object + required: + - latitude + - longitude + properties: + latitude: + type: number + format: float + minimum: -90 + maximum: 90 + description: Latitude coordinate + longitude: + type: number + format: float + minimum: -180 + maximum: 180 + description: Longitude coordinate + altitude: + type: number + format: float + description: Altitude in meters (optional) + accuracy: + type: number + format: float + description: GPS accuracy in meters (optional) diff --git a/openapi/testdata/localize/output_counter/metadata.yaml b/openapi/testdata/localize/output_counter/metadata.yaml new file mode 100644 index 0000000..5dc079d --- /dev/null +++ b/openapi/testdata/localize/output_counter/metadata.yaml @@ -0,0 +1,19 @@ +Metadata: + type: object + properties: + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + version: + type: integer + description: Version number + tags: + type: array + items: + type: string + description: Associated tags diff --git a/openapi/testdata/localize/output_counter/openapi.yaml b/openapi/testdata/localize/output_counter/openapi.yaml new file mode 100644 index 0000000..d11a395 --- /dev/null +++ b/openapi/testdata/localize/output_counter/openapi.yaml @@ -0,0 +1,78 @@ +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 + description: A test API for integration testing +servers: + - url: https://api.example.com/v1 +paths: + /users: + get: + summary: List users + operationId: getUsers + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "components.yaml#/components/schemas/User" + /products: + get: + summary: List products + operationId: getProducts + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "components.yaml#/components/schemas/Product" + /api-info: + get: + summary: Get API information + operationId: getApiInfo + responses: + "200": + description: API information + content: + application/json: + schema: + $ref: "components_1.yaml#/components/schemas/ApiInfo" + /billing-address: + get: + summary: Get billing address format + operationId: getBillingAddress + responses: + "200": + description: Billing address format + content: + application/json: + schema: + $ref: "address_1.yaml#/Address" + /user-profile: + get: + summary: Get user profile + operationId: getUserProfile + responses: + "200": + description: User profile information + content: + application/json: + schema: + $ref: "user-profile.yaml#/UserProfile" + /preferences: + get: + summary: Get user preferences + operationId: getUserPreferences + responses: + "200": + description: User preferences + content: + application/json: + schema: + $ref: "user-preferences.yaml#/UserPreferences" diff --git a/openapi/testdata/localize/output_counter/user-preferences.yaml b/openapi/testdata/localize/output_counter/user-preferences.yaml new file mode 100644 index 0000000..3f882a8 --- /dev/null +++ b/openapi/testdata/localize/output_counter/user-preferences.yaml @@ -0,0 +1,19 @@ +UserPreferences: + type: object + required: + - theme + - language + properties: + theme: + type: string + enum: [light, dark, auto] + description: UI theme preference + language: + type: string + description: Preferred language code + notifications: + type: boolean + description: Enable notifications + timezone: + type: string + description: User timezone diff --git a/openapi/testdata/localize/output_counter/user-profile.yaml b/openapi/testdata/localize/output_counter/user-profile.yaml new file mode 100644 index 0000000..d5fc10e --- /dev/null +++ b/openapi/testdata/localize/output_counter/user-profile.yaml @@ -0,0 +1,13 @@ +UserProfile: + type: object + required: + - userId + - preferences + properties: + userId: + type: string + description: User identifier + preferences: + $ref: "user-preferences.yaml#/UserPreferences" + metadata: + $ref: "./metadata.yaml#/Metadata" diff --git a/openapi/testdata/localize/output_pathbased/address.yaml b/openapi/testdata/localize/output_pathbased/address.yaml new file mode 100644 index 0000000..982a044 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/address.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - street + - city + - country + properties: + street: + type: string + description: Street address + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country code + postalCode: + type: string + description: Postal or ZIP code + geoLocation: + $ref: "./geo.yaml#/GeoLocation" + description: Geographic coordinates of the address diff --git a/openapi/testdata/localize/output_pathbased/api-components.yaml b/openapi/testdata/localize/output_pathbased/api-components.yaml new file mode 100644 index 0000000..65f6205 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/api-components.yaml @@ -0,0 +1,18 @@ +components: + schemas: + ApiInfo: + type: object + required: + - version + - name + properties: + version: + type: string + description: API version + name: + type: string + description: API name + status: + type: string + enum: [active, deprecated, beta] + description: API status diff --git a/openapi/testdata/localize/output_pathbased/category.yaml b/openapi/testdata/localize/output_pathbased/category.yaml new file mode 100644 index 0000000..5f281a3 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/category.yaml @@ -0,0 +1,15 @@ +Category: + type: object + required: + - id + - name + properties: + id: + type: string + description: Category ID + name: + type: string + description: Category name + parentCategory: + $ref: "#/Category" + description: Parent category for hierarchical categorization diff --git a/openapi/testdata/localize/output_pathbased/components.yaml b/openapi/testdata/localize/output_pathbased/components.yaml new file mode 100644 index 0000000..f12b990 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/components.yaml @@ -0,0 +1,39 @@ +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: string + description: User ID + name: + type: string + description: User name + email: + type: string + format: email + description: User email address + address: + $ref: "address.yaml#/Address" + Product: + type: object + required: + - id + - name + - price + properties: + id: + type: string + description: Product ID + name: + type: string + description: Product name + price: + type: number + format: float + description: Product price + category: + $ref: "category.yaml#/Category" diff --git a/openapi/testdata/localize/output_pathbased/geo.yaml b/openapi/testdata/localize/output_pathbased/geo.yaml new file mode 100644 index 0000000..c1a8aaa --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/geo.yaml @@ -0,0 +1,26 @@ +GeoLocation: + type: object + required: + - latitude + - longitude + properties: + latitude: + type: number + format: float + minimum: -90 + maximum: 90 + description: Latitude coordinate + longitude: + type: number + format: float + minimum: -180 + maximum: 180 + description: Longitude coordinate + altitude: + type: number + format: float + description: Altitude in meters (optional) + accuracy: + type: number + format: float + description: GPS accuracy in meters (optional) diff --git a/openapi/testdata/localize/output_pathbased/metadata.yaml b/openapi/testdata/localize/output_pathbased/metadata.yaml new file mode 100644 index 0000000..5dc079d --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/metadata.yaml @@ -0,0 +1,19 @@ +Metadata: + type: object + properties: + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + version: + type: integer + description: Version number + tags: + type: array + items: + type: string + description: Associated tags diff --git a/openapi/testdata/localize/output_pathbased/openapi.yaml b/openapi/testdata/localize/output_pathbased/openapi.yaml new file mode 100644 index 0000000..686fe96 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/openapi.yaml @@ -0,0 +1,78 @@ +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 + description: A test API for integration testing +servers: + - url: https://api.example.com/v1 +paths: + /users: + get: + summary: List users + operationId: getUsers + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "components.yaml#/components/schemas/User" + /products: + get: + summary: List products + operationId: getProducts + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "components.yaml#/components/schemas/Product" + /api-info: + get: + summary: Get API information + operationId: getApiInfo + responses: + "200": + description: API information + content: + application/json: + schema: + $ref: "api-components.yaml#/components/schemas/ApiInfo" + /billing-address: + get: + summary: Get billing address format + operationId: getBillingAddress + responses: + "200": + description: Billing address format + content: + application/json: + schema: + $ref: "shared-address.yaml#/Address" + /user-profile: + get: + summary: Get user profile + operationId: getUserProfile + responses: + "200": + description: User profile information + content: + application/json: + schema: + $ref: "user-profile.yaml#/UserProfile" + /preferences: + get: + summary: Get user preferences + operationId: getUserPreferences + responses: + "200": + description: User preferences + content: + application/json: + schema: + $ref: "user-preferences.yaml#/UserPreferences" diff --git a/openapi/testdata/localize/output_pathbased/shared-address.yaml b/openapi/testdata/localize/output_pathbased/shared-address.yaml new file mode 100644 index 0000000..c8fc99c --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/shared-address.yaml @@ -0,0 +1,25 @@ +Address: + type: object + required: + - line1 + - city + - country + properties: + line1: + type: string + description: Address line 1 + line2: + type: string + description: Address line 2 (optional) + city: + type: string + description: City name + state: + type: string + description: State or province + country: + type: string + description: Country name + zipCode: + type: string + description: ZIP or postal code diff --git a/openapi/testdata/localize/output_pathbased/user-preferences.yaml b/openapi/testdata/localize/output_pathbased/user-preferences.yaml new file mode 100644 index 0000000..3f882a8 --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/user-preferences.yaml @@ -0,0 +1,19 @@ +UserPreferences: + type: object + required: + - theme + - language + properties: + theme: + type: string + enum: [light, dark, auto] + description: UI theme preference + language: + type: string + description: Preferred language code + notifications: + type: boolean + description: Enable notifications + timezone: + type: string + description: User timezone diff --git a/openapi/testdata/localize/output_pathbased/user-profile.yaml b/openapi/testdata/localize/output_pathbased/user-profile.yaml new file mode 100644 index 0000000..d5fc10e --- /dev/null +++ b/openapi/testdata/localize/output_pathbased/user-profile.yaml @@ -0,0 +1,13 @@ +UserProfile: + type: object + required: + - userId + - preferences + properties: + userId: + type: string + description: User identifier + preferences: + $ref: "user-preferences.yaml#/UserPreferences" + metadata: + $ref: "./metadata.yaml#/Metadata" diff --git a/openapi/testdata/localize/remote/schemas/metadata.yaml b/openapi/testdata/localize/remote/schemas/metadata.yaml new file mode 100644 index 0000000..0a41425 --- /dev/null +++ b/openapi/testdata/localize/remote/schemas/metadata.yaml @@ -0,0 +1,19 @@ +Metadata: + type: object + properties: + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + version: + type: integer + description: Version number + tags: + type: array + items: + type: string + description: Associated tags diff --git a/openapi/testdata/localize/remote/schemas/user-preferences.yaml b/openapi/testdata/localize/remote/schemas/user-preferences.yaml new file mode 100644 index 0000000..75ce3f5 --- /dev/null +++ b/openapi/testdata/localize/remote/schemas/user-preferences.yaml @@ -0,0 +1,19 @@ +UserPreferences: + type: object + required: + - theme + - language + properties: + theme: + type: string + enum: [light, dark, auto] + description: UI theme preference + language: + type: string + description: Preferred language code + notifications: + type: boolean + description: Enable notifications + timezone: + type: string + description: User timezone diff --git a/openapi/testdata/localize/remote/schemas/user-profile.yaml b/openapi/testdata/localize/remote/schemas/user-profile.yaml new file mode 100644 index 0000000..5caf424 --- /dev/null +++ b/openapi/testdata/localize/remote/schemas/user-profile.yaml @@ -0,0 +1,13 @@ +UserProfile: + type: object + required: + - userId + - preferences + properties: + userId: + type: string + description: User identifier + preferences: + $ref: "https://api.example.com/schemas/user-preferences.yaml#/UserPreferences" + metadata: + $ref: "./metadata.yaml#/Metadata" diff --git a/system/filesystem.go b/system/filesystem.go index 24cb137..32f56a2 100644 --- a/system/filesystem.go +++ b/system/filesystem.go @@ -3,16 +3,38 @@ package system import ( "io/fs" "os" + "path/filepath" ) type VirtualFS interface { fs.FS } +// WritableVirtualFS extends VirtualFS with write operations needed for localization +type WritableVirtualFS interface { + VirtualFS + WriteFile(name string, data []byte, perm os.FileMode) error + MkdirAll(path string, perm os.FileMode) error +} + type FileSystem struct{} var _ VirtualFS = (*FileSystem)(nil) +var _ WritableVirtualFS = (*FileSystem)(nil) func (fs *FileSystem) Open(name string) (fs.File, error) { return os.Open(name) //nolint:gosec } + +func (fs *FileSystem) WriteFile(name string, data []byte, perm os.FileMode) error { + // Ensure directory exists + dir := filepath.Dir(name) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + return os.WriteFile(name, data, perm) +} + +func (fs *FileSystem) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +}