Skip to content

Commit ca028d0

Browse files
fix: improve localize remote reference handling and expose walk utilities (#49)
1 parent 192fe7c commit ca028d0

File tree

7 files changed

+156
-262
lines changed

7 files changed

+156
-262
lines changed

jsonschema/oas3/resolution.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func (s *JSONSchema[Referenceable]) resolve(ctx context.Context, opts references
232232
result, validationErrs, err = s.resolveDefsReference(ctx, ref, opts)
233233
} else {
234234
// Resolve as JSONSchema to handle both Schema and boolean cases
235-
result, validationErrs, err = references.Resolve(ctx, ref, unmarshaller, opts)
235+
result, validationErrs, err = references.Resolve(ctx, ref, unmarshaler, opts)
236236
}
237237
if err != nil {
238238
return nil, validationErrs, err
@@ -328,7 +328,7 @@ func (s *JSONSchema[Referenceable]) resolveDefsReference(ctx context.Context, re
328328

329329
// First, try to resolve using the standard references.Resolve with the target document
330330
// This handles external $defs, caching, and all standard resolution features
331-
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaller, opts)
331+
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaler, opts)
332332
if err == nil {
333333
return result, validationErrs, nil
334334
}
@@ -339,7 +339,7 @@ func (s *JSONSchema[Referenceable]) resolveDefsReference(ctx context.Context, re
339339
parentOpts.TargetDocument = parent
340340
parentOpts.TargetLocation = opts.TargetLocation // Keep the same location for caching
341341

342-
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaller, parentOpts)
342+
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaler, parentOpts)
343343
if err == nil {
344344
return result, validationErrs, nil
345345
}
@@ -392,7 +392,7 @@ func (s *JSONSchema[Referenceable]) tryResolveDefsUsingJSONPointerNavigation(ctx
392392
parentOpts.TargetDocument = parentTarget
393393
parentOpts.TargetLocation = opts.TargetLocation // Keep the same location for caching
394394

395-
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaller, parentOpts)
395+
result, validationErrs, err := references.Resolve(ctx, ref, unmarshaler, parentOpts)
396396
if err == nil {
397397
return result, validationErrs, nil
398398
}
@@ -422,7 +422,7 @@ func getParentJSONPointer(jsonPtr string) string {
422422
return jsonPtr[:lastSlash]
423423
}
424424

425-
func unmarshaller(ctx context.Context, node *yaml.Node, skipValidation bool) (*JSONSchema[Referenceable], []error, error) {
425+
func unmarshaler(ctx context.Context, node *yaml.Node, skipValidation bool) (*JSONSchema[Referenceable], []error, error) {
426426
jsonSchema := &JSONSchema[Referenceable]{}
427427
validationErrs, err := marshaller.UnmarshalNode(ctx, "", node, jsonSchema)
428428
if skipValidation {

openapi/cmd/localize.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,10 @@ func localizeOpenAPI(ctx context.Context, inputFile, targetDirectory string) err
150150

151151
// Set up localize options
152152
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,
153+
DocumentLocation: cleanInputFile,
154+
TargetDirectory: cleanTargetDir,
155+
VirtualFS: &system.FileSystem{},
156+
NamingStrategy: namingStrategy,
160157
}
161158

162159
// Perform localization (this modifies the doc in memory but doesn't affect the original file)

openapi/localize.go

Lines changed: 54 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"io"
8-
"net/http"
97
"path/filepath"
108
"strings"
119

@@ -31,12 +29,14 @@ const (
3129

3230
// LocalizeOptions represents the options available when localizing an OpenAPI document.
3331
type LocalizeOptions struct {
34-
// ResolveOptions are the options to use when resolving references during localization.
35-
ResolveOptions ResolveOptions
32+
// DocumentLocation is the location of the document being localized.
33+
DocumentLocation string
3634
// TargetDirectory is the directory where localized files will be written.
3735
TargetDirectory string
3836
// VirtualFS is the file system interface used for reading and writing files.
3937
VirtualFS system.WritableVirtualFS
38+
// HTTPClient is the HTTP client to use for fetching remote references.
39+
HTTPClient system.Client
4040
// NamingStrategy determines how external reference files are named when localized.
4141
NamingStrategy LocalizeNamingStrategy
4242
}
@@ -132,7 +132,13 @@ func Localize(ctx context.Context, doc *OpenAPI, opts LocalizeOptions) error {
132132
}
133133

134134
// Phase 1: Discover and collect all external references
135-
if err := discoverExternalReferences(ctx, doc, opts.ResolveOptions, localizeStorage); err != nil {
135+
if err := discoverExternalReferences(ctx, doc, ResolveOptions{
136+
RootDocument: doc,
137+
TargetDocument: doc,
138+
TargetLocation: opts.DocumentLocation,
139+
VirtualFS: opts.VirtualFS,
140+
HTTPClient: opts.HTTPClient,
141+
}, localizeStorage); err != nil {
136142
return fmt.Errorf("failed to discover external references: %w", err)
137143
}
138144

@@ -224,29 +230,25 @@ func discoverSchemaReference(ctx context.Context, schema *oas3.JSONSchema[oas3.R
224230
normalizedFilePath = normalizeFilePath(filePath)
225231
}
226232

227-
// Always resolve the schema to enable recursive discovery
228-
resolveOpts := oas3.ResolveOptions{
229-
RootDocument: opts.RootDocument,
230-
TargetDocument: opts.TargetDocument,
231-
TargetLocation: opts.TargetLocation,
232-
VirtualFS: opts.VirtualFS,
233-
HTTPClient: opts.HTTPClient,
234-
}
235-
236-
if _, err := schema.Resolve(ctx, resolveOpts); err != nil {
233+
if _, err := schema.Resolve(ctx, opts); err != nil {
237234
return fmt.Errorf("failed to resolve external schema reference %s: %w", ref, err)
238235
}
239236

240237
// Only store the file content if we haven't processed this file before
241238
if !storage.externalRefs.Has(normalizedFilePath) {
242-
// Get the file content for this reference
243-
content, err := getFileContentForReference(ctx, normalizedFilePath, opts)
244-
if err != nil {
245-
return fmt.Errorf("failed to get content for reference %s: %w", normalizedFilePath, err)
246-
}
239+
// Get the cached reference document content that was loaded during resolution
240+
resolutionInfo := schema.GetReferenceResolutionInfo()
241+
if resolutionInfo != nil {
242+
storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase
247243

248-
storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase
249-
storage.resolvedContent[normalizedFilePath] = content
244+
if data, found := opts.RootDocument.GetCachedReferenceDocument(resolutionInfo.AbsoluteReference); found {
245+
storage.resolvedContent[normalizedFilePath] = data
246+
} else {
247+
return fmt.Errorf("failed to get cached content for reference %s", normalizedFilePath)
248+
}
249+
} else {
250+
return fmt.Errorf("failed to get resolution info for reference %s", normalizedFilePath)
251+
}
250252
}
251253

252254
// Get the resolved schema and recursively discover references within it
@@ -313,112 +315,65 @@ func discoverGenericReference[T any, V interfaces.Validator[T], C marshaller.Cor
313315
return fmt.Errorf("failed to resolve external reference %s: %w", refStr, err)
314316
}
315317

316-
// Get the file content for this reference
317-
content, err := getFileContentForReference(ctx, normalizedFilePath, opts)
318-
if err != nil {
319-
return fmt.Errorf("failed to get content for reference %s: %w", normalizedFilePath, err)
320-
}
318+
// Get the cached reference document content that was loaded during resolution
319+
resolutionInfo := ref.GetReferenceResolutionInfo()
320+
if resolutionInfo != nil {
321+
storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase
321322

322-
storage.externalRefs.Set(normalizedFilePath, "") // Will be filled in filename generation phase
323-
storage.resolvedContent[normalizedFilePath] = content
323+
if data, found := opts.RootDocument.GetCachedReferenceDocument(resolutionInfo.AbsoluteReference); found {
324+
storage.resolvedContent[normalizedFilePath] = data
325+
} else {
326+
return fmt.Errorf("failed to get cached content for reference %s", normalizedFilePath)
327+
}
328+
} else {
329+
return fmt.Errorf("failed to get resolution info for reference %s", normalizedFilePath)
330+
}
324331

325332
// Get the resolved object and recursively discover references within it
326333
resolvedValue := ref.GetObject()
327334
if resolvedValue != nil {
328335
targetDocInfo := ref.GetReferenceResolutionInfo()
329336

337+
resolveOpts := ResolveOptions{
338+
RootDocument: opts.RootDocument,
339+
TargetDocument: targetDocInfo.ResolvedDocument,
340+
TargetLocation: targetDocInfo.AbsoluteReference,
341+
VirtualFS: opts.VirtualFS,
342+
HTTPClient: opts.HTTPClient,
343+
}
344+
330345
// Recursively discover references within the resolved object using Walk
331346
for item := range Walk(ctx, resolvedValue) {
332347
err := item.Match(Matcher{
333348
Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error {
334-
return discoverSchemaReference(ctx, s, ResolveOptions{
335-
RootDocument: opts.RootDocument,
336-
TargetDocument: targetDocInfo.ResolvedDocument,
337-
TargetLocation: targetDocInfo.AbsoluteReference,
338-
VirtualFS: opts.VirtualFS,
339-
HTTPClient: opts.HTTPClient,
340-
}, storage)
349+
return discoverSchemaReference(ctx, s, resolveOpts, storage)
341350
},
342351
ReferencedPathItem: func(r *ReferencedPathItem) error {
343-
return discoverGenericReference(ctx, r, ResolveOptions{
344-
RootDocument: opts.RootDocument,
345-
TargetDocument: targetDocInfo.ResolvedDocument,
346-
TargetLocation: targetDocInfo.AbsoluteReference,
347-
VirtualFS: opts.VirtualFS,
348-
HTTPClient: opts.HTTPClient,
349-
}, storage)
352+
return discoverGenericReference(ctx, r, resolveOpts, storage)
350353
},
351354
ReferencedParameter: func(r *ReferencedParameter) error {
352-
return discoverGenericReference(ctx, r, ResolveOptions{
353-
RootDocument: opts.RootDocument,
354-
TargetDocument: targetDocInfo.ResolvedDocument,
355-
TargetLocation: targetDocInfo.AbsoluteReference,
356-
VirtualFS: opts.VirtualFS,
357-
HTTPClient: opts.HTTPClient,
358-
}, storage)
355+
return discoverGenericReference(ctx, r, resolveOpts, storage)
359356
},
360357
ReferencedExample: func(r *ReferencedExample) error {
361-
return discoverGenericReference(ctx, r, ResolveOptions{
362-
RootDocument: opts.RootDocument,
363-
TargetDocument: targetDocInfo.ResolvedDocument,
364-
TargetLocation: targetDocInfo.AbsoluteReference,
365-
VirtualFS: opts.VirtualFS,
366-
HTTPClient: opts.HTTPClient,
367-
}, storage)
358+
return discoverGenericReference(ctx, r, resolveOpts, storage)
368359
},
369360
ReferencedRequestBody: func(r *ReferencedRequestBody) error {
370-
return discoverGenericReference(ctx, r, ResolveOptions{
371-
RootDocument: opts.RootDocument,
372-
TargetDocument: targetDocInfo.ResolvedDocument,
373-
TargetLocation: targetDocInfo.AbsoluteReference,
374-
VirtualFS: opts.VirtualFS,
375-
HTTPClient: opts.HTTPClient,
376-
}, storage)
361+
return discoverGenericReference(ctx, r, resolveOpts, storage)
377362
},
378363
ReferencedResponse: func(r *ReferencedResponse) error {
379-
return discoverGenericReference(ctx, r, ResolveOptions{
380-
RootDocument: opts.RootDocument,
381-
TargetDocument: targetDocInfo.ResolvedDocument,
382-
TargetLocation: targetDocInfo.AbsoluteReference,
383-
VirtualFS: opts.VirtualFS,
384-
HTTPClient: opts.HTTPClient,
385-
}, storage)
364+
return discoverGenericReference(ctx, r, resolveOpts, storage)
386365
},
387366
ReferencedHeader: func(r *ReferencedHeader) error {
388-
return discoverGenericReference(ctx, r, ResolveOptions{
389-
RootDocument: opts.RootDocument,
390-
TargetDocument: targetDocInfo.ResolvedDocument,
391-
TargetLocation: targetDocInfo.AbsoluteReference,
392-
VirtualFS: opts.VirtualFS,
393-
HTTPClient: opts.HTTPClient,
394-
}, storage)
367+
return discoverGenericReference(ctx, r, resolveOpts, storage)
395368
},
396369
ReferencedCallback: func(r *ReferencedCallback) error {
397-
return discoverGenericReference(ctx, r, ResolveOptions{
398-
RootDocument: opts.RootDocument,
399-
TargetDocument: targetDocInfo.ResolvedDocument,
400-
TargetLocation: targetDocInfo.AbsoluteReference,
401-
VirtualFS: opts.VirtualFS,
402-
HTTPClient: opts.HTTPClient,
403-
}, storage)
370+
return discoverGenericReference(ctx, r, resolveOpts, storage)
404371
},
405372
ReferencedLink: func(r *ReferencedLink) error {
406-
return discoverGenericReference(ctx, r, ResolveOptions{
407-
RootDocument: opts.RootDocument,
408-
TargetDocument: targetDocInfo.ResolvedDocument,
409-
TargetLocation: targetDocInfo.AbsoluteReference,
410-
VirtualFS: opts.VirtualFS,
411-
HTTPClient: opts.HTTPClient,
412-
}, storage)
373+
return discoverGenericReference(ctx, r, resolveOpts, storage)
413374
},
414375
ReferencedSecurityScheme: func(r *ReferencedSecurityScheme) error {
415-
return discoverGenericReference(ctx, r, ResolveOptions{
416-
RootDocument: opts.RootDocument,
417-
TargetDocument: targetDocInfo.ResolvedDocument,
418-
TargetLocation: targetDocInfo.AbsoluteReference,
419-
VirtualFS: opts.VirtualFS,
420-
HTTPClient: opts.HTTPClient,
421-
}, storage)
376+
return discoverGenericReference(ctx, r, resolveOpts, storage)
422377
},
423378
})
424379
if err != nil {
@@ -430,90 +385,6 @@ func discoverGenericReference[T any, V interfaces.Validator[T], C marshaller.Cor
430385
return nil
431386
}
432387

433-
// getFileContentForReference retrieves the content of a file referenced by the given file path
434-
func getFileContentForReference(ctx context.Context, filePath string, opts ResolveOptions) ([]byte, error) {
435-
// First check if this is a URL or file path
436-
classification, err := utils.ClassifyReference(filePath)
437-
if err != nil {
438-
return nil, fmt.Errorf("failed to classify reference %s: %w", filePath, err)
439-
}
440-
441-
var resolvedPath string
442-
if classification.Type == utils.ReferenceTypeURL {
443-
// For URLs, use the path as-is
444-
resolvedPath = filePath
445-
} else {
446-
// For file paths, check if we need to resolve relative to a URL target location
447-
resolvedPath = filePath
448-
if !filepath.IsAbs(filePath) && opts.TargetLocation != "" {
449-
// Check if target location is a URL
450-
if targetClassification, targetErr := utils.ClassifyReference(opts.TargetLocation); targetErr == nil && targetClassification.Type == utils.ReferenceTypeURL {
451-
// Resolve relative file path against URL target location
452-
resolved := resolveRelativeReference(filePath, opts.TargetLocation)
453-
// Re-classify the resolved reference
454-
if resolvedClassification, resolvedErr := utils.ClassifyReference(resolved); resolvedErr == nil {
455-
classification = resolvedClassification
456-
resolvedPath = resolved
457-
}
458-
} else {
459-
// Resolve relative to the target location directory (file path)
460-
targetDir := filepath.Dir(opts.TargetLocation)
461-
resolvedPath = filepath.Join(targetDir, filePath)
462-
}
463-
}
464-
}
465-
466-
switch classification.Type {
467-
case utils.ReferenceTypeFilePath:
468-
// Read from file system
469-
file, err := opts.VirtualFS.Open(resolvedPath)
470-
if err != nil {
471-
return nil, fmt.Errorf("failed to open file %s: %w", resolvedPath, err)
472-
}
473-
defer file.Close()
474-
475-
content, err := io.ReadAll(file)
476-
if err != nil {
477-
return nil, fmt.Errorf("failed to read file %s: %w", resolvedPath, err)
478-
}
479-
480-
return content, nil
481-
482-
case utils.ReferenceTypeURL:
483-
// Fetch content via HTTP
484-
httpClient := opts.HTTPClient
485-
if httpClient == nil {
486-
httpClient = http.DefaultClient
487-
}
488-
489-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolvedPath, nil)
490-
if err != nil {
491-
return nil, fmt.Errorf("failed to create HTTP request for %s: %w", resolvedPath, err)
492-
}
493-
494-
resp, err := httpClient.Do(req)
495-
if err != nil || resp == nil {
496-
return nil, fmt.Errorf("failed to fetch URL %s: %w", resolvedPath, err)
497-
}
498-
defer resp.Body.Close()
499-
500-
// Check if the response was successful
501-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
502-
return nil, fmt.Errorf("HTTP request failed with status %d for URL %s", resp.StatusCode, resolvedPath)
503-
}
504-
505-
content, err := io.ReadAll(resp.Body)
506-
if err != nil {
507-
return nil, fmt.Errorf("failed to read response body from %s: %w", resolvedPath, err)
508-
}
509-
510-
return content, nil
511-
512-
default:
513-
return nil, fmt.Errorf("unsupported reference type for localization: %s", resolvedPath)
514-
}
515-
}
516-
517388
// generateLocalizedFilenames creates conflict-free filenames for all external references
518389
func generateLocalizedFilenames(storage *localizeStorage, strategy LocalizeNamingStrategy) {
519390
// First pass: collect all base filenames to detect conflicts

0 commit comments

Comments
 (0)