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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions Sources/OpenAPIKit/Components Object/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -441,10 +441,27 @@ extension OpenAPI.Components {
extension OpenAPI.Components {
#if ExternalLoading
internal mutating func externallyDereference<Loader: ExternalLoader>(with loader: Loader.Type, depth: ExternalDereferenceDepth = .iterations(1), context: [Loader.Message] = []) async throws -> [Loader.Message] {
if case let .iterations(number) = depth,
number <= 0 {
return context
var remainingIterations: Int? = if case .iterations(let number) = depth { number } else { nil }
var accumulatedMessages = context

while true {
if let remainingIterations, remainingIterations <= 0 {
return accumulatedMessages
}

let (noNewComponents, newMessages) = try await performExternalDereferencePass(with: loader, context: accumulatedMessages)
accumulatedMessages = newMessages

if noNewComponents { return accumulatedMessages }

remainingIterations = remainingIterations.map { $0 - 1 }
}
}

/// One pass of external dereferencing. Snapshot each component map, dereference it,
/// and merge any newly-loaded components into `self`. Returns `(noNewComponents, messages)`,
/// where `noNewComponents` is true when the pass loaded nothing new (i.e. dereferencing is complete).
private mutating func performExternalDereferencePass<Loader: ExternalLoader>(with loader: Loader.Type, context: [Loader.Message]) async throws -> (noNewComponents: Bool, messages: [Loader.Message]) {

// NOTE: The links and callbacks related code commented out below pushes Swift 5.8 and 5.9
// over the edge and you get exit code 137 crashes in CI.
Expand Down Expand Up @@ -522,7 +539,7 @@ extension OpenAPI.Components {

let newMessages = try await context + m1 + m2 + m3 + m4 + m5 + m6 + m7 + m8 + m9 + m10 + m11

if noNewComponents { return newMessages }
if noNewComponents { return (true, newMessages) }

try merge(c1Resolved)
try merge(c2Resolved)
Expand All @@ -536,12 +553,7 @@ extension OpenAPI.Components {
try merge(c10Resolved)
try merge(c11Resolved)

switch depth {
case .iterations(let number):
return try await externallyDereference(with: loader, depth: .iterations(number - 1), context: newMessages)
case .full:
return try await externallyDereference(with: loader, depth: .full, context: newMessages)
}
return (false, newMessages)
}
#endif
}
Expand Down
32 changes: 22 additions & 10 deletions Sources/OpenAPIKit30/Components Object/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,27 @@ extension OpenAPI.Components {
extension OpenAPI.Components {
#if ExternalLoading
internal mutating func externallyDereference<Loader: ExternalLoader>(with loader: Loader.Type, depth: ExternalDereferenceDepth = .iterations(1), context: [Loader.Message] = []) async throws -> [Loader.Message] {
if case let .iterations(number) = depth,
number <= 0 {
return context
var remainingIterations: Int? = if case .iterations(let number) = depth { number } else { nil }
var accumulatedMessages = context

while true {
if let remainingIterations, remainingIterations <= 0 {
return accumulatedMessages
}

let (noNewComponents, newMessages) = try await performExternalDereferencePass(with: loader, context: accumulatedMessages)
accumulatedMessages = newMessages

if noNewComponents { return accumulatedMessages }

remainingIterations = remainingIterations.map { $0 - 1 }
}
}

/// One pass of external dereferencing. Snapshot each component map, dereference it,
/// and merge any newly-loaded components into `self`. Returns `(noNewComponents, messages)`,
/// where `noNewComponents` is true when the pass loaded nothing new (i.e. dereferencing is complete).
private mutating func performExternalDereferencePass<Loader: ExternalLoader>(with loader: Loader.Type, context: [Loader.Message]) async throws -> (noNewComponents: Bool, messages: [Loader.Message]) {

// NOTE: The links and callbacks related code commented out below pushes Swift 5.8 and 5.9
// over the edge and you get exit code 137 crashes in CI.
Expand Down Expand Up @@ -394,7 +411,7 @@ extension OpenAPI.Components {

let newMessages = try await context + m1 + m2 + m3 + m4 + m5 + m6 + m7 + m8 + m9 + m10

if noNewComponents { return newMessages }
if noNewComponents { return (true, newMessages) }

try merge(c1Resolved)
try merge(c2Resolved)
Expand All @@ -407,12 +424,7 @@ extension OpenAPI.Components {
try merge(c9Resolved)
try merge(c10Resolved)

switch depth {
case .iterations(let number):
return try await externallyDereference(with: loader, depth: .iterations(number - 1), context: newMessages)
case .full:
return try await externallyDereference(with: loader, depth: .full, context: newMessages)
}
return (false, newMessages)
}
#endif
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,5 +353,60 @@ final class ExternalDereferencingDocumentTests: XCTestCase {
"file://./schemas/string_param.json#"]
)
}

func test_cyclicExternalReferencesConvergeAtFullDepth() async throws {
// Documents that A→B→A cycle: `.full` must converge, not infinite-loop.
// Merge-collision (detectCollision keeps old) discards re-loaded components.
struct CyclicLoader: ExternalLoader {
typealias Message = String

static func load<T>(_ url: URL) async throws -> (T, [Message]) where T: Decodable {
let key = try componentKey(type: T.self, at: url)
let files: [String: Data] = [
"schemas_a_json": """
{"type":"object","properties":{"next":{"$ref":"file://./schemas/b.json"}}}
""".data(using: .utf8)!,
"schemas_b_json": """
{"type":"object","properties":{"next":{"$ref":"file://./schemas/a.json"}}}
""".data(using: .utf8)!
]
let data = try XCTUnwrap(files[key.rawValue])
let decoded = try JSONDecoder().decode(T.self, from: data)
return (decoded, [url.absoluteString])
}

static func componentKey<T>(type: T.Type, at url: URL) throws -> OpenAPI.ComponentKey {
let urlString = url.pathComponents.dropFirst()
.joined(separator: "_")
.replacingOccurrences(of: ".", with: "_")
return try .forceInit(rawValue: urlString)
}
}

let document = OpenAPI.Document(
info: .init(title: "cyclic test", version: "1.0.0"),
servers: [],
paths: [:],
components: .init(
schemas: ["entry": .reference(.external(URL(string: "file://./schemas/a.json")!))]
)
)

var docCopy = document
_ = try await docCopy.externallyDereference(with: CyclicLoader.self, depth: .full)

// .full converged despite the A→B→A cycle.
let aSchema = try XCTUnwrap(docCopy.components.schemas["schemas_a_json"])
let bSchema = try XCTUnwrap(docCopy.components.schemas["schemas_b_json"])

// Both schemas' external refs resolved to internal references.
guard case .object(_, let aCtx) = aSchema.value,
case .object(_, let bCtx) = bSchema.value else {
XCTFail("expected both schemas to be .object")
return
}
XCTAssertTrue(try XCTUnwrap(aCtx.properties["next"]).isReference)
XCTAssertTrue(try XCTUnwrap(bCtx.properties["next"]).isReference)
}
}
#endif