From 57106cfb1fa472a253958045abe9296ce73c3e8d Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sun, 22 Mar 2026 03:57:42 +0530 Subject: [PATCH 01/13] Support local dereferencing of schema anchors --- .../Components+JSONReference.swift | 27 ++ .../Document/DereferencedDocument.swift | 6 +- .../Document/Document+LocalAnchors.swift | 402 ++++++++++++++++++ Sources/OpenAPIKit/JSONReference.swift | 24 ++ .../Document/DereferencedDocumentTests.swift | 57 +++ .../OpenAPIKitTests/JSONReferenceTests.swift | 13 + 6 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 Sources/OpenAPIKit/Document/Document+LocalAnchors.swift diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index 40afdae29f..e17956bf6a 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -53,6 +53,11 @@ extension OpenAPI.Components { /// reference is itself another reference (e.g. entries in the `responses` /// dictionary are allowed to be references). public func contains(_ reference: JSONReference.InternalReference) -> Bool { + if case .anchor(name: let anchorName) = reference, + ReferenceType.self == JSONSchema.self { + return localAnchorSchema(named: anchorName) != nil + } + switch ReferenceType.openAPIComponentsKeyPath { case .a(let directPath): return reference.name @@ -317,6 +322,15 @@ extension OpenAPI.Components { /// - Throws: `ReferenceError.cannotLookupRemoteReference` or /// `ReferenceError.missingOnLookup(name:,key:)` public func lookupOnce(_ reference: JSONReference.InternalReference) throws -> Either, ReferenceType> { + if case .anchor(name: let anchorName) = reference, + ReferenceType.self == JSONSchema.self { + guard let schema = localAnchorSchema(named: anchorName) else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + + return .b(schema as! ReferenceType) + } + let value: Either, ReferenceType>? switch ReferenceType.openAPIComponentsKeyPath { case .a(let directPath): @@ -364,6 +378,19 @@ extension OpenAPI.Components { throw ReferenceCycleError(ref: reference.rawValue) } + if case .anchor(name: let anchorName) = reference, + ReferenceType.self == JSONSchema.self { + guard let schema = localAnchorSchema(named: anchorName) else { + throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) + } + + if case let .reference(newReference, _) = schema.value { + return try _lookup(newReference, following: visitedReferences.union([reference])) as! ReferenceType + } + + return schema as! ReferenceType + } + switch ReferenceType.openAPIComponentsKeyPath { case .a(let directPath): let value: ReferenceType? = reference.name diff --git a/Sources/OpenAPIKit/Document/DereferencedDocument.swift b/Sources/OpenAPIKit/Document/DereferencedDocument.swift index ca9badc550..61205f4c26 100644 --- a/Sources/OpenAPIKit/Document/DereferencedDocument.swift +++ b/Sources/OpenAPIKit/Document/DereferencedDocument.swift @@ -48,9 +48,11 @@ public struct DereferencedDocument: Equatable { /// on whether an unresolvable reference points to another file or just points to a /// component in the same file that cannot be found in the Components Object. internal init(_ document: OpenAPI.Document) throws { + let components = document.locallyDereferenceableComponents + self.paths = try document.paths.mapValues { try $0._dereferenced( - in: document.components, + in: components, following: [], dereferencedFromComponentNamed: nil ) @@ -58,7 +60,7 @@ public struct DereferencedDocument: Equatable { self.security = try document.security.map { try DereferencedSecurityRequirement( $0, - resolvingIn: document.components, + resolvingIn: components, following: [] ) } diff --git a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift new file mode 100644 index 0000000000..842e02aae7 --- /dev/null +++ b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift @@ -0,0 +1,402 @@ +// +// Document+LocalAnchors.swift +// + +import OpenAPIKitCore + +extension OpenAPI.Document { + internal var locallyDereferenceableComponents: OpenAPI.Components { + var components = self.components + var anchors: OrderedDictionary = [:] + + collectLocalAnchorSchemas(into: &anchors) + + for (anchor, schema) in anchors { + components.registerLocalAnchorSchema(schema, named: anchor) + } + + return components + } + + private func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + components.collectLocalAnchorSchemas(into: &anchors) + + for pathItem in paths.values { + guard case .b(let pathItem) = pathItem else { + continue + } + pathItem.collectLocalAnchorSchemas(into: &anchors) + } + + for webhook in webhooks.values { + guard case .b(let pathItem) = webhook else { + continue + } + pathItem.collectLocalAnchorSchemas(into: &anchors) + } + } +} + +extension OpenAPI.Components { + internal static let localAnchorVendorExtension = "x-openapikit-local-anchor" + + internal mutating func registerLocalAnchorSchema( + _ schema: JSONSchema, + named anchor: String + ) { + var collisionIndex = 0 + + while true { + let componentKey = Self.localAnchorComponentKey( + for: anchor, + collisionIndex: collisionIndex + ) + + if let existingSchema = schemas[componentKey] { + if existingSchema.localAnchorName == anchor { + return + } + + collisionIndex += 1 + continue + } + + schemas[componentKey] = schema.markedAsLocalAnchor(named: anchor) + return + } + } + + internal func localAnchorSchema(named anchor: String) -> JSONSchema? { + var collisionIndex = 0 + + while true { + let componentKey = Self.localAnchorComponentKey( + for: anchor, + collisionIndex: collisionIndex + ) + + guard let schema = schemas[componentKey] else { + return nil + } + + if schema.localAnchorName == anchor { + return schema.removingLocalAnchorMarker() + } + + collisionIndex += 1 + } + } + + internal static func localAnchorComponentKey( + for anchor: String, + collisionIndex: Int + ) -> OpenAPI.ComponentKey { + let encodedAnchor = anchor.utf8 + .flatMap(Self.hexDigits(for:)) + .map(String.init) + .joined() + let rawValue = "__openapikit_anchor_\(collisionIndex)_\(encodedAnchor)" + + return OpenAPI.ComponentKey(rawValue: rawValue)! + } + + private static func hexDigits(for byte: UInt8) -> [Character] { + let digits = Array("0123456789abcdef") + return [ + digits[Int(byte / 16)], + digits[Int(byte % 16)] + ] + } + + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for schema in schemas.values { + schema.collectLocalAnchorSchemas(into: &anchors) + } + + for parameter in parameters.values { + switch parameter { + case .a: + continue + case .b(let parameter): + parameter.collectLocalAnchorSchemas(into: &anchors) + } + } + + for request in requestBodies.values { + switch request { + case .a: + continue + case .b(let request): + request.collectLocalAnchorSchemas(into: &anchors) + } + } + + for response in responses.values { + switch response { + case .a: + continue + case .b(let response): + response.collectLocalAnchorSchemas(into: &anchors) + } + } + + for header in headers.values { + switch header { + case .a: + continue + case .b(let header): + header.collectLocalAnchorSchemas(into: &anchors) + } + } + + for pathItem in pathItems.values { + pathItem.collectLocalAnchorSchemas(into: &anchors) + } + + for callbacks in callbacks.values { + switch callbacks { + case .a: + continue + case .b(let callbacks): + callbacks.collectLocalAnchorSchemas(into: &anchors) + } + } + } +} + +extension OpenAPI.PathItem { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for parameter in parameters { + switch parameter { + case .a: + continue + case .b(let parameter): + parameter.collectLocalAnchorSchemas(into: &anchors) + } + } + + for endpoint in endpoints { + endpoint.operation.collectLocalAnchorSchemas(into: &anchors) + } + } +} + +extension OpenAPI.Operation { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for parameter in parameters { + switch parameter { + case .a: + continue + case .b(let parameter): + parameter.collectLocalAnchorSchemas(into: &anchors) + } + } + + if case .some(.b(let requestBody)) = requestBody { + requestBody.collectLocalAnchorSchemas(into: &anchors) + } + + for response in responses.values { + switch response { + case .a: + continue + case .b(let response): + response.collectLocalAnchorSchemas(into: &anchors) + } + } + + for callbacks in callbacks.values { + switch callbacks { + case .a: + continue + case .b(let callbacks): + callbacks.collectLocalAnchorSchemas(into: &anchors) + } + } + } +} + +extension OpenAPI.Callbacks { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for pathItem in values { + switch pathItem { + case .a: + continue + case .b(let pathItem): + pathItem.collectLocalAnchorSchemas(into: &anchors) + } + } + } +} + +extension OpenAPI.Parameter { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + switch schemaOrContent { + case .a(let schemaContext): + schemaContext.collectLocalAnchorSchemas(into: &anchors) + case .b(let content): + content.collectLocalAnchorSchemas(into: &anchors) + } + } +} + +extension OpenAPI.Parameter.SchemaContext { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + switch schema { + case .a: + break + case .b(let schema): + schema.collectLocalAnchorSchemas(into: &anchors) + } + } +} + +extension OpenAPI.Request { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + content.collectLocalAnchorSchemas(into: &anchors) + } +} + +extension OpenAPI.Response { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + headers?.collectLocalAnchorSchemas(into: &anchors) + content.collectLocalAnchorSchemas(into: &anchors) + } +} + +extension OpenAPI.Header { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + switch schemaOrContent { + case .a(let schemaContext): + schemaContext.collectLocalAnchorSchemas(into: &anchors) + case .b(let content): + content.collectLocalAnchorSchemas(into: &anchors) + } + } +} + +extension OrderedDictionary where Key == OpenAPI.ContentType, Value == Either, OpenAPI.Content> { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for content in values { + switch content { + case .a: + continue + case .b(let content): + content.collectLocalAnchorSchemas(into: &anchors) + } + } + } +} + +extension OpenAPI.Content { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + schema?.collectLocalAnchorSchemas(into: &anchors) + itemSchema?.collectLocalAnchorSchemas(into: &anchors) + } +} + +extension OrderedDictionary where Key == String, Value == Either, OpenAPI.Header> { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for header in values { + switch header { + case .a: + continue + case .b(let header): + header.collectLocalAnchorSchemas(into: &anchors) + } + } + } +} + +extension JSONSchema { + fileprivate var localAnchorName: String? { + vendorExtensions[OpenAPI.Components.localAnchorVendorExtension]?.value as? String + } + + fileprivate func markedAsLocalAnchor(named anchor: String) -> JSONSchema { + var extensions = vendorExtensions + extensions[OpenAPI.Components.localAnchorVendorExtension] = .init(anchor) + return with(vendorExtensions: extensions) + } + + fileprivate func removingLocalAnchorMarker() -> JSONSchema { + guard localAnchorName != nil else { + return self + } + + var extensions = vendorExtensions + extensions.removeValue(forKey: OpenAPI.Components.localAnchorVendorExtension) + return with(vendorExtensions: extensions) + } + + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + if let anchor, anchors[anchor] == nil { + anchors[anchor] = self + } + + for definition in defs.values { + definition.collectLocalAnchorSchemas(into: &anchors) + } + + switch value { + case .object(_, let objectContext): + for property in objectContext.properties.values { + property.collectLocalAnchorSchemas(into: &anchors) + } + + if case .b(let additionalProperties) = objectContext.additionalProperties { + additionalProperties.collectLocalAnchorSchemas(into: &anchors) + } + + case .array(_, let arrayContext): + arrayContext.items?.collectLocalAnchorSchemas(into: &anchors) + + case .all(of: let schemas, core: _), + .one(of: let schemas, core: _), + .any(of: let schemas, core: _): + for schema in schemas { + schema.collectLocalAnchorSchemas(into: &anchors) + } + + case .not(let schema, core: _): + schema.collectLocalAnchorSchemas(into: &anchors) + + case .null, + .boolean, + .number, + .integer, + .string, + .reference, + .fragment: + break + } + } +} diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index d89de99599..614183882d 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -68,6 +68,16 @@ public enum JSONReference: Equatabl return .internal(.path(path)) } + /// Reference a local JSON Schema anchor in this file. + /// + /// Example: + /// + /// JSONReference.anchor(named: "greetings") + /// // encoded string: "#greetings" + public static func anchor(named name: String) -> Self { + return .internal(.anchor(name: name)) + } + /// `true` for internal references, `false` for /// external references (i.e. to another file). public var isInternal: Bool { @@ -129,6 +139,8 @@ public enum JSONReference: Equatabl public enum InternalReference: LosslessStringConvertible, RawRepresentable, Equatable, Hashable, Sendable { /// The reference refers to a component (i.e. `#/components/...`). case component(name: String) + /// The reference refers to a local anchor (i.e. `#myAnchor`). + case anchor(name: String) /// The reference refers to some path outside the Components Object. case path(Path) @@ -147,6 +159,8 @@ public enum JSONReference: Equatabl switch self { case .component(name: let name): return name + case .anchor(name: let name): + return name case .path(let path): return path.components.last?.stringValue } @@ -165,6 +179,14 @@ public enum JSONReference: Equatabl return nil } let fragment = rawValue.dropFirst() + guard !fragment.isEmpty else { + self = .path(Path(rawValue: "")) + return + } + guard fragment.first == "/" else { + self = .anchor(name: String(fragment)) + return + } guard fragment.starts(with: "/components") else { self = .path(Path(rawValue: String(fragment))) return @@ -190,6 +212,8 @@ public enum JSONReference: Equatabl switch self { case .component(name: let name): return "#/components/\(ReferenceType.openAPIComponentsKey)/\(name)" + case .anchor(name: let name): + return "#\(name)" case .path(let path): return "#\(path.rawValue)" } diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index bed631ae73..6d5ecc4e89 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -124,4 +124,61 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(t1.security.count, 1) XCTAssertEqual(t1.security.first?.schemes["test"]?.securityScheme.type, .apiKey(name: "Api-Key", location: .header)) } + + func test_locallyDereferencedResolvesSchemaAnchorReferences() throws { + let anchoredChild = JSONSchema.string( + .init(anchor: "nameAnchor"), + .init() + ) + let anchoredSchema = JSONSchema.object( + properties: [ + "name": .reference(.anchor(named: "nameAnchor")) + ], + defs: [ + "nameDefinition": anchoredChild + ] + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .pathItem( + .init( + get: .init( + responses: [ + 200: .response( + description: "success", + content: [ + OpenAPI.ContentType.json: .content( + .init( + schema: .reference(.component(named: "anchoredSchema")) + ) + ) + ] + ) + ] + ) + ) + ) + ], + components: .direct( + schemas: [ + "anchoredSchema": anchoredSchema + ] + ) + ) + + let dereferencedDocument = try document.locallyDereferenced() + let schema = dereferencedDocument + .paths["/hello"]? + .get? + .responses[status: 200]? + .content[OpenAPI.ContentType.json]? + .schema + + let nameSchema = schema?.objectContext?.properties["name"] + XCTAssertEqual(nameSchema?.jsonType, .string) + XCTAssertEqual(nameSchema?.anchor, "nameAnchor") + } } diff --git a/Tests/OpenAPIKitTests/JSONReferenceTests.swift b/Tests/OpenAPIKitTests/JSONReferenceTests.swift index fc0f5447d7..0fd0334f55 100644 --- a/Tests/OpenAPIKitTests/JSONReferenceTests.swift +++ b/Tests/OpenAPIKitTests/JSONReferenceTests.swift @@ -42,6 +42,10 @@ final class JSONReferenceTests: XCTestCase { let t19 = JSONReference.InternalReference.component(name: "hello") XCTAssertEqual(t18, t19) + let t20 = JSONReference.InternalReference("#hello") + let t21 = JSONReference.InternalReference.anchor(name: "hello") + XCTAssertEqual(t20, t21) + let t7: JSONReference.Path = [ "hello", "world" @@ -98,6 +102,15 @@ final class JSONReferenceTests: XCTestCase { XCTAssertEqual(t5.rawValue, "#/hello/there") XCTAssertEqual(t5.description, "#/hello/there") + let t5a = JSONReference.anchor(named: "hello") + XCTAssertEqual(t5a.name, "hello") + XCTAssertEqual(t5a.absoluteString, "#hello") + + let t5b = JSONReference.InternalReference.anchor(name: "hello") + XCTAssertEqual(t5b.name, "hello") + XCTAssertEqual(t5b.rawValue, "#hello") + XCTAssertEqual(t5b.description, "#hello") + let t6 = JSONReference.Path("/hello/there") XCTAssertEqual(t6.components, ["hello", "there"]) XCTAssertEqual(t6.rawValue, "/hello/there") From 4401255c73c57b9b4a202baa6d3ddec720b17cfc Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sun, 22 Mar 2026 04:11:16 +0530 Subject: [PATCH 02/13] Stabilize code coverage job --- .github/workflows/codecov.yml | 2 +- Sources/OpenAPIKit/Document/DocumentInfo.swift | 2 +- Sources/OpenAPIKit30/Document/DocumentInfo.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index f64ad6f3bc..629e204cf3 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - run: swift test --enable-test-discovery --enable-code-coverage + - run: swift test --enable-test-discovery --enable-code-coverage -j 1 --parallel --num-workers 1 - id: analysis uses: mattpolzin/swift-codecov-action@0.7.5 with: diff --git a/Sources/OpenAPIKit/Document/DocumentInfo.swift b/Sources/OpenAPIKit/Document/DocumentInfo.swift index 78c200aacf..692d75863c 100644 --- a/Sources/OpenAPIKit/Document/DocumentInfo.swift +++ b/Sources/OpenAPIKit/Document/DocumentInfo.swift @@ -7,7 +7,7 @@ import OpenAPIKitCore #if canImport(FoundationEssentials) import FoundationEssentials #else -import Foundation +@preconcurrency import Foundation #endif extension OpenAPI.Document { diff --git a/Sources/OpenAPIKit30/Document/DocumentInfo.swift b/Sources/OpenAPIKit30/Document/DocumentInfo.swift index 0d123ae817..0dbf2a756b 100644 --- a/Sources/OpenAPIKit30/Document/DocumentInfo.swift +++ b/Sources/OpenAPIKit30/Document/DocumentInfo.swift @@ -7,7 +7,7 @@ import OpenAPIKitCore #if canImport(FoundationEssentials) import FoundationEssentials #else -import Foundation +@preconcurrency import Foundation #endif extension OpenAPI.Document { From d50d7a75d5f08266f8b35344ab5e723c4b0a8ddb Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sun, 22 Mar 2026 01:51:27 +0530 Subject: [PATCH 03/13] Avoid async fan-out in path item dereferencing --- .../Path Item/DereferencedPathItem.swift | 126 ++++++++---------- .../Path Item/DereferencedPathItem.swift | 104 +++++++-------- 2 files changed, 104 insertions(+), 126 deletions(-) diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 8bddb1ab75..5f0c42304c 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -153,80 +153,68 @@ extension OpenAPI.PathItem: LocallyDereferenceable { extension OpenAPI.PathItem: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - let oldParameters = parameters - let oldServers = servers - let oldGet = get - let oldPut = put - let oldPost = post - let oldDelete = delete - let oldOptions = options - let oldHead = head - let oldPatch = patch - let oldTrace = trace - let oldQuery = query - - let oldAdditionalOperations = additionalOperations - - async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) -// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) - async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) - async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) - async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) - async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) - async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) - async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) - async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) - async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) - - async let (newAdditionalOperations, c12, m12) = oldAdditionalOperations.externallyDereferenced(with: loader) - + async let (newParameters, c1, m1) = parameters.externallyDereferenced(with: loader) var pathItem = self var newComponents = try await c1 var newMessages = try await m1 - // ideally we would async let all of the props above and then set them here, - // but for now since there seems to be some sort of compiler bug we will do - // newServers in an if let below pathItem.parameters = try await newParameters - pathItem.get = try await newGet - pathItem.put = try await newPut - pathItem.post = try await newPost - pathItem.delete = try await newDelete - pathItem.options = try await newOptions - pathItem.head = try await newHead - pathItem.patch = try await newPatch - pathItem.trace = try await newTrace - pathItem.query = try await newQuery - pathItem.additionalOperations = try await newAdditionalOperations - - try await newComponents.merge(c3) - try await newComponents.merge(c4) - try await newComponents.merge(c5) - try await newComponents.merge(c6) - try await newComponents.merge(c7) - try await newComponents.merge(c8) - try await newComponents.merge(c9) - try await newComponents.merge(c10) - try await newComponents.merge(c11) - try await newComponents.merge(c12) - - try await newMessages += m3 - try await newMessages += m4 - try await newMessages += m5 - try await newMessages += m6 - try await newMessages += m7 - try await newMessages += m8 - try await newMessages += m9 - try await newMessages += m10 - try await newMessages += m11 - try await newMessages += m12 - - if let oldServers { - async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - pathItem.servers = try await newServers - try await newComponents.merge(c2) - try await newMessages += m2 + + let (newGet, c2, m2) = try await get.externallyDereferenced(with: loader) + pathItem.get = newGet + try newComponents.merge(c2) + newMessages += m2 + + let (newPut, c3, m3) = try await put.externallyDereferenced(with: loader) + pathItem.put = newPut + try newComponents.merge(c3) + newMessages += m3 + + let (newPost, c4, m4) = try await post.externallyDereferenced(with: loader) + pathItem.post = newPost + try newComponents.merge(c4) + newMessages += m4 + + let (newDelete, c5, m5) = try await delete.externallyDereferenced(with: loader) + pathItem.delete = newDelete + try newComponents.merge(c5) + newMessages += m5 + + let (newOptions, c6, m6) = try await options.externallyDereferenced(with: loader) + pathItem.options = newOptions + try newComponents.merge(c6) + newMessages += m6 + + let (newHead, c7, m7) = try await head.externallyDereferenced(with: loader) + pathItem.head = newHead + try newComponents.merge(c7) + newMessages += m7 + + let (newPatch, c8, m8) = try await patch.externallyDereferenced(with: loader) + pathItem.patch = newPatch + try newComponents.merge(c8) + newMessages += m8 + + let (newTrace, c9, m9) = try await trace.externallyDereferenced(with: loader) + pathItem.trace = newTrace + try newComponents.merge(c9) + newMessages += m9 + + let (newQuery, c10, m10) = try await query.externallyDereferenced(with: loader) + pathItem.query = newQuery + try newComponents.merge(c10) + newMessages += m10 + + let (newAdditionalOperations, c11, m11) = try await additionalOperations.externallyDereferenced(with: loader) + pathItem.additionalOperations = newAdditionalOperations + try newComponents.merge(c11) + newMessages += m11 + + if let servers { + let (newServers, c12, m12) = try await servers.externallyDereferenced(with: loader) + pathItem.servers = newServers + try newComponents.merge(c12) + newMessages += m12 } return (pathItem, newComponents, newMessages) diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index ae9d6e3be4..16e10ba298 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -142,68 +142,58 @@ extension OpenAPI.PathItem: LocallyDereferenceable { extension OpenAPI.PathItem: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - let oldParameters = parameters - let oldServers = servers - let oldGet = get - let oldPut = put - let oldPost = post - let oldDelete = delete - let oldOptions = options - let oldHead = head - let oldPatch = patch - let oldTrace = trace - - async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) -// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) - async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) - async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) - async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) - async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) - async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) - async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) - async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) - + async let (newParameters, c1, m1) = parameters.externallyDereferenced(with: loader) var pathItem = self var newComponents = try await c1 var newMessages = try await m1 - // ideally we would async let all of the props above and then set them here, - // but for now since there seems to be some sort of compiler bug we will do - // newServers in an if let below pathItem.parameters = try await newParameters - pathItem.get = try await newGet - pathItem.put = try await newPut - pathItem.post = try await newPost - pathItem.delete = try await newDelete - pathItem.options = try await newOptions - pathItem.head = try await newHead - pathItem.patch = try await newPatch - pathItem.trace = try await newTrace - - try await newComponents.merge(c3) - try await newComponents.merge(c4) - try await newComponents.merge(c5) - try await newComponents.merge(c6) - try await newComponents.merge(c7) - try await newComponents.merge(c8) - try await newComponents.merge(c9) - try await newComponents.merge(c10) - - try await newMessages += m3 - try await newMessages += m4 - try await newMessages += m5 - try await newMessages += m6 - try await newMessages += m7 - try await newMessages += m8 - try await newMessages += m9 - try await newMessages += m10 - - if let oldServers { - async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) - pathItem.servers = try await newServers - try await newComponents.merge(c2) - try await newMessages += m2 + + let (newGet, c2, m2) = try await get.externallyDereferenced(with: loader) + pathItem.get = newGet + try newComponents.merge(c2) + newMessages += m2 + + let (newPut, c3, m3) = try await put.externallyDereferenced(with: loader) + pathItem.put = newPut + try newComponents.merge(c3) + newMessages += m3 + + let (newPost, c4, m4) = try await post.externallyDereferenced(with: loader) + pathItem.post = newPost + try newComponents.merge(c4) + newMessages += m4 + + let (newDelete, c5, m5) = try await delete.externallyDereferenced(with: loader) + pathItem.delete = newDelete + try newComponents.merge(c5) + newMessages += m5 + + let (newOptions, c6, m6) = try await options.externallyDereferenced(with: loader) + pathItem.options = newOptions + try newComponents.merge(c6) + newMessages += m6 + + let (newHead, c7, m7) = try await head.externallyDereferenced(with: loader) + pathItem.head = newHead + try newComponents.merge(c7) + newMessages += m7 + + let (newPatch, c8, m8) = try await patch.externallyDereferenced(with: loader) + pathItem.patch = newPatch + try newComponents.merge(c8) + newMessages += m8 + + let (newTrace, c9, m9) = try await trace.externallyDereferenced(with: loader) + pathItem.trace = newTrace + try newComponents.merge(c9) + newMessages += m9 + + if let servers { + let (newServers, c10, m10) = try await servers.externallyDereferenced(with: loader) + pathItem.servers = newServers + try newComponents.merge(c10) + newMessages += m10 } return (pathItem, newComponents, newMessages) From ce0c17fd165ee778e8c6062a5f616f5e6fe316ff Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Sun, 22 Mar 2026 01:58:33 +0530 Subject: [PATCH 04/13] Avoid concurrent external loads in collections --- .../Array+ExternallyDereferenceable.swift | 30 +++++++------------ ...Dictionary+ExternallyDereferenceable.swift | 27 +++++++---------- ...Dictionary+ExternallyDereferenceable.swift | 30 +++++++------------ .../Array+ExternallyDereferenceable.swift | 30 +++++++------------ ...Dictionary+ExternallyDereferenceable.swift | 27 +++++++---------- ...Dictionary+ExternallyDereferenceable.swift | 30 +++++++------------ 6 files changed, 62 insertions(+), 112 deletions(-) diff --git a/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift index ca1f3dd542..ed7f7c7a47 100644 --- a/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift @@ -7,26 +7,18 @@ import OpenAPIKitCore extension Array where Element: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Int, (Element, OpenAPI.Components, [Loader.Message])).self) { group in - for (idx, elem) in zip(self.indices, self) { - group.addTask { - return try await (idx, elem.externallyDereferenced(with: loader)) - } - } + var newElements = Self() + newElements.reserveCapacity(count) + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newElems = Array<(Int, Element)>() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() - - for try await (idx, (elem, components, messages)) in group { - newElems.append((idx, elem)) - try newComponents.merge(components) - newMessages += messages - } - // things may come in out of order because of concurrency - // so we reorder after completing all entries. - newElems.sort { left, right in left.0 < right.0 } - return (newElems.map { $0.1 }, newComponents, newMessages) + for element in self { + let (newElement, components, messages) = try await element.externallyDereferenced(with: loader) + newElements.append(newElement) + try newComponents.merge(components) + newMessages += messages } + + return (newElements, newComponents, newMessages) } } diff --git a/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift index 0bc061a0f3..e101bc194b 100644 --- a/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift @@ -8,24 +8,17 @@ import OpenAPIKitCore extension Dictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in - for (key, value) in self { - group.addTask { - let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) - return (key, newRef, components, messages) - } - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessage = [Loader.Message]() - - for try await (key, newRef, components, messages) in group { - newDict[key] = newRef - try newComponents.merge(components) - newMessage += messages - } - return (newDict, newComponents, newMessage) + for (key, value) in self { + let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) + newDict[key] = newValue + try newComponents.merge(components) + newMessages += messages } + + return (newDict, newComponents, newMessages) } } diff --git a/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift index 66e78ab4f2..0447a2d309 100644 --- a/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift @@ -10,27 +10,17 @@ import OpenAPIKitCore extension OrderedDictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in - for (key, value) in self { - group.addTask { - let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) - return (key, newRef, components, messages) - } - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() - - for try await (key, newRef, components, messages) in group { - newDict[key] = newRef - try newComponents.merge(components) - newMessages += messages - } - // things may come in out of order because of concurrency - // so we reorder after completing all entries. - try newDict.applyOrder(self) - return (newDict, newComponents, newMessages) + for (key, value) in self { + let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) + newDict[key] = newValue + try newComponents.merge(components) + newMessages += messages } + + return (newDict, newComponents, newMessages) } } diff --git a/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift index ca1f3dd542..ed7f7c7a47 100644 --- a/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift @@ -7,26 +7,18 @@ import OpenAPIKitCore extension Array where Element: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Int, (Element, OpenAPI.Components, [Loader.Message])).self) { group in - for (idx, elem) in zip(self.indices, self) { - group.addTask { - return try await (idx, elem.externallyDereferenced(with: loader)) - } - } + var newElements = Self() + newElements.reserveCapacity(count) + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newElems = Array<(Int, Element)>() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() - - for try await (idx, (elem, components, messages)) in group { - newElems.append((idx, elem)) - try newComponents.merge(components) - newMessages += messages - } - // things may come in out of order because of concurrency - // so we reorder after completing all entries. - newElems.sort { left, right in left.0 < right.0 } - return (newElems.map { $0.1 }, newComponents, newMessages) + for element in self { + let (newElement, components, messages) = try await element.externallyDereferenced(with: loader) + newElements.append(newElement) + try newComponents.merge(components) + newMessages += messages } + + return (newElements, newComponents, newMessages) } } diff --git a/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift index 16ddfcd8d8..e101bc194b 100644 --- a/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift @@ -8,24 +8,17 @@ import OpenAPIKitCore extension Dictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in - for (key, value) in self { - group.addTask { - let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) - return (key, newRef, components, messages) - } - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() - - for try await (key, newRef, components, messages) in group { - newDict[key] = newRef - try newComponents.merge(components) - newMessages += messages - } - return (newDict, newComponents, newMessages) + for (key, value) in self { + let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) + newDict[key] = newValue + try newComponents.merge(components) + newMessages += messages } + + return (newDict, newComponents, newMessages) } } diff --git a/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift index 66e78ab4f2..0447a2d309 100644 --- a/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift @@ -10,27 +10,17 @@ import OpenAPIKitCore extension OrderedDictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in - for (key, value) in self { - group.addTask { - let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) - return (key, newRef, components, messages) - } - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() - - for try await (key, newRef, components, messages) in group { - newDict[key] = newRef - try newComponents.merge(components) - newMessages += messages - } - // things may come in out of order because of concurrency - // so we reorder after completing all entries. - try newDict.applyOrder(self) - return (newDict, newComponents, newMessages) + for (key, value) in self { + let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) + newDict[key] = newValue + try newComponents.merge(components) + newMessages += messages } + + return (newDict, newComponents, newMessages) } } From 6f7fa0f8ac07e5d0a9d04c1326ff45333d072250 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Wed, 1 Apr 2026 01:42:18 +0530 Subject: [PATCH 05/13] Remove unrelated dereferencing workaround changes --- .github/workflows/codecov.yml | 2 +- .../OpenAPIKit/Document/DocumentInfo.swift | 2 +- .../Path Item/DereferencedPathItem.swift | 126 ++++++++++-------- .../Array+ExternallyDereferenceable.swift | 30 +++-- ...Dictionary+ExternallyDereferenceable.swift | 27 ++-- ...Dictionary+ExternallyDereferenceable.swift | 30 +++-- .../OpenAPIKit30/Document/DocumentInfo.swift | 2 +- .../Path Item/DereferencedPathItem.swift | 104 ++++++++------- .../Array+ExternallyDereferenceable.swift | 30 +++-- ...Dictionary+ExternallyDereferenceable.swift | 27 ++-- ...Dictionary+ExternallyDereferenceable.swift | 30 +++-- 11 files changed, 241 insertions(+), 169 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 629e204cf3..f64ad6f3bc 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - run: swift test --enable-test-discovery --enable-code-coverage -j 1 --parallel --num-workers 1 + - run: swift test --enable-test-discovery --enable-code-coverage - id: analysis uses: mattpolzin/swift-codecov-action@0.7.5 with: diff --git a/Sources/OpenAPIKit/Document/DocumentInfo.swift b/Sources/OpenAPIKit/Document/DocumentInfo.swift index 692d75863c..78c200aacf 100644 --- a/Sources/OpenAPIKit/Document/DocumentInfo.swift +++ b/Sources/OpenAPIKit/Document/DocumentInfo.swift @@ -7,7 +7,7 @@ import OpenAPIKitCore #if canImport(FoundationEssentials) import FoundationEssentials #else -@preconcurrency import Foundation +import Foundation #endif extension OpenAPI.Document { diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 5f0c42304c..8bddb1ab75 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -153,68 +153,80 @@ extension OpenAPI.PathItem: LocallyDereferenceable { extension OpenAPI.PathItem: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - async let (newParameters, c1, m1) = parameters.externallyDereferenced(with: loader) + let oldParameters = parameters + let oldServers = servers + let oldGet = get + let oldPut = put + let oldPost = post + let oldDelete = delete + let oldOptions = options + let oldHead = head + let oldPatch = patch + let oldTrace = trace + let oldQuery = query + + let oldAdditionalOperations = additionalOperations + + async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) +// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) + async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) + async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) + async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) + async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) + async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) + async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) + async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) + async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) + async let (newQuery, c11, m11) = oldQuery.externallyDereferenced(with: loader) + + async let (newAdditionalOperations, c12, m12) = oldAdditionalOperations.externallyDereferenced(with: loader) + var pathItem = self var newComponents = try await c1 var newMessages = try await m1 + // ideally we would async let all of the props above and then set them here, + // but for now since there seems to be some sort of compiler bug we will do + // newServers in an if let below pathItem.parameters = try await newParameters - - let (newGet, c2, m2) = try await get.externallyDereferenced(with: loader) - pathItem.get = newGet - try newComponents.merge(c2) - newMessages += m2 - - let (newPut, c3, m3) = try await put.externallyDereferenced(with: loader) - pathItem.put = newPut - try newComponents.merge(c3) - newMessages += m3 - - let (newPost, c4, m4) = try await post.externallyDereferenced(with: loader) - pathItem.post = newPost - try newComponents.merge(c4) - newMessages += m4 - - let (newDelete, c5, m5) = try await delete.externallyDereferenced(with: loader) - pathItem.delete = newDelete - try newComponents.merge(c5) - newMessages += m5 - - let (newOptions, c6, m6) = try await options.externallyDereferenced(with: loader) - pathItem.options = newOptions - try newComponents.merge(c6) - newMessages += m6 - - let (newHead, c7, m7) = try await head.externallyDereferenced(with: loader) - pathItem.head = newHead - try newComponents.merge(c7) - newMessages += m7 - - let (newPatch, c8, m8) = try await patch.externallyDereferenced(with: loader) - pathItem.patch = newPatch - try newComponents.merge(c8) - newMessages += m8 - - let (newTrace, c9, m9) = try await trace.externallyDereferenced(with: loader) - pathItem.trace = newTrace - try newComponents.merge(c9) - newMessages += m9 - - let (newQuery, c10, m10) = try await query.externallyDereferenced(with: loader) - pathItem.query = newQuery - try newComponents.merge(c10) - newMessages += m10 - - let (newAdditionalOperations, c11, m11) = try await additionalOperations.externallyDereferenced(with: loader) - pathItem.additionalOperations = newAdditionalOperations - try newComponents.merge(c11) - newMessages += m11 - - if let servers { - let (newServers, c12, m12) = try await servers.externallyDereferenced(with: loader) - pathItem.servers = newServers - try newComponents.merge(c12) - newMessages += m12 + pathItem.get = try await newGet + pathItem.put = try await newPut + pathItem.post = try await newPost + pathItem.delete = try await newDelete + pathItem.options = try await newOptions + pathItem.head = try await newHead + pathItem.patch = try await newPatch + pathItem.trace = try await newTrace + pathItem.query = try await newQuery + pathItem.additionalOperations = try await newAdditionalOperations + + try await newComponents.merge(c3) + try await newComponents.merge(c4) + try await newComponents.merge(c5) + try await newComponents.merge(c6) + try await newComponents.merge(c7) + try await newComponents.merge(c8) + try await newComponents.merge(c9) + try await newComponents.merge(c10) + try await newComponents.merge(c11) + try await newComponents.merge(c12) + + try await newMessages += m3 + try await newMessages += m4 + try await newMessages += m5 + try await newMessages += m6 + try await newMessages += m7 + try await newMessages += m8 + try await newMessages += m9 + try await newMessages += m10 + try await newMessages += m11 + try await newMessages += m12 + + if let oldServers { + async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) + pathItem.servers = try await newServers + try await newComponents.merge(c2) + try await newMessages += m2 } return (pathItem, newComponents, newMessages) diff --git a/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift index ed7f7c7a47..ca1f3dd542 100644 --- a/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift @@ -7,18 +7,26 @@ import OpenAPIKitCore extension Array where Element: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newElements = Self() - newElements.reserveCapacity(count) - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Int, (Element, OpenAPI.Components, [Loader.Message])).self) { group in + for (idx, elem) in zip(self.indices, self) { + group.addTask { + return try await (idx, elem.externallyDereferenced(with: loader)) + } + } - for element in self { - let (newElement, components, messages) = try await element.externallyDereferenced(with: loader) - newElements.append(newElement) - try newComponents.merge(components) - newMessages += messages - } + var newElems = Array<(Int, Element)>() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - return (newElements, newComponents, newMessages) + for try await (idx, (elem, components, messages)) in group { + newElems.append((idx, elem)) + try newComponents.merge(components) + newMessages += messages + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + newElems.sort { left, right in left.0 < right.0 } + return (newElems.map { $0.1 }, newComponents, newMessages) + } } } diff --git a/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift index e101bc194b..0bc061a0f3 100644 --- a/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift @@ -8,17 +8,24 @@ import OpenAPIKitCore extension Dictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components, messages) + } + } - for (key, value) in self { - let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) - newDict[key] = newValue - try newComponents.merge(components) - newMessages += messages - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessage = [Loader.Message]() - return (newDict, newComponents, newMessages) + for try await (key, newRef, components, messages) in group { + newDict[key] = newRef + try newComponents.merge(components) + newMessage += messages + } + return (newDict, newComponents, newMessage) + } } } diff --git a/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift index 0447a2d309..66e78ab4f2 100644 --- a/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift @@ -10,17 +10,27 @@ import OpenAPIKitCore extension OrderedDictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components, messages) + } + } - for (key, value) in self { - let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) - newDict[key] = newValue - try newComponents.merge(components) - newMessages += messages - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - return (newDict, newComponents, newMessages) + for try await (key, newRef, components, messages) in group { + newDict[key] = newRef + try newComponents.merge(components) + newMessages += messages + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + try newDict.applyOrder(self) + return (newDict, newComponents, newMessages) + } } } diff --git a/Sources/OpenAPIKit30/Document/DocumentInfo.swift b/Sources/OpenAPIKit30/Document/DocumentInfo.swift index 0dbf2a756b..0d123ae817 100644 --- a/Sources/OpenAPIKit30/Document/DocumentInfo.swift +++ b/Sources/OpenAPIKit30/Document/DocumentInfo.swift @@ -7,7 +7,7 @@ import OpenAPIKitCore #if canImport(FoundationEssentials) import FoundationEssentials #else -@preconcurrency import Foundation +import Foundation #endif extension OpenAPI.Document { diff --git a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift index 16e10ba298..ae9d6e3be4 100644 --- a/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit30/Path Item/DereferencedPathItem.swift @@ -142,58 +142,68 @@ extension OpenAPI.PathItem: LocallyDereferenceable { extension OpenAPI.PathItem: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - async let (newParameters, c1, m1) = parameters.externallyDereferenced(with: loader) + let oldParameters = parameters + let oldServers = servers + let oldGet = get + let oldPut = put + let oldPost = post + let oldDelete = delete + let oldOptions = options + let oldHead = head + let oldPatch = patch + let oldTrace = trace + + async let (newParameters, c1, m1) = oldParameters.externallyDereferenced(with: loader) +// async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) + async let (newGet, c3, m3) = oldGet.externallyDereferenced(with: loader) + async let (newPut, c4, m4) = oldPut.externallyDereferenced(with: loader) + async let (newPost, c5, m5) = oldPost.externallyDereferenced(with: loader) + async let (newDelete, c6, m6) = oldDelete.externallyDereferenced(with: loader) + async let (newOptions, c7, m7) = oldOptions.externallyDereferenced(with: loader) + async let (newHead, c8, m8) = oldHead.externallyDereferenced(with: loader) + async let (newPatch, c9, m9) = oldPatch.externallyDereferenced(with: loader) + async let (newTrace, c10, m10) = oldTrace.externallyDereferenced(with: loader) + var pathItem = self var newComponents = try await c1 var newMessages = try await m1 + // ideally we would async let all of the props above and then set them here, + // but for now since there seems to be some sort of compiler bug we will do + // newServers in an if let below pathItem.parameters = try await newParameters - - let (newGet, c2, m2) = try await get.externallyDereferenced(with: loader) - pathItem.get = newGet - try newComponents.merge(c2) - newMessages += m2 - - let (newPut, c3, m3) = try await put.externallyDereferenced(with: loader) - pathItem.put = newPut - try newComponents.merge(c3) - newMessages += m3 - - let (newPost, c4, m4) = try await post.externallyDereferenced(with: loader) - pathItem.post = newPost - try newComponents.merge(c4) - newMessages += m4 - - let (newDelete, c5, m5) = try await delete.externallyDereferenced(with: loader) - pathItem.delete = newDelete - try newComponents.merge(c5) - newMessages += m5 - - let (newOptions, c6, m6) = try await options.externallyDereferenced(with: loader) - pathItem.options = newOptions - try newComponents.merge(c6) - newMessages += m6 - - let (newHead, c7, m7) = try await head.externallyDereferenced(with: loader) - pathItem.head = newHead - try newComponents.merge(c7) - newMessages += m7 - - let (newPatch, c8, m8) = try await patch.externallyDereferenced(with: loader) - pathItem.patch = newPatch - try newComponents.merge(c8) - newMessages += m8 - - let (newTrace, c9, m9) = try await trace.externallyDereferenced(with: loader) - pathItem.trace = newTrace - try newComponents.merge(c9) - newMessages += m9 - - if let servers { - let (newServers, c10, m10) = try await servers.externallyDereferenced(with: loader) - pathItem.servers = newServers - try newComponents.merge(c10) - newMessages += m10 + pathItem.get = try await newGet + pathItem.put = try await newPut + pathItem.post = try await newPost + pathItem.delete = try await newDelete + pathItem.options = try await newOptions + pathItem.head = try await newHead + pathItem.patch = try await newPatch + pathItem.trace = try await newTrace + + try await newComponents.merge(c3) + try await newComponents.merge(c4) + try await newComponents.merge(c5) + try await newComponents.merge(c6) + try await newComponents.merge(c7) + try await newComponents.merge(c8) + try await newComponents.merge(c9) + try await newComponents.merge(c10) + + try await newMessages += m3 + try await newMessages += m4 + try await newMessages += m5 + try await newMessages += m6 + try await newMessages += m7 + try await newMessages += m8 + try await newMessages += m9 + try await newMessages += m10 + + if let oldServers { + async let (newServers, c2, m2) = oldServers.externallyDereferenced(with: loader) + pathItem.servers = try await newServers + try await newComponents.merge(c2) + try await newMessages += m2 } return (pathItem, newComponents, newMessages) diff --git a/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift index ed7f7c7a47..ca1f3dd542 100644 --- a/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/Array+ExternallyDereferenceable.swift @@ -7,18 +7,26 @@ import OpenAPIKitCore extension Array where Element: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newElements = Self() - newElements.reserveCapacity(count) - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Int, (Element, OpenAPI.Components, [Loader.Message])).self) { group in + for (idx, elem) in zip(self.indices, self) { + group.addTask { + return try await (idx, elem.externallyDereferenced(with: loader)) + } + } - for element in self { - let (newElement, components, messages) = try await element.externallyDereferenced(with: loader) - newElements.append(newElement) - try newComponents.merge(components) - newMessages += messages - } + var newElems = Array<(Int, Element)>() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - return (newElements, newComponents, newMessages) + for try await (idx, (elem, components, messages)) in group { + newElems.append((idx, elem)) + try newComponents.merge(components) + newMessages += messages + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + newElems.sort { left, right in left.0 < right.0 } + return (newElems.map { $0.1 }, newComponents, newMessages) + } } } diff --git a/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift index e101bc194b..16ddfcd8d8 100644 --- a/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/Dictionary+ExternallyDereferenceable.swift @@ -8,17 +8,24 @@ import OpenAPIKitCore extension Dictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components, messages) + } + } - for (key, value) in self { - let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) - newDict[key] = newValue - try newComponents.merge(components) - newMessages += messages - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - return (newDict, newComponents, newMessages) + for try await (key, newRef, components, messages) in group { + newDict[key] = newRef + try newComponents.merge(components) + newMessages += messages + } + return (newDict, newComponents, newMessages) + } } } diff --git a/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift index 0447a2d309..66e78ab4f2 100644 --- a/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift +++ b/Sources/OpenAPIKit30/Utility/OrderedDictionary+ExternallyDereferenceable.swift @@ -10,17 +10,27 @@ import OpenAPIKitCore extension OrderedDictionary where Key: Sendable, Value: ExternallyDereferenceable & Sendable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { - var newDict = Self() - var newComponents = OpenAPI.Components() - var newMessages = [Loader.Message]() + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components, [Loader.Message]).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components, messages) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components, messages) + } + } - for (key, value) in self { - let (newValue, components, messages) = try await value.externallyDereferenced(with: loader) - newDict[key] = newValue - try newComponents.merge(components) - newMessages += messages - } + var newDict = Self() + var newComponents = OpenAPI.Components() + var newMessages = [Loader.Message]() - return (newDict, newComponents, newMessages) + for try await (key, newRef, components, messages) in group { + newDict[key] = newRef + try newComponents.merge(components) + newMessages += messages + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + try newDict.applyOrder(self) + return (newDict, newComponents, newMessages) + } } } From d4802d296bc09ca40a8c2aeb9c22652fc1bb5546 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Wed, 1 Apr 2026 02:24:09 +0530 Subject: [PATCH 06/13] Trigger CI rerun From 436663b7bab0acb4a845460cc177af041217a6e6 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Wed, 1 Apr 2026 02:50:37 +0530 Subject: [PATCH 07/13] Add coverage for local anchor dereferencing --- Tests/OpenAPIKitTests/ComponentsTests.swift | 63 ++++++++++ .../Document/DereferencedDocumentTests.swift | 110 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 1aaa86cdda..53871b1ff0 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -386,6 +386,69 @@ final class ComponentsTests: XCTestCase { } } + func test_anchorLookup() throws { + let anchorName = "testAnchor" + let anchorKey = try XCTUnwrap( + OpenAPI.ComponentKey(rawValue: "__openapikit_anchor_0_74657374416e63686f72") + ) + let aliasKey = try XCTUnwrap( + OpenAPI.ComponentKey(rawValue: "__openapikit_anchor_0_616c696173416e63686f72") + ) + let anchoredSchema = JSONSchema.string( + .init( + anchor: anchorName, + vendorExtensions: [ + "x-openapikit-local-anchor": .init(anchorName) + ] + ), + .init() + ) + let components = OpenAPI.Components.direct( + schemas: [ + "hello": .boolean, + anchorKey: anchoredSchema, + "aliasTarget": .reference(.component(named: "hello")), + aliasKey: JSONSchema.reference( + .component(named: "aliasTarget"), + .init( + anchor: "aliasAnchor", + vendorExtensions: [ + "x-openapikit-local-anchor": .init("aliasAnchor") + ] + ) + ) + ] + ) + + let anchorReference = JSONReference.anchor(named: anchorName) + XCTAssertTrue(try components.contains(anchorReference)) + let resolvedAnchorSchema = try components.lookup(anchorReference) + XCTAssertEqual(resolvedAnchorSchema.jsonType, .string) + XCTAssertEqual(resolvedAnchorSchema.anchor, anchorName) + let lookedUpAnchor: Either, JSONSchema> = try components.lookupOnce(anchorReference) + guard case .b(let onceResolvedAnchorSchema) = lookedUpAnchor else { + return XCTFail("Expected anchor lookupOnce to return a concrete schema") + } + XCTAssertEqual(onceResolvedAnchorSchema.jsonType, .string) + XCTAssertEqual(onceResolvedAnchorSchema.anchor, anchorName) + + let aliasReference = JSONReference.anchor(named: "aliasAnchor") + XCTAssertEqual(try components.lookup(aliasReference), JSONSchema.boolean) + } + + func test_missingAnchorLookup() { + let components = OpenAPI.Components.noComponents + let missingReference = JSONReference.anchor(named: "missingAnchor") + + XCTAssertFalse((try? components.contains(missingReference)) ?? true) + XCTAssertThrowsError(try components.lookup(missingReference)) { error in + XCTAssertEqual( + error as? OpenAPI.Components.ReferenceError, + .missingOnLookup(name: "missingAnchor", key: "schemas") + ) + } + } + func test_goodKeysNoProblems() { XCTAssertNil(OpenAPI.ComponentKey.problem(with: "hello")) XCTAssertNil(OpenAPI.ComponentKey.problem(with: "_hell.o-")) diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index 6d5ecc4e89..83bfa0710d 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -181,4 +181,114 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(nameSchema?.jsonType, .string) XCTAssertEqual(nameSchema?.anchor, "nameAnchor") } + + func test_locallyDereferencedResolvesAnchorsCollectedAcrossDocumentLocations() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .pathItem( + .init( + get: .init( + responses: [ + 200: .response( + description: "success", + content: [ + .json: .content( + .init( + schema: .object( + properties: [ + "parameter": .reference(.anchor(named: "parameterAnchor")), + "request": .reference(.anchor(named: "requestAnchor")), + "response": .reference(.anchor(named: "responseAnchor")), + "header": .reference(.anchor(named: "headerAnchor")), + "webhook": .reference(.anchor(named: "webhookAnchor")) + ] + ) + ) + ) + ] + ) + ] + ) + ) + ) + ], + webhooks: [ + "event": .pathItem( + .init( + post: .init( + responses: [ + 200: .response( + description: "webhook success", + content: [ + .json: .content( + .init( + schema: .number(.init(anchor: "webhookAnchor"), .init()) + ) + ) + ] + ) + ] + ) + ) + ) + ], + components: .direct( + schemas: [ + "__openapikit_anchor_0_776562686f6f6b416e63686f72": .string + ], + responses: [ + "anchoredResponse": .init( + description: "anchored response", + content: [ + .json: .content( + .init( + schema: .boolean(.init(anchor: "responseAnchor")) + ) + ) + ] + ) + ], + parameters: [ + "anchoredParameter": .query( + name: "kind", + schema: .string(.init(anchor: "parameterAnchor"), .init()) + ) + ], + requestBodies: [ + "anchoredRequest": .init( + content: [ + .json: .content( + .init( + schema: .integer(.init(anchor: "requestAnchor"), .init()) + ) + ) + ] + ) + ], + headers: [ + "anchoredHeader": .init( + schema: .string(.init(anchor: "headerAnchor"), .init()) + ) + ] + ) + ) + + let dereferencedDocument = try document.locallyDereferenced() + let schema = try XCTUnwrap( + dereferencedDocument + .paths["/hello"]? + .get? + .responses[status: 200]? + .content[OpenAPI.ContentType.json]? + .schema + ) + + XCTAssertEqual(schema.objectContext?.properties["parameter"]?.jsonType, .string) + XCTAssertEqual(schema.objectContext?.properties["request"]?.jsonType, .integer) + XCTAssertEqual(schema.objectContext?.properties["response"]?.jsonType, .boolean) + XCTAssertEqual(schema.objectContext?.properties["header"]?.jsonType, .string) + XCTAssertEqual(schema.objectContext?.properties["webhook"]?.jsonType, .number) + } } From 72161c855e4c7b379e857b84651be43036cf59f8 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Wed, 1 Apr 2026 03:08:40 +0530 Subject: [PATCH 08/13] Cover anchor lookup in media types and encodings --- .../Document/Document+LocalAnchors.swift | 40 +++++++++++++++++++ .../Document/DereferencedDocumentTests.swift | 22 +++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift index 842e02aae7..9b6f6ec6b3 100644 --- a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift +++ b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift @@ -165,6 +165,15 @@ extension OpenAPI.Components { callbacks.collectLocalAnchorSchemas(into: &anchors) } } + + for mediaType in mediaTypes.values { + switch mediaType { + case .a: + continue + case .b(let mediaType): + mediaType.collectLocalAnchorSchemas(into: &anchors) + } + } } } @@ -316,6 +325,37 @@ extension OpenAPI.Content { ) { schema?.collectLocalAnchorSchemas(into: &anchors) itemSchema?.collectLocalAnchorSchemas(into: &anchors) + + switch encoding { + case .a(let encodingMap): + for encoding in encodingMap.values { + encoding.collectLocalAnchorSchemas(into: &anchors) + } + case .b(let positionalEncoding): + positionalEncoding.collectLocalAnchorSchemas(into: &anchors) + case .none: + break + } + } +} + +extension OpenAPI.Content.Encoding { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + headers?.collectLocalAnchorSchemas(into: &anchors) + } +} + +extension OpenAPI.Content.PositionalEncoding { + fileprivate func collectLocalAnchorSchemas( + into anchors: inout OrderedDictionary + ) { + for encoding in prefixEncoding { + encoding.collectLocalAnchorSchemas(into: &anchors) + } + + itemEncoding?.collectLocalAnchorSchemas(into: &anchors) } } diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index 83bfa0710d..9a49ba244d 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -202,7 +202,9 @@ final class DereferencedDocumentTests: XCTestCase { "request": .reference(.anchor(named: "requestAnchor")), "response": .reference(.anchor(named: "responseAnchor")), "header": .reference(.anchor(named: "headerAnchor")), - "webhook": .reference(.anchor(named: "webhookAnchor")) + "webhook": .reference(.anchor(named: "webhookAnchor")), + "mediaType": .reference(.anchor(named: "mediaTypeAnchor")), + "encodingHeader": .reference(.anchor(named: "encodingHeaderAnchor")) ] ) ) @@ -271,6 +273,22 @@ final class DereferencedDocumentTests: XCTestCase { "anchoredHeader": .init( schema: .string(.init(anchor: "headerAnchor"), .init()) ) + ], + mediaTypes: [ + "anchoredMediaType": .init( + schema: .number(.init(anchor: "mediaTypeAnchor"), .init()), + encoding: [ + "payload": .init( + headers: [ + "anchoredEncodingHeader": .b( + .init( + schema: .integer(.init(anchor: "encodingHeaderAnchor"), .init()) + ) + ) + ] + ) + ] + ) ] ) ) @@ -290,5 +308,7 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(schema.objectContext?.properties["response"]?.jsonType, .boolean) XCTAssertEqual(schema.objectContext?.properties["header"]?.jsonType, .string) XCTAssertEqual(schema.objectContext?.properties["webhook"]?.jsonType, .number) + XCTAssertEqual(schema.objectContext?.properties["mediaType"]?.jsonType, .number) + XCTAssertEqual(schema.objectContext?.properties["encodingHeader"]?.jsonType, .integer) } } From 1945f4fbc5d141de91e4d482aabf900f0f1beafa Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 10 Apr 2026 19:26:16 +0530 Subject: [PATCH 09/13] Trigger CI rerun From bce87084d3beea8d71c86d432833a66b279f7cbf Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 10 Apr 2026 19:43:36 +0530 Subject: [PATCH 10/13] Keep local anchor PR focused --- Package.swift | 2 +- .../Document/Document+LocalAnchors.swift | 3 + .../DereferencedJSONSchema.swift | 25 +-- .../Schema Object/JSONSchema+Combining.swift | 3 - .../OpenAPIKit/Schema Object/JSONSchema.swift | 2 - .../Schema Object/JSONSchemaContext.swift | 13 -- .../Schema Object/SimplifiedJSONSchema.swift | 1 - .../Validator/Validation+Builtins.swift | 144 +----------- Sources/OpenAPIKit/Validator/Validator.swift | 6 - .../Document/DereferencedDocumentTests.swift | 56 +++++ .../ExternalDereferencingDocumentTests.swift | 17 +- .../DereferencedSchemaObjectTests.swift | 62 ----- .../Schema Object/JSONSchemaTests.swift | 49 ---- .../SchemaFragmentCombiningTests.swift | 16 -- .../Validator/BuiltinValidationTests.swift | 212 +----------------- 15 files changed, 68 insertions(+), 543 deletions(-) diff --git a/Package.swift b/Package.swift index d9656df4ad..ce22f3cc3c 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "OpenAPIKit", platforms: [ .macOS(.v10_15), - .iOS(.v13) + .iOS(.v12) ], products: [ .library( diff --git a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift index 9b6f6ec6b3..23270c73b1 100644 --- a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift +++ b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift @@ -418,6 +418,9 @@ extension JSONSchema { case .array(_, let arrayContext): arrayContext.items?.collectLocalAnchorSchemas(into: &anchors) + arrayContext.prefixItems?.forEach { + $0.collectLocalAnchorSchemas(into: &anchors) + } case .all(of: let schemas, core: _), .one(of: let schemas, core: _), diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index d4a0bef8e6..1e146f1ffe 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -340,7 +340,6 @@ extension DereferencedJSONSchema { public let maxProperties: Int? let _minProperties: Int? public let properties: OrderedDictionary - public let patternProperties: OrderedDictionary public let additionalProperties: Either? // NOTE that an object's required properties @@ -373,16 +372,7 @@ extension DereferencedJSONSchema { otherProperties[name] = dereferencedProperty } - var otherPatternProperties = OrderedDictionary() - for (pattern, property) in objectContext.patternProperties { - guard let dereferencedPatternProperty = property.dereferenced() else { - return nil - } - otherPatternProperties[pattern] = dereferencedPatternProperty - } - properties = otherProperties - patternProperties = otherPatternProperties maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -404,7 +394,6 @@ extension DereferencedJSONSchema { following references: Set ) throws { properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } - patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -419,13 +408,11 @@ extension DereferencedJSONSchema { internal init( properties: OrderedDictionary, - patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties - self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -444,7 +431,6 @@ extension DereferencedJSONSchema { return .init( properties: properties.mapValues { $0.jsonSchema }, - patternProperties: patternProperties.mapValues { $0.jsonSchema }, additionalProperties: underlyingAdditionalProperties, maxProperties: maxProperties, minProperties: _minProperties @@ -587,15 +573,11 @@ extension JSONSchema: ExternallyDereferenceable { try components.merge(c1) messages += m1 - let (newPatternProperties, c2, m2) = try await object.patternProperties.externallyDereferenced(with: loader) - try components.merge(c2) - messages += m2 - let newAdditionalProperties: Either? if case .b(let schema) = object.additionalProperties { - let (additionalProperties, c3, m3) = try await schema.externallyDereferenced(with: loader) - try components.merge(c3) - messages += m3 + let (additionalProperties, c2, m2) = try await schema.externallyDereferenced(with: loader) + try components.merge(c2) + messages += m2 newAdditionalProperties = .b(additionalProperties) } else { newAdditionalProperties = object.additionalProperties @@ -607,7 +589,6 @@ extension JSONSchema: ExternallyDereferenceable { core, .init( properties: newProperties, - patternProperties: newPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift index 129888695f..cac3517a36 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift @@ -528,7 +528,6 @@ extension JSONSchema.ArrayContext { extension JSONSchema.ObjectContext { internal func combined(with other: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components) throws -> JSONSchema.ObjectContext { let combinedProperties = try combine(properties: properties, with: other.properties, resolvingIn: components) - let combinedPatternProperties = try combine(properties: patternProperties, with: other.patternProperties, resolvingIn: components) if let conflict = conflicting(maxProperties, other.maxProperties) { throw JSONSchemaResolutionError(.attributeConflict(jsonType: .object, name: "maxProperties", original: String(conflict.0), new: String(conflict.1))) @@ -560,7 +559,6 @@ extension JSONSchema.ObjectContext { let newAdditionalProperties = additionalProperties ?? other.additionalProperties return .init( properties: combinedProperties, - patternProperties: combinedPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: newMaxProperties, minProperties: newMinProperties @@ -714,7 +712,6 @@ extension JSONSchema.ObjectContext { } return .init( properties: properties, - patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: _minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index a4c02beddb..f6200e540f 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -1614,7 +1614,6 @@ extension JSONSchema { minProperties: Int? = nil, maxProperties: Int? = nil, properties: OrderedDictionary = [:], - patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, allowedValues: [AnyCodable]? = nil, defaultValue: AnyCodable? = nil, @@ -1644,7 +1643,6 @@ extension JSONSchema { ) let objectContext = JSONSchema.ObjectContext( properties: properties, - patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index 5d3a9ee67e..a517efe059 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -782,10 +782,6 @@ extension JSONSchema { /// allows you to omit the property from encoding. public let additionalProperties: Either? - /// Schemas keyed by regular expressions that matching property names - /// must satisfy. - public let patternProperties: OrderedDictionary - /// The properties of this object that are required. /// /// - Note: An object's required properties array @@ -815,13 +811,11 @@ extension JSONSchema { public init( properties: OrderedDictionary, - patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties - self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -1266,7 +1260,6 @@ extension JSONSchema.ObjectContext { case maxProperties case minProperties case properties - case patternProperties case additionalProperties case required } @@ -1282,10 +1275,6 @@ extension JSONSchema.ObjectContext: Encodable { try container.encode(properties, forKey: .properties) } - if patternProperties.count > 0 { - try container.encode(patternProperties, forKey: .patternProperties) - } - try container.encodeIfPresent(additionalProperties, forKey: .additionalProperties) if !requiredProperties.isEmpty { @@ -1307,9 +1296,7 @@ extension JSONSchema.ObjectContext: Decodable { let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? [] let decodedProperties = try container.decodeIfPresent(OrderedDictionary.self, forKey: .properties) ?? [:] - let decodedPatternProperties = try container.decodeIfPresent(OrderedDictionary.self, forKey: .patternProperties) ?? [:] properties = Self.properties(decodedProperties, takingRequirementsFrom: requiredArray) - patternProperties = decodedPatternProperties.mapValues { $0.optionalSchemaObject() } } /// Make any property not in the given "required" array optional. diff --git a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift index a873d59892..3c06ab72f3 100644 --- a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift @@ -108,7 +108,6 @@ extension DereferencedJSONSchema { core, .init( properties: try object.properties.mapValues { try $0.simplified() }, - patternProperties: try object.patternProperties.mapValues { try $0.simplified() }, additionalProperties: additionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index fdeb4d4c8a..327f8991ac 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -341,23 +341,6 @@ extension Validation { ) } - /// Validate that all named OpenAPI `Server`s have unique names across the - /// whole Document. - /// - /// The OpenAPI Specification requires server names - /// [are unique](https://spec.openapis.org/oas/v3.2.0.html#server-object). - /// - /// - Important: This is included in validation by default. - public static var documentServerNamesAreUnique: Validation { - .init( - description: "The names of Servers in the Document are unique", - check: { context in - let serverNames = context.subject.allServers.compactMap(\.name) - return Set(serverNames).count == serverNames.count - } - ) - } - /// Validate that all OpenAPI Path Items have no duplicate parameters defined /// within them. /// @@ -394,108 +377,6 @@ extension Validation { ) } - /// Validate that `querystring` parameters are unique and do not coexist - /// with `query` parameters within a Path Item's effective operation - /// parameters. - /// - /// OpenAPI 3.2.0 requires that a `querystring` parameter - /// [must not appear more than once and must not appear in the same operation - /// as any `query` parameters](https://spec.openapis.org/oas/v3.2.0.html#parameter-locations). - /// - /// - Important: This is included in validation by default. - public static var querystringParametersAreCompatible: Validation { - .init( - description: "Querystring parameters are unique and do not coexist with query parameters", - check: { context in - let pathParameters = resolvedParameters(context.subject.parameters, components: context.document.components) - let pathSummary = ParameterLocationSummary(pathParameters) - let pathParametersPath = context.codingPath + [Validator.CodingKey.init(stringValue: "parameters")] - var errors = [ValidationError]() - let pathHasMultipleQuerystringParameters = pathSummary.querystringCount > 1 - let pathMixesQueryLocations = pathSummary.querystringCount > 0 && pathSummary.queryCount > 0 - - if pathHasMultipleQuerystringParameters { - errors.append( - ValidationError( - reason: "Path Item parameters must not contain more than one `querystring` parameter", - at: pathParametersPath - ) - ) - } - - if pathMixesQueryLocations { - errors.append( - ValidationError( - reason: "Path Item parameters must not mix `querystring` and `query` parameter locations", - at: pathParametersPath - ) - ) - } - - for endpoint in context.subject.endpoints { - let operationParameters = resolvedParameters(endpoint.operation.parameters, components: context.document.components) - let operationSummary = ParameterLocationSummary(operationParameters) - let operationParametersPath = context.codingPath + [ - Validator.CodingKey.init(stringValue: codingPathKey(for: endpoint.method)), - Validator.CodingKey.init(stringValue: "parameters") - ] - let operationHasMultipleQuerystringParameters = operationSummary.querystringCount > 1 - let operationMixesQueryLocations = operationSummary.querystringCount > 0 && - operationSummary.queryCount > 0 - let effectiveQuerystringCount = pathSummary.querystringCount + operationSummary.querystringCount - let effectiveQueryCount = pathSummary.queryCount + operationSummary.queryCount - let inheritedHasMultipleQuerystringParameters = - !pathHasMultipleQuerystringParameters && - !operationHasMultipleQuerystringParameters && - effectiveQuerystringCount > 1 - let inheritedMixesQueryLocations = - !pathMixesQueryLocations && - !operationMixesQueryLocations && - effectiveQuerystringCount > 0 && - effectiveQueryCount > 0 - - if operationHasMultipleQuerystringParameters { - errors.append( - ValidationError( - reason: "Operation parameters must not contain more than one `querystring` parameter", - at: operationParametersPath - ) - ) - } - - if operationMixesQueryLocations { - errors.append( - ValidationError( - reason: "Operation parameters must not mix `querystring` and `query` parameter locations", - at: operationParametersPath - ) - ) - } - - if inheritedHasMultipleQuerystringParameters { - errors.append( - ValidationError( - reason: "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters", - at: operationParametersPath - ) - ) - } - - if inheritedMixesQueryLocations { - errors.append( - ValidationError( - reason: "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters", - at: operationParametersPath - ) - ) - } - } - - return errors - } - ) - } - /// Validate that all OpenAPI Operation Ids are unique across the whole Document. /// /// The OpenAPI Specification requires that Operation Ids [are unique](https://spec.openapis.org/oas/v3.2.0.html#operation-object). @@ -694,32 +575,9 @@ extension Validation { /// Used by both the Path Item parameter check and the /// Operation parameter check in the default validations. fileprivate func parametersAreUnique(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> Bool { - let foundParameters = resolvedParameters(parameters, components: components) + let foundParameters = parameters.compactMap { try? components.lookup($0) } let identities = foundParameters.map { OpenAPI.Parameter.ParameterIdentity(name: $0.name, location: $0.location) } return Set(identities).count == foundParameters.count } - -fileprivate func resolvedParameters(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> [OpenAPI.Parameter] { - parameters.compactMap { try? components.lookup($0) } -} - -fileprivate struct ParameterLocationSummary { - let queryCount: Int - let querystringCount: Int - - init(_ parameters: [OpenAPI.Parameter]) { - queryCount = parameters.filter { $0.location == .query }.count - querystringCount = parameters.filter { $0.location == .querystring }.count - } -} - -fileprivate func codingPathKey(for method: OpenAPI.HttpMethod) -> String { - switch method { - case .builtin(let builtin): - return builtin.rawValue.lowercased() - case .other(let other): - return other - } -} diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 698fb4e957..4b8c5fadea 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -75,10 +75,8 @@ extension OpenAPI.Document { /// The default validations are /// - Operations must contain at least one response. /// - Document-level tag names are unique. -/// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. -/// - Querystring parameters are unique and do not coexist with query parameters. /// - Operation Ids are unique across the whole Document. /// - All OpenAPI.References that refer to components in this /// document can be found in the components dictionary. @@ -158,10 +156,8 @@ public final class Validator { internal var nonReferenceDefaultValidations: [AnyValidation] = [ .init(.documentTagNamesAreUnique), - .init(.documentServerNamesAreUnique), .init(.pathItemParametersAreUnique), .init(.operationParametersAreUnique), - .init(.querystringParametersAreCompatible), .init(.operationIdsAreUnique), .init(.serverVariableEnumIsValid), .init(.serverVariableDefaultExistsInEnum), @@ -204,10 +200,8 @@ public final class Validator { /// /// The default validations are /// - Document-level tag names are unique. - /// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. - /// - Querystring parameters are unique and do not coexist with query parameters. /// - Operation Ids are unique across the whole Document. /// - All OpenAPI.References that refer to components in this document can /// be found in the components dictionary. diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index 9a49ba244d..fb223346ca 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -311,4 +311,60 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(schema.objectContext?.properties["mediaType"]?.jsonType, .number) XCTAssertEqual(schema.objectContext?.properties["encodingHeader"]?.jsonType, .integer) } + + func test_locallyDereferencedResolvesSchemaAnchorReferencesFromPrefixItems() throws { + let anchoredTupleChild = JSONSchema.string( + .init(anchor: "tupleAnchor"), + .init() + ) + let anchoredSchema = JSONSchema.array( + .init(), + .init( + prefixItems: [ + anchoredTupleChild + ] + ) + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .pathItem( + .init( + get: .init( + responses: [ + 200: .response( + description: "success", + content: [ + .json: .content( + .init( + schema: .reference(.anchor(named: "tupleAnchor")) + ) + ) + ] + ) + ] + ) + ) + ) + ], + components: .direct( + schemas: [ + "anchoredTupleSchema": anchoredSchema + ] + ) + ) + + let dereferencedDocument = try document.locallyDereferenced() + let schema = dereferencedDocument + .paths["/hello"]? + .get? + .responses[status: 200]? + .content[.json]? + .schema + + XCTAssertEqual(schema?.jsonType, .string) + XCTAssertEqual(schema?.anchor, "tupleAnchor") + } } diff --git a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift index f775db6ee3..6d3f1f2018 100644 --- a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift @@ -76,17 +76,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { """, "schemas_basic_object_json": """ { - "type": "object", - "patternProperties": { - "^x-": { - "$ref": "file://./schemas/pattern_property.json" - } - } - } - """, - "schemas_pattern_property_json": """ - { - "type": "string" + "type": "object" } """, "requests_hello_json": """ @@ -320,10 +310,6 @@ final class ExternalDereferencingDocumentTests: XCTestCase { // for this document, depth of 4 is enough for all the above to compare equally XCTAssertEqual(docCopy1, docCopy2) XCTAssertEqual(docCopy2, docCopy3) - XCTAssertEqual( - docCopy3.components.schemas["schemas_basic_object_json"]?.objectContext?.patternProperties["^x-"], - .reference(.component(named: "schemas_pattern_property_json"), required: false) - ) XCTAssertEqual( messages.sorted(), @@ -342,7 +328,6 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./requests/webhook.json", "file://./responses/webhook.json", "file://./schemas/basic_object.json", - "file://./schemas/pattern_property.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index 5f639b190e..f3bf0acf4e 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -304,12 +304,6 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) - let tPattern = JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced() - XCTAssertEqual( - tPattern?.objectContext?.patternProperties["^x-"], - .string(.init(), .init()) - ) - let t3 = JSONSchema.object( properties: [ "required": .string, @@ -326,37 +320,6 @@ final class DereferencedSchemaObjectTests: XCTestCase { ) } - func test_optionalObjectContextPatternPropertiesCanConvertBackToJSONSchema() throws { - let context = try XCTUnwrap( - DereferencedJSONSchema.ObjectContext( - .init( - properties: ["fixed": .string], - patternProperties: ["^x-": .boolean], - additionalProperties: .init(.integer) - ) - ) - ) - - XCTAssertEqual( - context.patternProperties["^x-"], - .boolean(.init()) - ) - XCTAssertEqual( - context.additionalProperties?.schemaValue, - .integer(.init(), .init()) - ) - - let schema = DereferencedJSONSchema.object(.init(), context).jsonSchema - XCTAssertEqual( - schema.objectContext, - .init( - properties: ["fixed": .string], - patternProperties: ["^x-": .boolean], - additionalProperties: .init(.integer) - ) - ) - } - func test_throwingObjectWithoutReferences() throws { let components = OpenAPI.Components.noComponents let t1 = try JSONSchema.object(properties: ["test": .string]).dereferenced(in: components) @@ -372,12 +335,6 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) - let tPattern = try JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced(in: components) - XCTAssertEqual( - tPattern.objectContext?.patternProperties["^x-"], - .string(.init(), .init()) - ) - let t3 = try JSONSchema.object( properties: [ "required": .string, @@ -396,7 +353,6 @@ final class DereferencedSchemaObjectTests: XCTestCase { func test_optionalObjectWithReferences() { XCTAssertNil(JSONSchema.object(properties: ["test": .reference(.component(named: "test"))]).dereferenced()) - XCTAssertNil(JSONSchema.object(patternProperties: ["^x-": .reference(.component(named: "missing"))]).dereferenced()) } func test_throwingObjectWithReferences() throws { @@ -559,24 +515,6 @@ final class DereferencedSchemaObjectTests: XCTestCase { } } - func test_simplifiedObjectWithPatternProperties() throws { - let simplified = try JSONSchema.object( - patternProperties: ["^x-": .all(of: [.string])], - additionalProperties: .init(.all(of: [.boolean])) - ) - .dereferenced(in: .noComponents) - .simplified() - - XCTAssertEqual( - simplified.objectContext?.patternProperties["^x-"], - .string(.init(), .init()) - ) - XCTAssertEqual( - simplified.objectContext?.additionalProperties?.schemaValue, - .boolean(.init()) - ) - } - func test_withDescription() throws { let null = JSONSchema.null().dereferenced()!.with(description: "test") let object = JSONSchema.object.dereferenced()!.with(description: "test") diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift index 154f5414ec..06c0ab59e2 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift @@ -3237,55 +3237,6 @@ extension SchemaObjectTests { XCTAssertEqual(contextB, .init(properties: ["hello": .boolean(.init(format: .generic, required: false))], additionalProperties: .init(.string))) } - func test_encodeObjectWithPatternProperties() { - let object = JSONSchema.object( - .init(format: .unspecified, required: true), - .init( - properties: ["hello": .boolean(.init(format: .unspecified, required: false))], - patternProperties: ["^x-": .string(required: false)] - ) - ) - - testEncodingPropertyLines(entity: object, - propertyLines: [ - "\"patternProperties\" : {", - " \"^x-\" : {", - " \"type\" : \"string\"", - " }", - "},", - "\"properties\" : {", - " \"hello\" : {", - " \"type\" : \"boolean\"", - " }", - "},", - "\"type\" : \"object\"" - ]) - } - - func test_decodeObjectWithPatternProperties() { - let objectData = """ - { - "patternProperties": { - "^x-": { "type": "string" } - }, - "type": "object" - } - """.data(using: .utf8)! - - let object = try! orderUnstableDecode(JSONSchema.self, from: objectData) - - XCTAssertEqual( - object, - JSONSchema.object( - .init(format: .generic), - .init( - properties: [:], - patternProperties: ["^x-": .string(required: false)] - ) - ) - ) - } - func test_encodeObjectWithExample() { let string = try! JSONSchema.string(.init(format: .unspecified, required: true), .init()) .with(example: "hello") diff --git a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift index 21657a6722..f4662cb81f 100644 --- a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift @@ -1074,22 +1074,6 @@ final class SchemaFragmentCombiningTests: XCTestCase { XCTAssert(error ~= .attributeConflict, "\(error) is not ~= `.attributeConflict` -- \(fragments)") } } - - let patternPropertyDifference: [JSONSchema] = [ - .object(.init(), .init(properties: [:], patternProperties: ["^x-": .boolean])), - .object(.init(), .init(properties: [:], patternProperties: ["^x-": .string])) - ] - XCTAssertThrowsError(try patternPropertyDifference.combined(resolvingAgainst: .noComponents)) - } - - func test_ObjectPatternPropertiesCombine() throws { - let combined = try [ - JSONSchema.object(patternProperties: ["^x-": .string]), - JSONSchema.object(patternProperties: ["^y-": .boolean]) - ].combined(resolvingAgainst: .noComponents) - - XCTAssertEqual(combined.objectContext?.patternProperties["^x-"], .string(.init(), .init())) - XCTAssertEqual(combined.objectContext?.patternProperties["^y-"], .boolean(.init())) } // MARK: - Inconsistency Failures diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index 73247c6667..f8ffa15b14 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -24,13 +24,11 @@ final class BuiltinValidationTests: XCTestCase { ]) let withoutReferenceValidations = Validator().skippingReferenceValidations() - XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 9) + XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 7) XCTAssertEqual(withoutReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", - "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", - "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -38,13 +36,11 @@ final class BuiltinValidationTests: XCTestCase { ]) let defaultValidations = Validator() - XCTAssertEqual(defaultValidations.validationDescriptions.count, 19) + XCTAssertEqual(defaultValidations.validationDescriptions.count, 17) XCTAssertEqual(defaultValidations.validationDescriptions, [ "The names of Tags in the Document are unique", - "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", - "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -62,13 +58,11 @@ final class BuiltinValidationTests: XCTestCase { ]) let stricterReferenceValidations = Validator().validatingAllReferencesFoundInComponents() - XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 19) + XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 17) XCTAssertEqual(stricterReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", - "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", - "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -567,75 +561,6 @@ final class BuiltinValidationTests: XCTestCase { try document.validate() } - func test_duplicateServerNamesOnDocumentFails() { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [ - .init(url: URL(string: "https://root.example.com")!, name: "shared") - ], - paths: [ - "/hello": .init( - get: .init( - responses: [ - 200: .response(description: "hi") - ], - servers: [ - .init(url: URL(string: "https://operation.example.com")!, name: "shared") - ] - ) - ) - ], - components: .noComponents - ) - - XCTAssertThrowsError(try document.validate()) { error in - let error = error as? ValidationErrorCollection - XCTAssertEqual(error?.values.first?.reason, "Failed to satisfy: The names of Servers in the Document are unique") - XCTAssertEqual(error?.values.first?.codingPath.map { $0.stringValue }, []) - } - } - - func test_uniqueServerNamesOnDocumentSucceeds() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [ - .init(url: URL(string: "https://root.example.com")!, name: "root") - ], - paths: [ - "/hello": .init( - servers: [ - .init(url: URL(string: "https://path.example.com")!, name: "path"), - .init(url: URL(string: "https://unnamed-path.example.com")!) - ], - get: .init( - responses: [ - 200: .response(description: "hi") - ], - servers: [ - .init(url: URL(string: "https://operation.example.com")!, name: "operation"), - .init(url: URL(string: "https://unnamed-operation.example.com")!) - ] - ) - ) - ], - webhooks: [ - "/event": .init( - post: .init( - responses: [ - 200: .response(description: "ok") - ], - servers: [ - .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") - ] - ) - ) - ], - components: .noComponents - ) - - try document.validate() - } - func test_duplicateOperationParameterFails() { let document = OpenAPI.Document( info: .init(title: "test", version: "1.0"), @@ -1486,135 +1411,4 @@ final class BuiltinValidationTests: XCTestCase { XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") } } - - func test_duplicateQuerystringParametersOnPathItem_fails() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [], - paths: [ - "/hello": .init( - parameters: [ - .parameter(OpenAPI.Parameter.querystring(name: "first", content: [:])), - .parameter(OpenAPI.Parameter.querystring(name: "second", content: [:])) - ], - get: .init(responses: [:]) - ) - ], - components: .noComponents - ) - - let validator = Validator.blank.validating(.querystringParametersAreCompatible) - - XCTAssertThrowsError(try document.validate(using: validator)) { error in - let errorCollection = error as? ValidationErrorCollection - XCTAssertEqual(errorCollection?.values.first?.reason, "Path Item parameters must not contain more than one `querystring` parameter") - XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].parameters") - } - } - - func test_duplicateQuerystringParametersAcrossPathItemAndOperation_fails() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [], - paths: [ - "/hello": .init( - parameters: [ - .parameter(OpenAPI.Parameter.querystring(name: "first", content: [:])) - ], - get: .init( - parameters: [ - .parameter(OpenAPI.Parameter.querystring(name: "second", content: [:])) - ], - responses: [:] - ) - ) - ], - components: .noComponents - ) - - let validator = Validator.blank.validating(.querystringParametersAreCompatible) - - XCTAssertThrowsError(try document.validate(using: validator)) { error in - let errorCollection = error as? ValidationErrorCollection - XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters") - XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") - } - } - - func test_querystringAndQueryParametersOnOperation_fails() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [], - paths: [ - "/hello": .init( - get: .init( - parameters: [ - .parameter(OpenAPI.Parameter.query(name: "query", schema: .string)), - .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) - ], - responses: [:] - ) - ) - ], - components: .noComponents - ) - - let validator = Validator.blank.validating(.querystringParametersAreCompatible) - - XCTAssertThrowsError(try document.validate(using: validator)) { error in - let errorCollection = error as? ValidationErrorCollection - XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations") - XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") - } - } - - func test_querystringAndQueryParametersAcrossPathItemAndOperation_fails() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [], - paths: [ - "/hello": .init( - parameters: [ - .parameter(OpenAPI.Parameter.query(name: "query", schema: .string)) - ], - get: .init( - parameters: [ - .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) - ], - responses: [:] - ) - ) - ], - components: .noComponents - ) - - let validator = Validator.blank.validating(.querystringParametersAreCompatible) - - XCTAssertThrowsError(try document.validate(using: validator)) { error in - let errorCollection = error as? ValidationErrorCollection - XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters") - XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") - } - } - - func test_singleQuerystringParameter_succeeds() throws { - let document = OpenAPI.Document( - info: .init(title: "test", version: "1.0"), - servers: [], - paths: [ - "/hello": .init( - get: .init( - parameters: [ - .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) - ], - responses: [:] - ) - ) - ], - components: .noComponents - ) - - let validator = Validator.blank.validating(.querystringParametersAreCompatible) - try document.validate(using: validator) - } } From 3f934f493d5b20ec5a5643d5c007639b83018d2c Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 10 Apr 2026 19:50:34 +0530 Subject: [PATCH 11/13] Fail on duplicate local schema anchors --- .../Document/DereferencedDocument.swift | 2 +- .../Document/Document+LocalAnchors.swift | 165 +++++++++++------- .../Document/DereferencedDocumentTests.swift | 44 +++++ 3 files changed, 145 insertions(+), 66 deletions(-) diff --git a/Sources/OpenAPIKit/Document/DereferencedDocument.swift b/Sources/OpenAPIKit/Document/DereferencedDocument.swift index 61205f4c26..103f5a3428 100644 --- a/Sources/OpenAPIKit/Document/DereferencedDocument.swift +++ b/Sources/OpenAPIKit/Document/DereferencedDocument.swift @@ -48,7 +48,7 @@ public struct DereferencedDocument: Equatable { /// on whether an unresolvable reference points to another file or just points to a /// component in the same file that cannot be found in the Components Object. internal init(_ document: OpenAPI.Document) throws { - let components = document.locallyDereferenceableComponents + let components = try document.locallyDereferenceableComponents() self.paths = try document.paths.mapValues { try $0._dereferenced( diff --git a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift index 23270c73b1..02affc3854 100644 --- a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift +++ b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift @@ -5,13 +5,33 @@ import OpenAPIKitCore extension OpenAPI.Document { - internal var locallyDereferenceableComponents: OpenAPI.Components { + public struct DuplicateAnchorError: Swift.Error, Equatable, CustomStringConvertible { + public let name: String + + public init(name: String) { + self.name = name + } + + public var description: String { + "Encountered multiple JSON Schema $anchor definitions named '\(name)' while preparing a locally dereferenced document. OpenAPIKit cannot determine which schema '#\(name)' should resolve to." + } + + public var localizedDescription: String { + description + } + } + + internal func locallyDereferenceableComponents() throws -> OpenAPI.Components { var components = self.components - var anchors: OrderedDictionary = [:] + var localAnchors = LocalAnchorCollection() - collectLocalAnchorSchemas(into: &anchors) + collectLocalAnchorSchemas(into: &localAnchors) + + if let duplicateAnchor = localAnchors.duplicateAnchorNames.sorted().first { + throw DuplicateAnchorError(name: duplicateAnchor) + } - for (anchor, schema) in anchors { + for (anchor, schema) in localAnchors.schemasByName { components.registerLocalAnchorSchema(schema, named: anchor) } @@ -19,22 +39,39 @@ extension OpenAPI.Document { } private func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { - components.collectLocalAnchorSchemas(into: &anchors) + components.collectLocalAnchorSchemas(into: &localAnchors) for pathItem in paths.values { guard case .b(let pathItem) = pathItem else { continue } - pathItem.collectLocalAnchorSchemas(into: &anchors) + pathItem.collectLocalAnchorSchemas(into: &localAnchors) } for webhook in webhooks.values { guard case .b(let pathItem) = webhook else { continue } - pathItem.collectLocalAnchorSchemas(into: &anchors) + pathItem.collectLocalAnchorSchemas(into: &localAnchors) + } + } +} + +fileprivate struct LocalAnchorCollection { + var schemasByName: OrderedDictionary = [:] + var duplicateAnchorNames: Set = [] + + mutating func record(_ schema: JSONSchema) { + guard let anchor = schema.anchor else { + return + } + + if schemasByName[anchor] == nil { + schemasByName[anchor] = schema + } else { + duplicateAnchorNames.insert(anchor) } } } @@ -111,10 +148,10 @@ extension OpenAPI.Components { } fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for schema in schemas.values { - schema.collectLocalAnchorSchemas(into: &anchors) + schema.collectLocalAnchorSchemas(into: &localAnchors) } for parameter in parameters.values { @@ -122,7 +159,7 @@ extension OpenAPI.Components { case .a: continue case .b(let parameter): - parameter.collectLocalAnchorSchemas(into: &anchors) + parameter.collectLocalAnchorSchemas(into: &localAnchors) } } @@ -131,7 +168,7 @@ extension OpenAPI.Components { case .a: continue case .b(let request): - request.collectLocalAnchorSchemas(into: &anchors) + request.collectLocalAnchorSchemas(into: &localAnchors) } } @@ -140,7 +177,7 @@ extension OpenAPI.Components { case .a: continue case .b(let response): - response.collectLocalAnchorSchemas(into: &anchors) + response.collectLocalAnchorSchemas(into: &localAnchors) } } @@ -149,12 +186,12 @@ extension OpenAPI.Components { case .a: continue case .b(let header): - header.collectLocalAnchorSchemas(into: &anchors) + header.collectLocalAnchorSchemas(into: &localAnchors) } } for pathItem in pathItems.values { - pathItem.collectLocalAnchorSchemas(into: &anchors) + pathItem.collectLocalAnchorSchemas(into: &localAnchors) } for callbacks in callbacks.values { @@ -162,7 +199,7 @@ extension OpenAPI.Components { case .a: continue case .b(let callbacks): - callbacks.collectLocalAnchorSchemas(into: &anchors) + callbacks.collectLocalAnchorSchemas(into: &localAnchors) } } @@ -171,7 +208,7 @@ extension OpenAPI.Components { case .a: continue case .b(let mediaType): - mediaType.collectLocalAnchorSchemas(into: &anchors) + mediaType.collectLocalAnchorSchemas(into: &localAnchors) } } } @@ -179,38 +216,38 @@ extension OpenAPI.Components { extension OpenAPI.PathItem { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for parameter in parameters { switch parameter { case .a: continue case .b(let parameter): - parameter.collectLocalAnchorSchemas(into: &anchors) + parameter.collectLocalAnchorSchemas(into: &localAnchors) } } for endpoint in endpoints { - endpoint.operation.collectLocalAnchorSchemas(into: &anchors) + endpoint.operation.collectLocalAnchorSchemas(into: &localAnchors) } } } extension OpenAPI.Operation { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for parameter in parameters { switch parameter { case .a: continue case .b(let parameter): - parameter.collectLocalAnchorSchemas(into: &anchors) + parameter.collectLocalAnchorSchemas(into: &localAnchors) } } if case .some(.b(let requestBody)) = requestBody { - requestBody.collectLocalAnchorSchemas(into: &anchors) + requestBody.collectLocalAnchorSchemas(into: &localAnchors) } for response in responses.values { @@ -218,7 +255,7 @@ extension OpenAPI.Operation { case .a: continue case .b(let response): - response.collectLocalAnchorSchemas(into: &anchors) + response.collectLocalAnchorSchemas(into: &localAnchors) } } @@ -227,7 +264,7 @@ extension OpenAPI.Operation { case .a: continue case .b(let callbacks): - callbacks.collectLocalAnchorSchemas(into: &anchors) + callbacks.collectLocalAnchorSchemas(into: &localAnchors) } } } @@ -235,14 +272,14 @@ extension OpenAPI.Operation { extension OpenAPI.Callbacks { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for pathItem in values { switch pathItem { case .a: continue case .b(let pathItem): - pathItem.collectLocalAnchorSchemas(into: &anchors) + pathItem.collectLocalAnchorSchemas(into: &localAnchors) } } } @@ -250,70 +287,70 @@ extension OpenAPI.Callbacks { extension OpenAPI.Parameter { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { switch schemaOrContent { case .a(let schemaContext): - schemaContext.collectLocalAnchorSchemas(into: &anchors) + schemaContext.collectLocalAnchorSchemas(into: &localAnchors) case .b(let content): - content.collectLocalAnchorSchemas(into: &anchors) + content.collectLocalAnchorSchemas(into: &localAnchors) } } } extension OpenAPI.Parameter.SchemaContext { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { switch schema { case .a: break case .b(let schema): - schema.collectLocalAnchorSchemas(into: &anchors) + schema.collectLocalAnchorSchemas(into: &localAnchors) } } } extension OpenAPI.Request { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { - content.collectLocalAnchorSchemas(into: &anchors) + content.collectLocalAnchorSchemas(into: &localAnchors) } } extension OpenAPI.Response { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { - headers?.collectLocalAnchorSchemas(into: &anchors) - content.collectLocalAnchorSchemas(into: &anchors) + headers?.collectLocalAnchorSchemas(into: &localAnchors) + content.collectLocalAnchorSchemas(into: &localAnchors) } } extension OpenAPI.Header { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { switch schemaOrContent { case .a(let schemaContext): - schemaContext.collectLocalAnchorSchemas(into: &anchors) + schemaContext.collectLocalAnchorSchemas(into: &localAnchors) case .b(let content): - content.collectLocalAnchorSchemas(into: &anchors) + content.collectLocalAnchorSchemas(into: &localAnchors) } } } extension OrderedDictionary where Key == OpenAPI.ContentType, Value == Either, OpenAPI.Content> { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for content in values { switch content { case .a: continue case .b(let content): - content.collectLocalAnchorSchemas(into: &anchors) + content.collectLocalAnchorSchemas(into: &localAnchors) } } } @@ -321,18 +358,18 @@ extension OrderedDictionary where Key == OpenAPI.ContentType, Value == Either + into localAnchors: inout LocalAnchorCollection ) { - schema?.collectLocalAnchorSchemas(into: &anchors) - itemSchema?.collectLocalAnchorSchemas(into: &anchors) + schema?.collectLocalAnchorSchemas(into: &localAnchors) + itemSchema?.collectLocalAnchorSchemas(into: &localAnchors) switch encoding { case .a(let encodingMap): for encoding in encodingMap.values { - encoding.collectLocalAnchorSchemas(into: &anchors) + encoding.collectLocalAnchorSchemas(into: &localAnchors) } case .b(let positionalEncoding): - positionalEncoding.collectLocalAnchorSchemas(into: &anchors) + positionalEncoding.collectLocalAnchorSchemas(into: &localAnchors) case .none: break } @@ -341,34 +378,34 @@ extension OpenAPI.Content { extension OpenAPI.Content.Encoding { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { - headers?.collectLocalAnchorSchemas(into: &anchors) + headers?.collectLocalAnchorSchemas(into: &localAnchors) } } extension OpenAPI.Content.PositionalEncoding { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for encoding in prefixEncoding { - encoding.collectLocalAnchorSchemas(into: &anchors) + encoding.collectLocalAnchorSchemas(into: &localAnchors) } - itemEncoding?.collectLocalAnchorSchemas(into: &anchors) + itemEncoding?.collectLocalAnchorSchemas(into: &localAnchors) } } extension OrderedDictionary where Key == String, Value == Either, OpenAPI.Header> { fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { for header in values { switch header { case .a: continue case .b(let header): - header.collectLocalAnchorSchemas(into: &anchors) + header.collectLocalAnchorSchemas(into: &localAnchors) } } } @@ -396,41 +433,39 @@ extension JSONSchema { } fileprivate func collectLocalAnchorSchemas( - into anchors: inout OrderedDictionary + into localAnchors: inout LocalAnchorCollection ) { - if let anchor, anchors[anchor] == nil { - anchors[anchor] = self - } + localAnchors.record(self) for definition in defs.values { - definition.collectLocalAnchorSchemas(into: &anchors) + definition.collectLocalAnchorSchemas(into: &localAnchors) } switch value { case .object(_, let objectContext): for property in objectContext.properties.values { - property.collectLocalAnchorSchemas(into: &anchors) + property.collectLocalAnchorSchemas(into: &localAnchors) } if case .b(let additionalProperties) = objectContext.additionalProperties { - additionalProperties.collectLocalAnchorSchemas(into: &anchors) + additionalProperties.collectLocalAnchorSchemas(into: &localAnchors) } case .array(_, let arrayContext): - arrayContext.items?.collectLocalAnchorSchemas(into: &anchors) + arrayContext.items?.collectLocalAnchorSchemas(into: &localAnchors) arrayContext.prefixItems?.forEach { - $0.collectLocalAnchorSchemas(into: &anchors) + $0.collectLocalAnchorSchemas(into: &localAnchors) } case .all(of: let schemas, core: _), .one(of: let schemas, core: _), .any(of: let schemas, core: _): for schema in schemas { - schema.collectLocalAnchorSchemas(into: &anchors) + schema.collectLocalAnchorSchemas(into: &localAnchors) } case .not(let schema, core: _): - schema.collectLocalAnchorSchemas(into: &anchors) + schema.collectLocalAnchorSchemas(into: &localAnchors) case .null, .boolean, diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index fb223346ca..e2c211fb7e 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -367,4 +367,48 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(schema?.jsonType, .string) XCTAssertEqual(schema?.anchor, "tupleAnchor") } + + func test_locallyDereferencedFailsOnDuplicateSchemaAnchors() { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .pathItem( + .init( + get: .init( + responses: [ + 200: .response( + description: "success", + content: [ + .json: .content( + .init( + schema: .reference(.anchor(named: "duplicateAnchor")) + ) + ) + ] + ) + ] + ) + ) + ) + ], + components: .direct( + schemas: [ + "first": .string(.init(anchor: "duplicateAnchor"), .init()), + "second": .integer(.init(anchor: "duplicateAnchor"), .init()) + ] + ) + ) + + XCTAssertThrowsError(try document.locallyDereferenced()) { error in + XCTAssertEqual( + error as? OpenAPI.Document.DuplicateAnchorError, + .init(name: "duplicateAnchor") + ) + XCTAssertEqual( + (error as? OpenAPI.Document.DuplicateAnchorError)?.description, + "Encountered multiple JSON Schema $anchor definitions named 'duplicateAnchor' while preparing a locally dereferenced document. OpenAPIKit cannot determine which schema '#duplicateAnchor' should resolve to." + ) + } + } } From b73a4de0c340dc9a961b20e74ba92bb5bec90249 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 10 Apr 2026 20:02:01 +0530 Subject: [PATCH 12/13] Restore upstream main changes and keep anchor support --- Package.swift | 2 +- .../Document/Document+LocalAnchors.swift | 4 + .../DereferencedJSONSchema.swift | 25 ++- .../Schema Object/JSONSchema+Combining.swift | 3 + .../OpenAPIKit/Schema Object/JSONSchema.swift | 2 + .../Schema Object/JSONSchemaContext.swift | 13 ++ .../Schema Object/SimplifiedJSONSchema.swift | 1 + .../Validator/Validation+Builtins.swift | 144 +++++++++++- Sources/OpenAPIKit/Validator/Validator.swift | 6 + .../Document/DereferencedDocumentTests.swift | 53 +++++ .../ExternalDereferencingDocumentTests.swift | 17 +- .../DereferencedSchemaObjectTests.swift | 62 +++++ .../Schema Object/JSONSchemaTests.swift | 49 ++++ .../SchemaFragmentCombiningTests.swift | 16 ++ .../Validator/BuiltinValidationTests.swift | 212 +++++++++++++++++- 15 files changed, 600 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index ce22f3cc3c..d9656df4ad 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "OpenAPIKit", platforms: [ .macOS(.v10_15), - .iOS(.v12) + .iOS(.v13) ], products: [ .library( diff --git a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift index 02affc3854..bc38dfc15a 100644 --- a/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift +++ b/Sources/OpenAPIKit/Document/Document+LocalAnchors.swift @@ -447,6 +447,10 @@ extension JSONSchema { property.collectLocalAnchorSchemas(into: &localAnchors) } + for property in objectContext.patternProperties.values { + property.collectLocalAnchorSchemas(into: &localAnchors) + } + if case .b(let additionalProperties) = objectContext.additionalProperties { additionalProperties.collectLocalAnchorSchemas(into: &localAnchors) } diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 1e146f1ffe..d4a0bef8e6 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -340,6 +340,7 @@ extension DereferencedJSONSchema { public let maxProperties: Int? let _minProperties: Int? public let properties: OrderedDictionary + public let patternProperties: OrderedDictionary public let additionalProperties: Either? // NOTE that an object's required properties @@ -372,7 +373,16 @@ extension DereferencedJSONSchema { otherProperties[name] = dereferencedProperty } + var otherPatternProperties = OrderedDictionary() + for (pattern, property) in objectContext.patternProperties { + guard let dereferencedPatternProperty = property.dereferenced() else { + return nil + } + otherPatternProperties[pattern] = dereferencedPatternProperty + } + properties = otherProperties + patternProperties = otherPatternProperties maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -394,6 +404,7 @@ extension DereferencedJSONSchema { following references: Set ) throws { properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -408,11 +419,13 @@ extension DereferencedJSONSchema { internal init( properties: OrderedDictionary, + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties + self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -431,6 +444,7 @@ extension DereferencedJSONSchema { return .init( properties: properties.mapValues { $0.jsonSchema }, + patternProperties: patternProperties.mapValues { $0.jsonSchema }, additionalProperties: underlyingAdditionalProperties, maxProperties: maxProperties, minProperties: _minProperties @@ -573,11 +587,15 @@ extension JSONSchema: ExternallyDereferenceable { try components.merge(c1) messages += m1 + let (newPatternProperties, c2, m2) = try await object.patternProperties.externallyDereferenced(with: loader) + try components.merge(c2) + messages += m2 + let newAdditionalProperties: Either? if case .b(let schema) = object.additionalProperties { - let (additionalProperties, c2, m2) = try await schema.externallyDereferenced(with: loader) - try components.merge(c2) - messages += m2 + let (additionalProperties, c3, m3) = try await schema.externallyDereferenced(with: loader) + try components.merge(c3) + messages += m3 newAdditionalProperties = .b(additionalProperties) } else { newAdditionalProperties = object.additionalProperties @@ -589,6 +607,7 @@ extension JSONSchema: ExternallyDereferenceable { core, .init( properties: newProperties, + patternProperties: newPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift index cac3517a36..129888695f 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift @@ -528,6 +528,7 @@ extension JSONSchema.ArrayContext { extension JSONSchema.ObjectContext { internal func combined(with other: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components) throws -> JSONSchema.ObjectContext { let combinedProperties = try combine(properties: properties, with: other.properties, resolvingIn: components) + let combinedPatternProperties = try combine(properties: patternProperties, with: other.patternProperties, resolvingIn: components) if let conflict = conflicting(maxProperties, other.maxProperties) { throw JSONSchemaResolutionError(.attributeConflict(jsonType: .object, name: "maxProperties", original: String(conflict.0), new: String(conflict.1))) @@ -559,6 +560,7 @@ extension JSONSchema.ObjectContext { let newAdditionalProperties = additionalProperties ?? other.additionalProperties return .init( properties: combinedProperties, + patternProperties: combinedPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: newMaxProperties, minProperties: newMinProperties @@ -712,6 +714,7 @@ extension JSONSchema.ObjectContext { } return .init( properties: properties, + patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: _minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index f6200e540f..a4c02beddb 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -1614,6 +1614,7 @@ extension JSONSchema { minProperties: Int? = nil, maxProperties: Int? = nil, properties: OrderedDictionary = [:], + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, allowedValues: [AnyCodable]? = nil, defaultValue: AnyCodable? = nil, @@ -1643,6 +1644,7 @@ extension JSONSchema { ) let objectContext = JSONSchema.ObjectContext( properties: properties, + patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index a517efe059..5d3a9ee67e 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -782,6 +782,10 @@ extension JSONSchema { /// allows you to omit the property from encoding. public let additionalProperties: Either? + /// Schemas keyed by regular expressions that matching property names + /// must satisfy. + public let patternProperties: OrderedDictionary + /// The properties of this object that are required. /// /// - Note: An object's required properties array @@ -811,11 +815,13 @@ extension JSONSchema { public init( properties: OrderedDictionary, + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties + self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -1260,6 +1266,7 @@ extension JSONSchema.ObjectContext { case maxProperties case minProperties case properties + case patternProperties case additionalProperties case required } @@ -1275,6 +1282,10 @@ extension JSONSchema.ObjectContext: Encodable { try container.encode(properties, forKey: .properties) } + if patternProperties.count > 0 { + try container.encode(patternProperties, forKey: .patternProperties) + } + try container.encodeIfPresent(additionalProperties, forKey: .additionalProperties) if !requiredProperties.isEmpty { @@ -1296,7 +1307,9 @@ extension JSONSchema.ObjectContext: Decodable { let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? [] let decodedProperties = try container.decodeIfPresent(OrderedDictionary.self, forKey: .properties) ?? [:] + let decodedPatternProperties = try container.decodeIfPresent(OrderedDictionary.self, forKey: .patternProperties) ?? [:] properties = Self.properties(decodedProperties, takingRequirementsFrom: requiredArray) + patternProperties = decodedPatternProperties.mapValues { $0.optionalSchemaObject() } } /// Make any property not in the given "required" array optional. diff --git a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift index 3c06ab72f3..a873d59892 100644 --- a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift @@ -108,6 +108,7 @@ extension DereferencedJSONSchema { core, .init( properties: try object.properties.mapValues { try $0.simplified() }, + patternProperties: try object.patternProperties.mapValues { try $0.simplified() }, additionalProperties: additionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index 327f8991ac..fdeb4d4c8a 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -341,6 +341,23 @@ extension Validation { ) } + /// Validate that all named OpenAPI `Server`s have unique names across the + /// whole Document. + /// + /// The OpenAPI Specification requires server names + /// [are unique](https://spec.openapis.org/oas/v3.2.0.html#server-object). + /// + /// - Important: This is included in validation by default. + public static var documentServerNamesAreUnique: Validation { + .init( + description: "The names of Servers in the Document are unique", + check: { context in + let serverNames = context.subject.allServers.compactMap(\.name) + return Set(serverNames).count == serverNames.count + } + ) + } + /// Validate that all OpenAPI Path Items have no duplicate parameters defined /// within them. /// @@ -377,6 +394,108 @@ extension Validation { ) } + /// Validate that `querystring` parameters are unique and do not coexist + /// with `query` parameters within a Path Item's effective operation + /// parameters. + /// + /// OpenAPI 3.2.0 requires that a `querystring` parameter + /// [must not appear more than once and must not appear in the same operation + /// as any `query` parameters](https://spec.openapis.org/oas/v3.2.0.html#parameter-locations). + /// + /// - Important: This is included in validation by default. + public static var querystringParametersAreCompatible: Validation { + .init( + description: "Querystring parameters are unique and do not coexist with query parameters", + check: { context in + let pathParameters = resolvedParameters(context.subject.parameters, components: context.document.components) + let pathSummary = ParameterLocationSummary(pathParameters) + let pathParametersPath = context.codingPath + [Validator.CodingKey.init(stringValue: "parameters")] + var errors = [ValidationError]() + let pathHasMultipleQuerystringParameters = pathSummary.querystringCount > 1 + let pathMixesQueryLocations = pathSummary.querystringCount > 0 && pathSummary.queryCount > 0 + + if pathHasMultipleQuerystringParameters { + errors.append( + ValidationError( + reason: "Path Item parameters must not contain more than one `querystring` parameter", + at: pathParametersPath + ) + ) + } + + if pathMixesQueryLocations { + errors.append( + ValidationError( + reason: "Path Item parameters must not mix `querystring` and `query` parameter locations", + at: pathParametersPath + ) + ) + } + + for endpoint in context.subject.endpoints { + let operationParameters = resolvedParameters(endpoint.operation.parameters, components: context.document.components) + let operationSummary = ParameterLocationSummary(operationParameters) + let operationParametersPath = context.codingPath + [ + Validator.CodingKey.init(stringValue: codingPathKey(for: endpoint.method)), + Validator.CodingKey.init(stringValue: "parameters") + ] + let operationHasMultipleQuerystringParameters = operationSummary.querystringCount > 1 + let operationMixesQueryLocations = operationSummary.querystringCount > 0 && + operationSummary.queryCount > 0 + let effectiveQuerystringCount = pathSummary.querystringCount + operationSummary.querystringCount + let effectiveQueryCount = pathSummary.queryCount + operationSummary.queryCount + let inheritedHasMultipleQuerystringParameters = + !pathHasMultipleQuerystringParameters && + !operationHasMultipleQuerystringParameters && + effectiveQuerystringCount > 1 + let inheritedMixesQueryLocations = + !pathMixesQueryLocations && + !operationMixesQueryLocations && + effectiveQuerystringCount > 0 && + effectiveQueryCount > 0 + + if operationHasMultipleQuerystringParameters { + errors.append( + ValidationError( + reason: "Operation parameters must not contain more than one `querystring` parameter", + at: operationParametersPath + ) + ) + } + + if operationMixesQueryLocations { + errors.append( + ValidationError( + reason: "Operation parameters must not mix `querystring` and `query` parameter locations", + at: operationParametersPath + ) + ) + } + + if inheritedHasMultipleQuerystringParameters { + errors.append( + ValidationError( + reason: "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters", + at: operationParametersPath + ) + ) + } + + if inheritedMixesQueryLocations { + errors.append( + ValidationError( + reason: "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters", + at: operationParametersPath + ) + ) + } + } + + return errors + } + ) + } + /// Validate that all OpenAPI Operation Ids are unique across the whole Document. /// /// The OpenAPI Specification requires that Operation Ids [are unique](https://spec.openapis.org/oas/v3.2.0.html#operation-object). @@ -575,9 +694,32 @@ extension Validation { /// Used by both the Path Item parameter check and the /// Operation parameter check in the default validations. fileprivate func parametersAreUnique(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> Bool { - let foundParameters = parameters.compactMap { try? components.lookup($0) } + let foundParameters = resolvedParameters(parameters, components: components) let identities = foundParameters.map { OpenAPI.Parameter.ParameterIdentity(name: $0.name, location: $0.location) } return Set(identities).count == foundParameters.count } + +fileprivate func resolvedParameters(_ parameters: OpenAPI.Parameter.Array, components: OpenAPI.Components) -> [OpenAPI.Parameter] { + parameters.compactMap { try? components.lookup($0) } +} + +fileprivate struct ParameterLocationSummary { + let queryCount: Int + let querystringCount: Int + + init(_ parameters: [OpenAPI.Parameter]) { + queryCount = parameters.filter { $0.location == .query }.count + querystringCount = parameters.filter { $0.location == .querystring }.count + } +} + +fileprivate func codingPathKey(for method: OpenAPI.HttpMethod) -> String { + switch method { + case .builtin(let builtin): + return builtin.rawValue.lowercased() + case .other(let other): + return other + } +} diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 4b8c5fadea..698fb4e957 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -75,8 +75,10 @@ extension OpenAPI.Document { /// The default validations are /// - Operations must contain at least one response. /// - Document-level tag names are unique. +/// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. +/// - Querystring parameters are unique and do not coexist with query parameters. /// - Operation Ids are unique across the whole Document. /// - All OpenAPI.References that refer to components in this /// document can be found in the components dictionary. @@ -156,8 +158,10 @@ public final class Validator { internal var nonReferenceDefaultValidations: [AnyValidation] = [ .init(.documentTagNamesAreUnique), + .init(.documentServerNamesAreUnique), .init(.pathItemParametersAreUnique), .init(.operationParametersAreUnique), + .init(.querystringParametersAreCompatible), .init(.operationIdsAreUnique), .init(.serverVariableEnumIsValid), .init(.serverVariableDefaultExistsInEnum), @@ -200,8 +204,10 @@ public final class Validator { /// /// The default validations are /// - Document-level tag names are unique. + /// - Server names are unique across the whole Document. /// - Parameters are unique within each Path Item. /// - Parameters are unique within each Operation. + /// - Querystring parameters are unique and do not coexist with query parameters. /// - Operation Ids are unique across the whole Document. /// - All OpenAPI.References that refer to components in this document can /// be found in the components dictionary. diff --git a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift index e2c211fb7e..931172928e 100644 --- a/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DereferencedDocumentTests.swift @@ -368,6 +368,59 @@ final class DereferencedDocumentTests: XCTestCase { XCTAssertEqual(schema?.anchor, "tupleAnchor") } + func test_locallyDereferencedResolvesSchemaAnchorReferencesFromPatternProperties() throws { + let anchoredPatternChild = JSONSchema.string( + .init(anchor: "patternAnchor"), + .init() + ) + let anchoredSchema = JSONSchema.object( + patternProperties: [ + "^x-": anchoredPatternChild + ] + ) + + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .pathItem( + .init( + get: .init( + responses: [ + 200: .response( + description: "success", + content: [ + .json: .content( + .init( + schema: .reference(.anchor(named: "patternAnchor")) + ) + ) + ] + ) + ] + ) + ) + ) + ], + components: .direct( + schemas: [ + "anchoredPatternSchema": anchoredSchema + ] + ) + ) + + let dereferencedDocument = try document.locallyDereferenced() + let schema = dereferencedDocument + .paths["/hello"]? + .get? + .responses[status: 200]? + .content[.json]? + .schema + + XCTAssertEqual(schema?.jsonType, .string) + XCTAssertEqual(schema?.anchor, "patternAnchor") + } + func test_locallyDereferencedFailsOnDuplicateSchemaAnchors() { let document = OpenAPI.Document( info: .init(title: "test", version: "1.0"), diff --git a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift index 6d3f1f2018..f775db6ee3 100644 --- a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift @@ -76,7 +76,17 @@ final class ExternalDereferencingDocumentTests: XCTestCase { """, "schemas_basic_object_json": """ { - "type": "object" + "type": "object", + "patternProperties": { + "^x-": { + "$ref": "file://./schemas/pattern_property.json" + } + } + } + """, + "schemas_pattern_property_json": """ + { + "type": "string" } """, "requests_hello_json": """ @@ -310,6 +320,10 @@ final class ExternalDereferencingDocumentTests: XCTestCase { // for this document, depth of 4 is enough for all the above to compare equally XCTAssertEqual(docCopy1, docCopy2) XCTAssertEqual(docCopy2, docCopy3) + XCTAssertEqual( + docCopy3.components.schemas["schemas_basic_object_json"]?.objectContext?.patternProperties["^x-"], + .reference(.component(named: "schemas_pattern_property_json"), required: false) + ) XCTAssertEqual( messages.sorted(), @@ -328,6 +342,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./requests/webhook.json", "file://./responses/webhook.json", "file://./schemas/basic_object.json", + "file://./schemas/pattern_property.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index f3bf0acf4e..5f639b190e 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -304,6 +304,12 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) + let tPattern = JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced() + XCTAssertEqual( + tPattern?.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + let t3 = JSONSchema.object( properties: [ "required": .string, @@ -320,6 +326,37 @@ final class DereferencedSchemaObjectTests: XCTestCase { ) } + func test_optionalObjectContextPatternPropertiesCanConvertBackToJSONSchema() throws { + let context = try XCTUnwrap( + DereferencedJSONSchema.ObjectContext( + .init( + properties: ["fixed": .string], + patternProperties: ["^x-": .boolean], + additionalProperties: .init(.integer) + ) + ) + ) + + XCTAssertEqual( + context.patternProperties["^x-"], + .boolean(.init()) + ) + XCTAssertEqual( + context.additionalProperties?.schemaValue, + .integer(.init(), .init()) + ) + + let schema = DereferencedJSONSchema.object(.init(), context).jsonSchema + XCTAssertEqual( + schema.objectContext, + .init( + properties: ["fixed": .string], + patternProperties: ["^x-": .boolean], + additionalProperties: .init(.integer) + ) + ) + } + func test_throwingObjectWithoutReferences() throws { let components = OpenAPI.Components.noComponents let t1 = try JSONSchema.object(properties: ["test": .string]).dereferenced(in: components) @@ -335,6 +372,12 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) + let tPattern = try JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced(in: components) + XCTAssertEqual( + tPattern.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + let t3 = try JSONSchema.object( properties: [ "required": .string, @@ -353,6 +396,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { func test_optionalObjectWithReferences() { XCTAssertNil(JSONSchema.object(properties: ["test": .reference(.component(named: "test"))]).dereferenced()) + XCTAssertNil(JSONSchema.object(patternProperties: ["^x-": .reference(.component(named: "missing"))]).dereferenced()) } func test_throwingObjectWithReferences() throws { @@ -515,6 +559,24 @@ final class DereferencedSchemaObjectTests: XCTestCase { } } + func test_simplifiedObjectWithPatternProperties() throws { + let simplified = try JSONSchema.object( + patternProperties: ["^x-": .all(of: [.string])], + additionalProperties: .init(.all(of: [.boolean])) + ) + .dereferenced(in: .noComponents) + .simplified() + + XCTAssertEqual( + simplified.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + XCTAssertEqual( + simplified.objectContext?.additionalProperties?.schemaValue, + .boolean(.init()) + ) + } + func test_withDescription() throws { let null = JSONSchema.null().dereferenced()!.with(description: "test") let object = JSONSchema.object.dereferenced()!.with(description: "test") diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift index 06c0ab59e2..154f5414ec 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift @@ -3237,6 +3237,55 @@ extension SchemaObjectTests { XCTAssertEqual(contextB, .init(properties: ["hello": .boolean(.init(format: .generic, required: false))], additionalProperties: .init(.string))) } + func test_encodeObjectWithPatternProperties() { + let object = JSONSchema.object( + .init(format: .unspecified, required: true), + .init( + properties: ["hello": .boolean(.init(format: .unspecified, required: false))], + patternProperties: ["^x-": .string(required: false)] + ) + ) + + testEncodingPropertyLines(entity: object, + propertyLines: [ + "\"patternProperties\" : {", + " \"^x-\" : {", + " \"type\" : \"string\"", + " }", + "},", + "\"properties\" : {", + " \"hello\" : {", + " \"type\" : \"boolean\"", + " }", + "},", + "\"type\" : \"object\"" + ]) + } + + func test_decodeObjectWithPatternProperties() { + let objectData = """ + { + "patternProperties": { + "^x-": { "type": "string" } + }, + "type": "object" + } + """.data(using: .utf8)! + + let object = try! orderUnstableDecode(JSONSchema.self, from: objectData) + + XCTAssertEqual( + object, + JSONSchema.object( + .init(format: .generic), + .init( + properties: [:], + patternProperties: ["^x-": .string(required: false)] + ) + ) + ) + } + func test_encodeObjectWithExample() { let string = try! JSONSchema.string(.init(format: .unspecified, required: true), .init()) .with(example: "hello") diff --git a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift index f4662cb81f..21657a6722 100644 --- a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift @@ -1074,6 +1074,22 @@ final class SchemaFragmentCombiningTests: XCTestCase { XCTAssert(error ~= .attributeConflict, "\(error) is not ~= `.attributeConflict` -- \(fragments)") } } + + let patternPropertyDifference: [JSONSchema] = [ + .object(.init(), .init(properties: [:], patternProperties: ["^x-": .boolean])), + .object(.init(), .init(properties: [:], patternProperties: ["^x-": .string])) + ] + XCTAssertThrowsError(try patternPropertyDifference.combined(resolvingAgainst: .noComponents)) + } + + func test_ObjectPatternPropertiesCombine() throws { + let combined = try [ + JSONSchema.object(patternProperties: ["^x-": .string]), + JSONSchema.object(patternProperties: ["^y-": .boolean]) + ].combined(resolvingAgainst: .noComponents) + + XCTAssertEqual(combined.objectContext?.patternProperties["^x-"], .string(.init(), .init())) + XCTAssertEqual(combined.objectContext?.patternProperties["^y-"], .boolean(.init())) } // MARK: - Inconsistency Failures diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index f8ffa15b14..73247c6667 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -24,11 +24,13 @@ final class BuiltinValidationTests: XCTestCase { ]) let withoutReferenceValidations = Validator().skippingReferenceValidations() - XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 7) + XCTAssertEqual(withoutReferenceValidations.validationDescriptions.count, 9) XCTAssertEqual(withoutReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", + "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -36,11 +38,13 @@ final class BuiltinValidationTests: XCTestCase { ]) let defaultValidations = Validator() - XCTAssertEqual(defaultValidations.validationDescriptions.count, 17) + XCTAssertEqual(defaultValidations.validationDescriptions.count, 19) XCTAssertEqual(defaultValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", + "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -58,11 +62,13 @@ final class BuiltinValidationTests: XCTestCase { ]) let stricterReferenceValidations = Validator().validatingAllReferencesFoundInComponents() - XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 17) + XCTAssertEqual(stricterReferenceValidations.validationDescriptions.count, 19) XCTAssertEqual(stricterReferenceValidations.validationDescriptions, [ "The names of Tags in the Document are unique", + "The names of Servers in the Document are unique", "Path Item parameters are unique (identity is defined by the \'name\' and \'location\')", "Operation parameters are unique (identity is defined by the \'name\' and \'location\')", + "Querystring parameters are unique and do not coexist with query parameters", "All Operation Ids in Document are unique", "Server Variable\'s enum is either not defined or is non-empty (if defined).", "Server Variable\'s default must exist in enum, if enum is defined.", @@ -561,6 +567,75 @@ final class BuiltinValidationTests: XCTestCase { try document.validate() } + func test_duplicateServerNamesOnDocumentFails() { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [ + .init(url: URL(string: "https://root.example.com")!, name: "shared") + ], + paths: [ + "/hello": .init( + get: .init( + responses: [ + 200: .response(description: "hi") + ], + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "shared") + ] + ) + ) + ], + components: .noComponents + ) + + XCTAssertThrowsError(try document.validate()) { error in + let error = error as? ValidationErrorCollection + XCTAssertEqual(error?.values.first?.reason, "Failed to satisfy: The names of Servers in the Document are unique") + XCTAssertEqual(error?.values.first?.codingPath.map { $0.stringValue }, []) + } + } + + func test_uniqueServerNamesOnDocumentSucceeds() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [ + .init(url: URL(string: "https://root.example.com")!, name: "root") + ], + paths: [ + "/hello": .init( + servers: [ + .init(url: URL(string: "https://path.example.com")!, name: "path"), + .init(url: URL(string: "https://unnamed-path.example.com")!) + ], + get: .init( + responses: [ + 200: .response(description: "hi") + ], + servers: [ + .init(url: URL(string: "https://operation.example.com")!, name: "operation"), + .init(url: URL(string: "https://unnamed-operation.example.com")!) + ] + ) + ) + ], + webhooks: [ + "/event": .init( + post: .init( + responses: [ + 200: .response(description: "ok") + ], + servers: [ + .init(url: URL(string: "https://webhook.example.com")!, name: "webhook") + ] + ) + ) + ], + components: .noComponents + ) + + try document.validate() + } + func test_duplicateOperationParameterFails() { let document = OpenAPI.Document( info: .init(title: "test", version: "1.0"), @@ -1411,4 +1486,135 @@ final class BuiltinValidationTests: XCTestCase { XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters[0]") } } + + func test_duplicateQuerystringParametersOnPathItem_fails() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + parameters: [ + .parameter(OpenAPI.Parameter.querystring(name: "first", content: [:])), + .parameter(OpenAPI.Parameter.querystring(name: "second", content: [:])) + ], + get: .init(responses: [:]) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.querystringParametersAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Path Item parameters must not contain more than one `querystring` parameter") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].parameters") + } + } + + func test_duplicateQuerystringParametersAcrossPathItemAndOperation_fails() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + parameters: [ + .parameter(OpenAPI.Parameter.querystring(name: "first", content: [:])) + ], + get: .init( + parameters: [ + .parameter(OpenAPI.Parameter.querystring(name: "second", content: [:])) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.querystringParametersAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not contain more than one `querystring` parameter, including inherited Path Item parameters") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") + } + } + + func test_querystringAndQueryParametersOnOperation_fails() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + parameters: [ + .parameter(OpenAPI.Parameter.query(name: "query", schema: .string)), + .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.querystringParametersAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") + } + } + + func test_querystringAndQueryParametersAcrossPathItemAndOperation_fails() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + parameters: [ + .parameter(OpenAPI.Parameter.query(name: "query", schema: .string)) + ], + get: .init( + parameters: [ + .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.querystringParametersAreCompatible) + + XCTAssertThrowsError(try document.validate(using: validator)) { error in + let errorCollection = error as? ValidationErrorCollection + XCTAssertEqual(errorCollection?.values.first?.reason, "Operation parameters must not mix `querystring` and `query` parameter locations, including inherited Path Item parameters") + XCTAssertEqual(errorCollection?.values.first?.codingPath.stringValue, ".paths[\'/hello\'].get.parameters") + } + } + + func test_singleQuerystringParameter_succeeds() throws { + let document = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello": .init( + get: .init( + parameters: [ + .parameter(OpenAPI.Parameter.querystring(name: "querystring", content: [:])) + ], + responses: [:] + ) + ) + ], + components: .noComponents + ) + + let validator = Validator.blank.validating(.querystringParametersAreCompatible) + try document.validate(using: validator) + } } From 62c092005b7f78a182214d1b8f41d0b3789aa6e9 Mon Sep 17 00:00:00 2001 From: Ayush Srivastava Date: Fri, 10 Apr 2026 20:14:25 +0530 Subject: [PATCH 13/13] Trigger CI rerun