From 8710e388ceaf515d5402c1fac25cac0c9c6bc0c7 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 18 Nov 2025 19:34:10 -0600 Subject: [PATCH 01/12] align existing schema property with spec --- .../Components+JSONReference.swift | 57 +++++++++++++ Sources/OpenAPIKit/Content/Content.swift | 25 ++++-- .../OpenAPIKit/Schema Object/JSONSchema.swift | 5 ++ .../Validator/Validation+Builtins.swift | 26 +++++- Sources/OpenAPIKit/Validator/Validator.swift | 1 + Sources/OpenAPIKitCompat/Compat30To31.swift | 11 ++- .../DocumentConversionTests.swift | 8 +- .../SchemaErrorTests.swift | 2 +- Tests/OpenAPIKitTests/ComponentsTests.swift | 80 +++++++++++++++---- .../Content/ContentTests.swift | 13 +-- .../Request/RequestTests.swift | 6 +- .../Response/ResponseTests.swift | 2 +- .../Validator/ValidatorTests.swift | 16 ++-- documentation/validation.md | 8 +- 14 files changed, 204 insertions(+), 56 deletions(-) diff --git a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift index fc709c9b2..5a10f642d 100644 --- a/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift +++ b/Sources/OpenAPIKit/Components Object/Components+JSONReference.swift @@ -114,6 +114,29 @@ extension OpenAPI.Components { return self[localReference] } + /// Retrieve schema from the `Components`. If the JSONSchema is not a + /// reference, it will be returned as-is. If it is a reference, it will be + /// looked up in the components if it refers to a components entry. If the + /// reference does not refer to a components entry, the function will return + /// `nil`. + /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. If a cycle is + /// encountered or a reference to a part of the document outside of the + /// `Components` is encountered then the function returns `nil`. + /// + /// If you want a throwing lookup, use the `lookup()` method. + public subscript(_ schema: JSONSchema) -> JSONSchema? { + guard case .reference(let reference, _) = schema.value else { + return schema + } + guard case .internal(let localReference) = reference else { + return nil + } + + return self[localReference] + } + /// Retrieve item referenced from the `Components`. /// /// This function will follow subsequent refernences found within the @@ -328,6 +351,13 @@ extension OpenAPI.Components { guard let value else { throw ReferenceError.missingOnLookup(name: reference.name ?? "unnamed", key: ReferenceType.openAPIComponentsKey) } + + // special case for JSONSchemas that are references + if let schema = value as? JSONSchema, + case let .reference(newReference, _) = schema.value { + return try _lookup(newReference, following: visitedReferences.union([reference])) as! ReferenceType + } + return value case .b(let referencePath): @@ -374,6 +404,33 @@ extension OpenAPI.Components { } } + /// Lookup schema in the `Components`. If the JSONSchema is not a + /// reference, it will be returned as-is. If it is a reference, it will be + /// looked up in the components if it refers to a components entry. + /// + /// This function will follow subsequent refernences found within the + /// `Components` as long as no cycles are encountered. + /// + /// If you want to look something up without throwing, you might want to use the subscript + /// operator on the `Components`. + /// + /// If you also want to fully dereference the value in question instead + /// of just looking it up see the various `dereference` functions + /// on this type for more information. + /// + /// - Important: Looking up an external reference (i.e. one that points to another file) + /// is not currently supported by OpenAPIKit and will therefore always throw an error. + /// + /// - Throws: `ReferenceError.cannotLookupRemoteReference` or + /// `ReferenceError.missingOnLookup(name:,key:)` or + /// `ReferenceCycleError` + public func lookup(_ schema: JSONSchema) throws -> JSONSchema { + guard case .reference(let reference, _) = schema.value else { + return schema + } + return try lookup(reference) + } + /// Create an `OpenAPI.Reference`. /// /// - Throws: If the given name does not refer to an existing component of the given type. diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 0d45bd53b..754e9ad6f 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -12,7 +12,7 @@ extension OpenAPI { /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). public struct Content: Equatable, CodableVendorExtendable, Sendable { - public var schema: Either, JSONSchema>? + public var schema: JSONSchema? public var example: AnyCodable? public var examples: Example.Map? public var encoding: OrderedDictionary? @@ -27,7 +27,7 @@ extension OpenAPI { /// Create `Content` with a schema, a reference to a schema, or no /// schema at all and optionally provide a single example. public init( - schema: Either, JSONSchema>?, + schema: JSONSchema?, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] @@ -47,7 +47,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schemaReference) + self.schema = .reference(schemaReference.jsonReference) self.example = example self.examples = nil self.encoding = encoding @@ -62,7 +62,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + self.schema = schema self.example = example self.examples = nil self.encoding = encoding @@ -77,7 +77,16 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = schema + switch schema { + case .none: + self.schema = nil + + case .some(.a(let reference)): + self.schema = .reference(reference.jsonReference) + + case .some(.b(let schemaValue)): + self.schema = schemaValue + } self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -92,7 +101,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schemaReference) + self.schema = .reference(schemaReference.jsonReference) self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -107,7 +116,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + self.schema = schema self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding @@ -179,7 +188,7 @@ extension OpenAPI.Content: Decodable { ) } - schema = try container.decodeIfPresent(Either, JSONSchema>.self, forKey: .schema) + schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index fa4a2b28b..0a4202137 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -358,6 +358,11 @@ extension JSONSchema { guard case .reference = value else { return false } return true } + + public var reference: JSONReference? { + guard case let .reference(reference, _) = value else { return nil } + return reference + } } // MARK: - Context Accessors diff --git a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift index def7c7f79..672d38a67 100644 --- a/Sources/OpenAPIKit/Validator/Validation+Builtins.swift +++ b/Sources/OpenAPIKit/Validator/Validation+Builtins.swift @@ -254,14 +254,14 @@ extension Validation { ) } - /// Validate that all non-external JSONSchema references are found in the document's + /// Validate that all non-external OpenAPI JSONSchema references are found in the document's /// components dictionary. /// /// - Important: This is included in validation by default. /// public static var schemaReferencesAreValid: Validation> { .init( - description: "JSONSchema reference can be found in components/schemas", + description: "OpenAPI JSONSchema reference can be found in components/schemas", check: { context in guard case let .internal(internalReference) = context.subject.jsonReference, case .component = internalReference else { @@ -276,6 +276,28 @@ extension Validation { ) } + /// Validate that all non-external JSONSchema references are found in the document's + /// components dictionary. + /// + /// - Important: This is included in validation by default. + /// + public static var jsonSchemaReferencesAreValid: Validation { + .init( + description: "JSONSchema reference can be found in components/schemas", + check: { context in + guard case let .internal(internalReference) = context.subject.reference, + case .component = internalReference else { + // don't make assertions about external references + // TODO: could make a stronger assertion including + // internal references outside of components given + // some way to resolve those references. + return true + } + return context.document.components.contains(internalReference) + } + ) + } + /// Validate that all non-external Response references are found in the document's /// components dictionary. /// diff --git a/Sources/OpenAPIKit/Validator/Validator.swift b/Sources/OpenAPIKit/Validator/Validator.swift index 66fcd8611..bfaa7174b 100644 --- a/Sources/OpenAPIKit/Validator/Validator.swift +++ b/Sources/OpenAPIKit/Validator/Validator.swift @@ -184,6 +184,7 @@ public final class Validator { .init(.operationParametersAreUnique), .init(.operationIdsAreUnique), .init(.schemaReferencesAreValid), + .init(.jsonSchemaReferencesAreValid), .init(.responseReferencesAreValid), .init(.parameterReferencesAreValid), .init(.exampleReferencesAreValid), diff --git a/Sources/OpenAPIKitCompat/Compat30To31.swift b/Sources/OpenAPIKitCompat/Compat30To31.swift index 2ab98b06c..df5b3193b 100644 --- a/Sources/OpenAPIKitCompat/Compat30To31.swift +++ b/Sources/OpenAPIKitCompat/Compat30To31.swift @@ -247,8 +247,17 @@ extension OpenAPIKit30.OpenAPI.Content: To31 { vendorExtensions: vendorExtensions ) } else { + let eitherRef = schema.map(eitherRefTo31) + let schema: OpenAPIKit.JSONSchema? = switch eitherRef { + case .none: + nil + case .a(let reference): + .reference(reference.jsonReference) + case .b(let value): + value + } return OpenAPIKit.OpenAPI.Content( - schema: schema.map(eitherRefTo31), + schema: schema, example: example, encoding: encoding?.mapValues { $0.to31() }, vendorExtensions: vendorExtensions diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 9568e15e1..029f135c4 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1086,11 +1086,11 @@ fileprivate func assertEqualNewToOld(_ newRequest: OpenAPIKit.OpenAPI.Request, _ fileprivate func assertEqualNewToOld(_ newContentMap: OpenAPIKit.OpenAPI.Content.Map, _ oldContentMap: OpenAPIKit30.OpenAPI.Content.Map) throws { for ((newCt, newContent), (oldCt, oldContent)) in zip(newContentMap, oldContentMap) { XCTAssertEqual(newCt, oldCt) - switch (newContent.schema, oldContent.schema) { - case (.a(let ref1), .a(let ref2)): + switch (newContent.schema?.value, oldContent.schema) { + case (.reference(let ref1, _), .a(let ref2)): XCTAssertEqual(ref1.absoluteString, ref2.absoluteString) - case (.b(let schema1), .b(let schema2)): - try assertEqualNewToOld(schema1, schema2) + case (let .some(schema1), .b(let schema2)): + try assertEqualNewToOld(.init(schema:schema1), schema2) default: XCTFail("Found one reference and one schema. \(String(describing: newContent.schema)) / \(String(describing: oldContent.schema))") } diff --git a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift index 4aaca5ff4..a8f913f35 100644 --- a/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift +++ b/Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift @@ -35,7 +35,7 @@ final class SchemaErrorTests: XCTestCase { let openAPIError = OpenAPI.Error(from: error) - XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a JSONSchema in .content['application/json'].schema for the status code '200' response of the **GET** endpoint under `/hello/world`. \n\nJSONSchema could not be decoded because:\nProblem encountered when parsing `maximum`: Expected an Integer literal but found a floating point value (1.234)..") + XCTAssertEqual(openAPIError.localizedDescription, "Problem encountered when parsing `maximum` in .content['application/json'].schema for the status code '200' response of the **GET** endpoint under `/hello/world`: Expected an Integer literal but found a floating point value (1.234).") XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ "paths", "/hello/world", diff --git a/Tests/OpenAPIKitTests/ComponentsTests.swift b/Tests/OpenAPIKitTests/ComponentsTests.swift index 3a7ad1d35..0d15cca4c 100644 --- a/Tests/OpenAPIKitTests/ComponentsTests.swift +++ b/Tests/OpenAPIKitTests/ComponentsTests.swift @@ -28,7 +28,8 @@ final class ComponentsTests: XCTestCase { func test_directConstructor() { let c1 = OpenAPI.Components( schemas: [ - "one": .string + "one": .string, + "ref": .reference(.component(named: "one")) ], responses: [ "two": .response(.init(description: "hello", content: [:])) @@ -74,7 +75,8 @@ final class ComponentsTests: XCTestCase { let c2 = OpenAPI.Components.direct( schemas: [ - "one": .string + "one": .string, + "ref": .reference(.component(named: "one")) ], responses: [ "two": .init(description: "hello", content: [:]) @@ -125,7 +127,8 @@ final class ComponentsTests: XCTestCase { let components = OpenAPI.Components( schemas: [ "hello": .string, - "world": .integer(required: false) + "world": .integer(required: false), + "ref": .reference(.component(named: "hello")) ], parameters: [ "my_direct_param": .parameter(.cookie(name: "my_param", schema: .string)), @@ -135,30 +138,33 @@ final class ComponentsTests: XCTestCase { let ref1 = JSONReference.component(named: "world") let ref2 = JSONReference.component(named: "missing") - let ref3 = JSONReference.component(named: "param") + let ref3 = JSONReference.component(named: "ref") + let ref4 = JSONReference.component(named: "param") XCTAssertEqual(components[ref1], .integer(required: false)) XCTAssertEqual(try? components.lookup(ref1), components[ref1]) XCTAssertNil(components[ref2]) - XCTAssertNil(components[ref3]) + XCTAssertEqual(components[ref3], .string) + XCTAssertEqual(try? components.lookup(ref3), components[ref3]) + XCTAssertNil(components[ref4]) - let ref4 = JSONReference.InternalReference.component(name: "world") - let ref5 = JSONReference.InternalReference.component(name: "missing") - let ref6 = JSONReference.InternalReference.component(name: "param") + let ref5 = JSONReference.InternalReference.component(name: "world") + let ref6 = JSONReference.InternalReference.component(name: "missing") + let ref7 = JSONReference.InternalReference.component(name: "param") - XCTAssertEqual(components[ref4], .integer(required: false)) - XCTAssertNil(components[ref5]) + XCTAssertEqual(components[ref5], .integer(required: false)) XCTAssertNil(components[ref6]) + XCTAssertNil(components[ref7]) - let ref7 = JSONReference.component(named: "my_param") + let ref8 = JSONReference.component(named: "my_param") - XCTAssertEqual(components[ref7], .cookie(name: "my_param", schema: .string)) + XCTAssertEqual(components[ref8], .cookie(name: "my_param", schema: .string)) - let ref8 = JSONReference.external(URL(string: "hello.json")!) + let ref9 = JSONReference.external(URL(string: "hello.json")!) - XCTAssertNil(components[ref8]) + XCTAssertNil(components[ref9]) - XCTAssertThrowsError(try components.contains(ref8)) + XCTAssertThrowsError(try components.contains(ref9)) } func test_lookupOnce() throws { @@ -411,6 +417,50 @@ extension ComponentsTests { XCTAssertEqual(decoded, OpenAPI.Components()) } + func test_schemaReference_encode() throws { + let t1 = OpenAPI.Components( + schemas: [ + "ref": .reference(.external(URL(string: "./hi.yml")!)) + ] + ) + + let encoded = try orderUnstableTestStringFromEncoding(of: t1) + + assertJSONEquivalent( + encoded, + """ + { + "schemas" : { + "ref" : { + "$ref" : ".\\/hi.yml" + } + } + } + """ + ) + } + + func test_schemaReference_decode() throws { + let t1 = + """ + { + "schemas" : { + "ref" : { + "$ref" : "./hi.yml" + } + } + } + """.data(using: .utf8)! + + let decoded = try orderUnstableDecode(OpenAPI.Components.self, from: t1) + + XCTAssertEqual(decoded, OpenAPI.Components( + schemas: [ + "ref": .reference(.external(URL(string: "./hi.yml")!)) + ] + )) + } + func test_maximal_encode() throws { let t1 = OpenAPI.Components.direct( schemas: [ diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index e4930f4e8..c38519969 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -11,17 +11,14 @@ import OpenAPIKit final class ContentTests: XCTestCase { func test_init() { - let t1 = OpenAPI.Content(schema: .init(.external(URL(string:"hello.json#/world")!))) + let t1 = OpenAPI.Content(schema: .reference(.external(URL(string:"hello.json#/world")!))) XCTAssertNotNil(t1.schema?.reference) - XCTAssertNil(t1.schema?.schemaValue) let t2 = OpenAPI.Content(schema: .init(.string)) - XCTAssertNotNil(t2.schema?.schemaValue) XCTAssertNil(t2.schema?.reference) let t3 = OpenAPI.Content(schemaReference: .external(URL(string: "hello.json#/world")!)) XCTAssertNotNil(t3.schema?.reference) - XCTAssertNil(t3.schema?.schemaValue) let withExample = OpenAPI.Content( schema: .init(.string), @@ -52,13 +49,11 @@ final class ContentTests: XCTestCase { examples: nil ) XCTAssertNotNil(t4.schema?.reference) - XCTAssertNil(t4.schema?.schemaValue) let t5 = OpenAPI.Content( schema: .string, examples: nil ) - XCTAssertNotNil(t5.schema?.schemaValue) XCTAssertNil(t5.schema?.reference) let _ = OpenAPI.Content( @@ -98,7 +93,7 @@ final class ContentTests: XCTestCase { .tar: .init(schema: .init(.boolean)), .tif: .init(schema: .init(.string(contentEncoding: .binary))), .txt: .init(schema: .init(.number)), - .xml: .init(schema: .init(.external(URL(string: "hello.json#/world")!))), + .xml: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))), .yaml: .init(schema: .init(.string)), .zip: .init(schema: .init(.string)), @@ -118,7 +113,7 @@ final class ContentTests: XCTestCase { // MARK: - Codable extension ContentTests { func test_referenceContent_encode() { - let content = OpenAPI.Content(schema: .init(.external(URL(string: "hello.json#/world")!))) + let content = OpenAPI.Content(schema: .reference(.external(URL(string: "hello.json#/world")!))) let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) assertJSONEquivalent( @@ -144,7 +139,7 @@ extension ContentTests { """.data(using: .utf8)! let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) - XCTAssertEqual(content, OpenAPI.Content(schema: .init(.external(URL(string: "hello.json#/world")!)))) + XCTAssertEqual(content, OpenAPI.Content(schema: .reference(.external(URL(string: "hello.json#/world")!)))) } func test_schemaContent_encode() { diff --git a/Tests/OpenAPIKitTests/Request/RequestTests.swift b/Tests/OpenAPIKitTests/Request/RequestTests.swift index a30190e6f..efa58f603 100644 --- a/Tests/OpenAPIKitTests/Request/RequestTests.swift +++ b/Tests/OpenAPIKitTests/Request/RequestTests.swift @@ -37,7 +37,7 @@ final class RequestTests: XCTestCase { ]) let _ = OpenAPI.Request(content: [ - .json: .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ]) } } @@ -61,7 +61,7 @@ extension RequestTests { func test_onlyReferenceContent_encode() { let request = OpenAPI.Request(content: [ - .json: .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json: .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ]) let encodedString = try! orderUnstableTestStringFromEncoding(of: request) @@ -86,7 +86,7 @@ extension RequestTests { let request = try! orderUnstableDecode(OpenAPI.Request.self, from: requestData) XCTAssertEqual(request, OpenAPI.Request(content: [ - .json : .init(schema: .init(.external(URL(string: "hello.json#/world")!))) + .json : .init(schema: .reference(.external(URL(string: "hello.json#/world")!))) ])) } diff --git a/Tests/OpenAPIKitTests/Response/ResponseTests.swift b/Tests/OpenAPIKitTests/Response/ResponseTests.swift index 0d85c85ae..fcc27d71f 100644 --- a/Tests/OpenAPIKitTests/Response/ResponseTests.swift +++ b/Tests/OpenAPIKitTests/Response/ResponseTests.swift @@ -17,7 +17,7 @@ final class ResponseTests: XCTestCase { XCTAssertNil(r1.headers) XCTAssertEqual(r1.content, [:]) - let content = OpenAPI.Content(schema: .init(OpenAPI.Reference.external(URL(string: "hello.yml")!))) + let content = OpenAPI.Content(schema: .reference(.external(URL(string: "hello.yml")!))) let header = OpenAPI.Header(schemaOrContent: .init(.header(.string))) let r2 = OpenAPI.Response(description: "", headers: ["hello": .init(header)], diff --git a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift index 6c17f8b29..54bb6f7b5 100644 --- a/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift +++ b/Tests/OpenAPIKitTests/Validator/ValidatorTests.swift @@ -1157,20 +1157,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( @@ -1287,20 +1287,20 @@ final class ValidatorTests: XCTestCase { let requestBodyContainsName = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName ), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) let responseBodyContainsNameAndId = Validation( check: unwrap( - \.content[.json]?.schema?.schemaValue, + \.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId ), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) let successResponseBodyContainsNameAndId = Validation( diff --git a/documentation/validation.md b/documentation/validation.md index 8744ce1b2..904e0798f 100644 --- a/documentation/validation.md +++ b/documentation/validation.md @@ -487,17 +487,17 @@ let responseResourceContainsId = Validation( // clause to skip over any requests that do not have such schemas // without error. let requestBodyContainsName = Validation( - check: unwrap(\.content[.json]?.schema?.schemaValue, into: resourceContainsName), + check: unwrap(\.content[.json]?.schema, into: resourceContainsName), - when: \OpenAPI.Request.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Request.content[.json]?.schema != nil ) // Similarly, we check JSON response schemas. This time we check // for both a 'name' and an 'id'. let responseBodyContainsNameAndId = Validation( - check: unwrap(\.content[.json]?.schema?.schemaValue, into: resourceContainsName, responseResourceContainsId), + check: unwrap(\.content[.json]?.schema, into: resourceContainsName, responseResourceContainsId), - when: \OpenAPI.Response.content[.json]?.schema?.schemaValue != nil + when: \OpenAPI.Response.content[.json]?.schema != nil ) // We are specifically looking only at 201 ("created") status code From eb8de23787a7086f93ef3a081d8483552d49c381 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 19 Nov 2025 10:31:14 -0600 Subject: [PATCH 02/12] fix component name tracking for jsonschema reference dereferencing --- Sources/OpenAPIKit/Content/Content.swift | 2 +- .../Schema Object/DereferencedJSONSchema.swift | 4 ++-- .../DereferencedSchemaObjectTests.swift | 14 +++++++------- .../SchemaFragmentCombiningTests.swift | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 754e9ad6f..05576fef4 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -47,7 +47,7 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .reference(schemaReference.jsonReference) + self.schema = .reference(schemaReference.jsonReference, description: schemaReference.description) self.example = example self.examples = nil self.encoding = encoding diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index d1a86b33d..43e4ef873 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -485,10 +485,10 @@ extension JSONSchema: LocallyDereferenceable { // TODO: consider which other core context properties to override here as with description ^ var extensions = dereferenced.vendorExtensions - if let name { + if let name = name ?? reference.name { extensions[OpenAPI.Components.componentNameExtension] = .init(name) } - dereferenced = dereferenced.with(vendorExtensions: vendorExtensions) + dereferenced = dereferenced.with(vendorExtensions: extensions) return dereferenced case .boolean(let context): diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index 21502fbeb..ca1ea9e51 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -278,7 +278,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { schemas: ["test": .string] ) let t1 = try JSONSchema.reference(.component(named: "test")).dereferenced(in: components) - XCTAssertEqual(t1, .string(.init(), .init())) + XCTAssertEqual(t1, .string(.init(vendorExtensions: ["x-component-name": "test"]), .init())) } func test_throwingReferenceWithOverriddenDescription() throws { @@ -286,7 +286,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { schemas: ["test": .string] ) let t1 = try JSONSchema.reference(.component(named: "test"), description: "hello").dereferenced(in: components) - XCTAssertEqual(t1, .string(.init(description: "hello"), .init())) + XCTAssertEqual(t1, .string(.init(description: "hello", vendorExtensions: ["x-component-name": "test"]), .init())) } func test_optionalObjectWithoutReferences() { @@ -360,7 +360,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.object(properties: ["test": .reference(.component(named: "test"))]).dereferenced(in: components) XCTAssertEqual( t1, - .object(.init(), DereferencedJSONSchema.ObjectContext(.init(properties: ["test": .boolean]))!) + .object(.init(), DereferencedJSONSchema.ObjectContext(.init(properties: ["test": .boolean(.init(vendorExtensions: ["x-component-name": "test"]))]))!) ) XCTAssertThrowsError(try JSONSchema.object(properties: ["missing": .reference(.component(named: "missing"))]).dereferenced(in: components)) } @@ -394,7 +394,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.array(items: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .array(.init(), DereferencedJSONSchema.ArrayContext(.init(items: .boolean))!) + .array(.init(), DereferencedJSONSchema.ArrayContext(.init(items: .boolean(.init(vendorExtensions: ["x-component-name": "test"]))))!) ) XCTAssertThrowsError(try JSONSchema.array(items: .reference(.component(named: "missing"))).dereferenced(in: components)) } @@ -427,7 +427,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.one(of: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .one(of: [.boolean(.init())], core: .init()) + .one(of: [.boolean(.init(vendorExtensions: ["x-component-name": "test"]))], core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.one(of: .reference(.component(named: "missing"))).dereferenced(in: components)) @@ -461,7 +461,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.any(of: .reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .any(of: [.boolean(.init())], core: .init()) + .any(of: [.boolean(.init(vendorExtensions: ["x-component-name": "test"]))], core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.any(of: .reference(.component(named: "missing"))).dereferenced(in: components)) @@ -489,7 +489,7 @@ final class DereferencedSchemaObjectTests: XCTestCase { let t1 = try JSONSchema.not(.reference(.component(named: "test"))).dereferenced(in: components) XCTAssertEqual( t1, - .not(.boolean(.init()), core: .init()) + .not(.boolean(.init(vendorExtensions: ["x-component-name": "test"])), core: .init()) ) XCTAssertEqual(t1.coreContext as? JSONSchema.CoreContext, .init()) XCTAssertThrowsError(try JSONSchema.not(.reference(.component(named: "missing"))).dereferenced(in: components)) diff --git a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift index c6cc5cf6a..fd529f6d5 100644 --- a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift @@ -575,7 +575,7 @@ final class SchemaFragmentCombiningTests: XCTestCase { let schema1 = try t1.combined(resolvingAgainst: components) XCTAssertEqual( schema1, - JSONSchema.string.dereferenced() + JSONSchema.string(.init(), .init()).dereferenced() ) let t2 = [ @@ -585,7 +585,7 @@ final class SchemaFragmentCombiningTests: XCTestCase { let schema2 = try t2.combined(resolvingAgainst: components) XCTAssertEqual( schema2, - JSONSchema.object(description: "test", properties: ["test": .string]).dereferenced() + JSONSchema.object(description: "test", properties: ["test": .string(.init(vendorExtensions: ["x-component-name": "test"]), .init())]).dereferenced() ) } From 1a2c840f8469031547b50bd8789c821a85f2565b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Wed, 19 Nov 2025 21:21:52 -0600 Subject: [PATCH 03/12] addition of positional schema, encoding for media type object --- Sources/OpenAPIKit/Content/Content.swift | 204 +++++++++++++++++- .../Content/ContentPositionalEncoding.swift | 41 ++++ .../Content/DereferencedContent.swift | 52 ++++- ...ereferencedContentPositionalEncoding.swift | 38 ++++ .../Either/Either+Convenience.swift | 11 + .../DocumentConversionTests.swift | 1 + .../Content/ContentTests.swift | 200 +++++++++++++++++ .../Content/DereferencedContentTests.swift | 8 +- 8 files changed, 530 insertions(+), 25 deletions(-) create mode 100644 Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift create mode 100644 Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index 05576fef4..f923b9510 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -12,10 +12,31 @@ extension OpenAPI { /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). public struct Content: Equatable, CodableVendorExtendable, Sendable { + /// A schema describing the complete content of the request, response, + /// parameter, or header. public var schema: JSONSchema? + + /// A schema describing each item within a sequential media type. + public var itemSchema: JSONSchema? + public var example: AnyCodable? public var examples: Example.Map? - public var encoding: OrderedDictionary? + + /// Provide either a map of encodings or some combination of prefix- + /// and item- positional encodings. + /// + /// If the OpenAPI Document specifies the 'encoding' key (a map) + /// then this property will be set to its first case. If the OpenAPI + /// Document specifies either or both of the 'prefixEncoding' and + /// 'itemEncoding' keys, this property will be set to its second case. + /// + /// You can access the encoding map (OAS 'encoding' property) as the `Content` + /// type's `encodingMap` as well. + /// + /// You can access the positional encoding (OAS 'prefixEncoding' and + /// `itemEncoding` properties) as the `Content` type's `prefixEncoding` + /// and `itemEncoding` properties. + public var encoding: Either, PositionalEncoding>? /// Dictionary of vendor extensions. /// @@ -24,18 +45,87 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// The encoding of this `Content` (Media Type Object) if it is a map + /// from property names to encoding information. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `prefixEncoding` and `itemEncoding` properties. + public var encodingMap: OrderedDictionary? { encoding?.a } + + /// The positional prefix-encoding for this `Content` (Media Type + /// Object) if set. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `itemEncoding` and `encodingMap` properties. + public var prefixEncoding: [Encoding]? { encoding?.b?.prefixEncoding } + + /// The positional item-encoding for this `Content` (Media Type + /// Object) if set. + /// + /// This property gets modified as part of the `encoding` property. + /// + /// See also the `prefixEncoding` and `encodingMap` properties. + public var itemEncoding: Encoding? { encoding?.b?.itemEncoding } + /// Create `Content` with a schema, a reference to a schema, or no /// schema at all and optionally provide a single example. public init( schema: JSONSchema?, + itemSchema: JSONSchema? = nil, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema + self.example = example + self.examples = nil + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema, a reference to a schema, or no + /// schema at all and optionally provide a single example. + public init( + schema: JSONSchema?, + itemSchema: JSONSchema? = nil, + example: AnyCodable? = nil, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = schema + self.itemSchema = itemSchema + self.example = example + self.examples = nil + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema, a reference to a schema, or no + /// schema at all and optionally provide a single example. + public init( + itemSchema: JSONSchema?, + example: AnyCodable? = nil, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema self.example = example self.examples = nil - self.encoding = encoding + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } self.vendorExtensions = vendorExtensions } @@ -50,7 +140,7 @@ extension OpenAPI { self.schema = .reference(schemaReference.jsonReference, description: schemaReference.description) self.example = example self.examples = nil - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -58,14 +148,16 @@ extension OpenAPI { /// example. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema self.example = example self.examples = nil - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -89,7 +181,7 @@ extension OpenAPI { } self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -104,7 +196,7 @@ extension OpenAPI { self.schema = .reference(schemaReference.jsonReference) self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions } @@ -112,14 +204,53 @@ extension OpenAPI { /// of examples. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, examples: Example.Map?, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { self.schema = schema + self.itemSchema = itemSchema + self.examples = examples + self.example = examples.flatMap(Self.firstExample(from:)) + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema and optionally provide a map + /// of examples. + public init( + itemSchema: JSONSchema?, + examples: Example.Map?, + encoding: OrderedDictionary? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema + self.examples = examples + self.example = examples.flatMap(Self.firstExample(from:)) + self.encoding = encoding.map(Either.a) + self.vendorExtensions = vendorExtensions + } + + /// Create `Content` with a schema and optionally provide a map + /// of examples. + public init( + itemSchema: JSONSchema? = nil, + examples: Example.Map?, + prefixEncoding: [Encoding] = [], + itemEncoding: Encoding? = nil, + vendorExtensions: [String: AnyCodable] = [:] + ) { + self.schema = nil + self.itemSchema = itemSchema self.examples = examples self.example = examples.flatMap(Self.firstExample(from:)) - self.encoding = encoding + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + self.encoding = nil + } self.vendorExtensions = vendorExtensions } } @@ -159,6 +290,7 @@ extension OpenAPI.Content: Encodable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(schema, forKey: .schema) + try container.encodeIfPresent(itemSchema, forKey: .itemSchema) // only encode `examples` if non-nil, // otherwise encode `example` if non-nil @@ -168,7 +300,18 @@ extension OpenAPI.Content: Encodable { try container.encode(example, forKey: .example) } - try container.encodeIfPresent(encoding, forKey: .encoding) + if let encoding { + switch encoding { + case .a(let encoding): + try container.encode(encoding, forKey: .encoding) + + case .b(let positionalEncoding): + if !positionalEncoding.prefixEncoding.isEmpty { + try container.encode(positionalEncoding.prefixEncoding, forKey: .prefixEncoding) + } + try container.encodeIfPresent(positionalEncoding.itemEncoding, forKey: .itemEncoding) + } + } if VendorExtensionsConfiguration.isEnabled(for: encoder) { try encodeExtensions(to: &container) @@ -188,9 +331,27 @@ extension OpenAPI.Content: Decodable { ) } + guard !(container.contains(.encoding) && (container.contains(.prefixEncoding) || container.contains(.itemEncoding))) else { + throw GenericError( + subjectName: "Encoding and Positional Encoding", + details: "If `prefixEncoding` or `itemEncoding` are specified then `encoding` is not allowed in the Media Type Object (`OpenAPI.Content`).", + codingPath: container.codingPath + ) + } + schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) + itemSchema = try container.decodeIfPresent(JSONSchema.self, forKey: .itemSchema) + + if container.contains(.encoding) { + encoding = .a(try container.decode(OrderedDictionary.self, forKey: .encoding)) + } else if container.contains(.prefixEncoding) || container.contains(.itemEncoding) { + let prefixEncoding = try container.decodeIfPresent([Encoding].self, forKey: .prefixEncoding) ?? [] + let itemEncoding = try container.decodeIfPresent(Encoding.self, forKey: .itemEncoding) - encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) + encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + encoding = nil + } if container.contains(.example) { example = try container.decode(AnyCodable.self, forKey: .example) @@ -208,13 +369,24 @@ extension OpenAPI.Content: Decodable { extension OpenAPI.Content { internal enum CodingKeys: ExtendableCodingKey { case schema + case itemSchema case example // `example` and `examples` are mutually exclusive case examples // `example` and `examples` are mutually exclusive case encoding + case itemEncoding + case prefixEncoding case extended(String) static var allBuiltinKeys: [CodingKeys] { - return [.schema, .example, .examples, .encoding] + return [ + .schema, + .itemSchema, + .example, + .examples, + .encoding, + .itemEncoding, + .prefixEncoding + ] } static func extendedKey(for value: String) -> CodingKeys { @@ -225,12 +397,18 @@ extension OpenAPI.Content { switch stringValue { case "schema": self = .schema + case "itemSchema": + self = .itemSchema case "example": self = .example case "examples": self = .examples case "encoding": self = .encoding + case "itemEncoding": + self = .itemEncoding + case "prefixEncoding": + self = .prefixEncoding default: self = .extendedKey(for: stringValue) } @@ -240,12 +418,18 @@ extension OpenAPI.Content { switch self { case .schema: return "schema" + case .itemSchema: + return "itemSchema" case .example: return "example" case .examples: return "examples" case .encoding: return "encoding" + case .itemEncoding: + return "itemEncoding" + case .prefixEncoding: + return "prefixEncoding" case .extended(let key): return key } diff --git a/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift b/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift new file mode 100644 index 000000000..ab9a4f544 --- /dev/null +++ b/Sources/OpenAPIKit/Content/ContentPositionalEncoding.swift @@ -0,0 +1,41 @@ +// +// ContentPositionalEncoding.swift +// +// +// Created by Mathew Polzin on 12/29/19. +// + +import OpenAPIKitCore + +extension OpenAPI.Content { + /// OpenAPI Spec `itemEncoding` and `prefixEncoding` on the "Media Type Object" + /// + /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.2.0.html#media-type-object). + public struct PositionalEncoding: Equatable, Sendable { + + /// An array of positional encoding information, as defined under + /// [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `prefixEncoding` field **SHALL** only apply when the media type is + /// `multipart`. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var prefixEncoding: [OpenAPI.Content.Encoding] + + /// A single Encoding Object that provides encoding information for + /// multiple array items, as defined under [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `itemEncoding` field **SHALL** only apply when the media type + /// is multipart. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var itemEncoding: OpenAPI.Content.Encoding? + + public init( + prefixEncoding: [OpenAPI.Content.Encoding] = [], + itemEncoding: OpenAPI.Content.Encoding? = nil + ) { + self.prefixEncoding = prefixEncoding + self.itemEncoding = itemEncoding + } + } +} + diff --git a/Sources/OpenAPIKit/Content/DereferencedContent.swift b/Sources/OpenAPIKit/Content/DereferencedContent.swift index 5350b55ee..d122422e5 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContent.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContent.swift @@ -14,9 +14,14 @@ import OpenAPIKitCore public struct DereferencedContent: Equatable { public let underlyingContent: OpenAPI.Content public let schema: DereferencedJSONSchema? + public let itemSchema: DereferencedJSONSchema? public let examples: OrderedDictionary? public let example: AnyCodable? - public let encoding: OrderedDictionary? + public let encoding: Either, DereferencedPositionalEncoding>? + + public var encodingMap: OrderedDictionary? { encoding?.a } + public var prefixEncoding: [DereferencedContentEncoding]? { encoding?.b?.prefixEncoding } + public var itemEncoding: DereferencedContentEncoding? { encoding?.b?.itemEncoding } public subscript(dynamicMember path: KeyPath) -> T { return underlyingContent[keyPath: path] @@ -35,6 +40,7 @@ public struct DereferencedContent: Equatable { following references: Set ) throws { self.schema = try content.schema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) + self.itemSchema = try content.itemSchema?._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) let examples = try content.examples? .mapValues { try $0._dereferenced( @@ -48,10 +54,17 @@ public struct DereferencedContent: Equatable { self.example = examples.flatMap(OpenAPI.Content.firstExample(from:)) ?? content.example - self.encoding = try content.encoding.map { encodingMap in - try encodingMap.mapValues { encoding in + switch content.encoding { + case .a(let encodingMap): + self.encoding = .a(try encodingMap.mapValues { encoding in try encoding._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) - } + }) + case .b(let positionalEncoding): + let prefixEncoding = try positionalEncoding.prefixEncoding.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + let itemEncoding = try positionalEncoding.itemEncoding.map { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + case nil: + self.encoding = nil } self.underlyingContent = content @@ -79,8 +92,10 @@ extension OpenAPI.Content: LocallyDereferenceable { extension OpenAPI.Content: ExternallyDereferenceable { public func externallyDereferenced(with loader: Loader.Type) async throws -> (Self, OpenAPI.Components, [Loader.Message]) { let oldSchema = schema + let oldItemSchema = itemSchema async let (newSchema, c1, m1) = oldSchema.externallyDereferenced(with: loader) + async let (newItemSchema, c2, m2) = oldItemSchema.externallyDereferenced(with: loader) var newContent = self var newComponents = try await c1 @@ -88,18 +103,33 @@ extension OpenAPI.Content: ExternallyDereferenceable { newContent.schema = try await newSchema + try await newComponents.merge(c2) + newMessages += try await m2 + newContent.itemSchema = try await newItemSchema + if let oldExamples = examples { - let (newExamples, c2, m2) = try await oldExamples.externallyDereferenced(with: loader) + let (newExamples, c3, m3) = try await oldExamples.externallyDereferenced(with: loader) newContent.examples = newExamples - try newComponents.merge(c2) - newMessages += m2 + try newComponents.merge(c3) + newMessages += m3 } if let oldEncoding = encoding { - let (newEncoding, c3, m3) = try await oldEncoding.externallyDereferenced(with: loader) - newContent.encoding = newEncoding - try newComponents.merge(c3) - newMessages += m3 + switch oldEncoding { + case .a(let oldEncoding): + let (newEncoding, c4, m4) = try await oldEncoding.externallyDereferenced(with: loader) + newContent.encoding = .a(newEncoding) + try newComponents.merge(c4) + newMessages += m4 + + case .b(let oldPositionalEncoding): + async let (newItemEncoding, c4, m4) = try oldPositionalEncoding.itemEncoding.externallyDereferenced(with: loader) + async let (newPrefixEncoding, c5, m5) = try oldPositionalEncoding.prefixEncoding.externallyDereferenced(with: loader) + newContent.encoding = try await .b(.init(prefixEncoding: newPrefixEncoding, itemEncoding: newItemEncoding)) + try await newComponents.merge(c4) + try await newComponents.merge(c5) + newMessages += try await m4 + m5 + } } return (newContent, newComponents, newMessages) diff --git a/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift b/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift new file mode 100644 index 000000000..11226ea2f --- /dev/null +++ b/Sources/OpenAPIKit/Content/DereferencedContentPositionalEncoding.swift @@ -0,0 +1,38 @@ +// +// DereferencedContentPositionalEncoding.swift +// +// +// Created by Mathew Polzin on 12/29/19. +// + +import OpenAPIKitCore + +/// OpenAPI Spec `itemEncoding` and `prefixEncoding` on the "Media Type Object" +/// +/// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.2.0.html#media-type-object). +public struct DereferencedPositionalEncoding: Equatable { + + /// An array of positional encoding information, as defined under + /// [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `prefixEncoding` field **SHALL** only apply when the media type is + /// `multipart`. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var prefixEncoding: [DereferencedContentEncoding] + + /// A single Encoding Object that provides encoding information for + /// multiple array items, as defined under [Encoding By Position](https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position). + /// The `itemEncoding` field **SHALL** only apply when the media type + /// is multipart. If no Encoding Object is provided for a property, the + /// behavior is determined by the default values documented for the + /// Encoding Object. + public var itemEncoding: DereferencedContentEncoding? + + public init( + prefixEncoding: [DereferencedContentEncoding] = [], + itemEncoding: DereferencedContentEncoding? = nil + ) { + self.prefixEncoding = prefixEncoding + self.itemEncoding = itemEncoding + } +} diff --git a/Sources/OpenAPIKit/Either/Either+Convenience.swift b/Sources/OpenAPIKit/Either/Either+Convenience.swift index bbc414460..4fb851c6f 100644 --- a/Sources/OpenAPIKit/Either/Either+Convenience.swift +++ b/Sources/OpenAPIKit/Either/Either+Convenience.swift @@ -71,6 +71,13 @@ extension Either where A == DereferencedSchemaContext { } } +extension Either where A == OrderedDictionary { + + public var mapValue: A? { + a + } +} + extension Either where B == OpenAPI.PathItem { /// Retrieve the path item if that is what this property contains. public var pathItemValue: B? { b } @@ -141,6 +148,10 @@ extension Either where B == OpenAPI.SecurityScheme { public var securitySchemeValue: B? { b } } +extension Either where B == OpenAPI.Content.PositionalEncoding { + public var positionalValue: B? { b } +} + // MARK: - Convenience constructors extension Either where A == Bool { /// Construct a boolean value. diff --git a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 029f135c4..0103e4eb5 100644 --- a/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift +++ b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift @@ -1112,6 +1112,7 @@ fileprivate func assertEqualNewToOld(_ newContentMap: OpenAPIKit.OpenAPI.Content XCTAssertNil(oldContent.examples) } if let newEncodingRef = newContent.encoding { + let newEncodingRef = try XCTUnwrap(newEncodingRef.mapValue) let oldEncodingRef = try XCTUnwrap(oldContent.encoding) for ((newKey, newEncoding), (oldKey, oldEncoding)) in zip(newEncodingRef, oldEncodingRef) { XCTAssertEqual(newKey, oldKey) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c38519969..3d10fbe94 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -44,6 +44,21 @@ final class ContentTests: XCTestCase { XCTAssertEqual(withExamples.example?.value as? String, "world") XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + let withExamples2 = OpenAPI.Content( + itemSchema: .string, + examples: [ + "hello": .example(.init(value: .init("world"))), + "bbbb": .example(.init(value: .b("pick me"))), + "aaaa": .example(.init(value: .a(URL(string: "http://google.com")!))) + ] + ) + XCTAssertEqual(withExamples2.itemSchema, .string) + XCTAssertNotNil(withExamples2.examples) + // we expect the example to be the first example where ordering + // is the order in which the examples are given: + XCTAssertEqual(withExamples2.example?.value as? String, "world") + XCTAssertEqual(withExamples2.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + let t4 = OpenAPI.Content( schemaReference: .external(URL(string: "hello.json#/world")!), examples: nil @@ -69,6 +84,34 @@ final class ContentTests: XCTestCase { ) ] ) + + let withPrefixEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init()] + ) + + XCTAssertNil(withPrefixEncoding.schema) + XCTAssertEqual(withPrefixEncoding.itemSchema, .string) + XCTAssertEqual(withPrefixEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: nil)) + + let withItemEncoding = OpenAPI.Content( + itemSchema: .string, + itemEncoding: .init() + ) + + XCTAssertNil(withItemEncoding.schema) + XCTAssertEqual(withItemEncoding.itemSchema, .string) + XCTAssertEqual(withItemEncoding.encoding?.positionalValue, .init(itemEncoding: .init())) + + let withPrefixAndItemEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init()], + itemEncoding: .init() + ) + + XCTAssertNil(withPrefixAndItemEncoding.schema) + XCTAssertEqual(withPrefixAndItemEncoding.itemSchema, .string) + XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) } func test_contentMap() { @@ -172,6 +215,72 @@ extension ContentTests { XCTAssertEqual(content, OpenAPI.Content(schema: .init(.string))) } + func test_itemSchemaContent_encode() { + let content = OpenAPI.Content(itemSchema: .string) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemSchema" : { + "type" : "string" + } + } + """ + ) + } + + func test_itemSchemaContent_decode() { + let contentData = + """ + { + "itemSchema" : { + "type" : "string" + } + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual(content, OpenAPI.Content(itemSchema: .init(.string))) + } + + func test_schemaAndItemSchemaContent_encode() { + let content = OpenAPI.Content(schema: .string, itemSchema: .string) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemSchema" : { + "type" : "string" + }, + "schema" : { + "type" : "string" + } + } + """ + ) + } + + func test_schemaAndItemSchemaContent_decode() { + let contentData = + """ + { + "itemSchema" : { + "type" : "string" + }, + "schema" : { + "type" : "string" + } + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual(content, OpenAPI.Content(schema: .string, itemSchema: .init(.string))) + } + func test_schemalessContent_encode() { let content = OpenAPI.Content(schema: nil, example: "hello world") let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) @@ -347,6 +456,40 @@ extension ContentTests { XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) } + func test_decodeFailureForBothEncodingAndItemEncoding() { + let contentData = + """ + { + "encoding" : { + "json" : { + "contentType" : "application\\/json" + } + }, + "itemEncoding" : { + "json" : { + "contentType" : "application\\/json" + } + } + } + """.data(using: .utf8)! + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) + } + + func test_decodeFailureForBothEncodingAndPrefixEncoding() { + let contentData = + """ + { + "encoding" : { + "json" : { + "contentType" : "application\\/json" + } + }, + "prefixEncoding" : [] + } + """.data(using: .utf8)! + XCTAssertThrowsError(try orderUnstableDecode(OpenAPI.Content.self, from: contentData)) + } + func test_encodingAndSchema_encode() { let content = OpenAPI.Content( schema: .init(.string), @@ -397,6 +540,63 @@ extension ContentTests { ) } + func test_prefixAndItemEncodingAndItemSchema_encode() { + let content = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [.init(contentTypes: [.json])], + itemEncoding: .init(contentTypes: [.json]) + ) + let encodedContent = try! orderUnstableTestStringFromEncoding(of: content) + + assertJSONEquivalent( + encodedContent, + """ + { + "itemEncoding" : { + "contentType" : "application\\/json" + }, + "itemSchema" : { + "type" : "string" + }, + "prefixEncoding" : [ + { + "contentType" : "application\\/json" + } + ] + } + """ + ) + } + + func test_prefixAndItemEncodingAndItemSchema_decode() { + let contentData = + """ + { + "itemEncoding" : { + "contentType" : "application\\/json" + }, + "itemSchema" : { + "type" : "string" + }, + "prefixEncoding" : [ + { + "contentType" : "application\\/json" + } + ] + } + """.data(using: .utf8)! + let content = try! orderUnstableDecode(OpenAPI.Content.self, from: contentData) + + XCTAssertEqual( + content, + OpenAPI.Content( + itemSchema: .init(.string), + prefixEncoding: [.init(contentTypes: [.json])], + itemEncoding: .init(contentTypes: [.json]) + ) + ) + } + func test_vendorExtensions_encode() { let content = OpenAPI.Content( schema: .init(.string), diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index bf4ae9bb2..03caa192b 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -125,8 +125,8 @@ final class DereferencedContentTests: XCTestCase { func test_inlineEncoding() throws { let t1 = try OpenAPI.Content(schema: .string, encoding: ["test": .init()]).dereferenced(in: .noComponents) - XCTAssertNotNil(t1.encoding?["test"]) - XCTAssertNil(t1.encoding?["test"]?.headers) + XCTAssertNotNil(t1.encodingMap?["test"]) + XCTAssertNil(t1.encodingMap?["test"]?.headers) } func test_referencedHeaderInEncoding() throws { @@ -146,11 +146,11 @@ final class DereferencedContentTests: XCTestCase { ] ).dereferenced(in: components) XCTAssertEqual( - t1.encoding?["test"]?.headers?["test"]?.schemaOrContent.schemaValue, + t1.encodingMap?["test"]?.headers?["test"]?.schemaOrContent.schemaValue, DereferencedJSONSchema.string(.init(), .init()) ) // just test that dynamic member lookup is connected correctly - XCTAssertEqual(t1.encoding?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) + XCTAssertEqual(t1.encodingMap?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) } func test_missingHeaderInEncoding() { From 43f6eebd31fe1833771f23ee2ce7a808d1d08617 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Fri, 21 Nov 2025 19:44:25 -0600 Subject: [PATCH 04/12] add a bit more test coverage --- .../Content/ContentTests.swift | 21 +++++++++++ .../Content/DereferencedContentTests.swift | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index 3d10fbe94..c95ef9337 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -112,6 +112,27 @@ final class ContentTests: XCTestCase { XCTAssertNil(withPrefixAndItemEncoding.schema) XCTAssertEqual(withPrefixAndItemEncoding.itemSchema, .string) XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) + XCTAssertEqual(withPrefixAndItemEncoding.prefixEncoding, [.init()]) + XCTAssertEqual(withPrefixAndItemEncoding.itemEncoding, .init()) + + XCTAssertEqual( + OpenAPI.Content( + schema: .string, + prefixEncoding: [], + itemEncoding: nil + ), + OpenAPI.Content( + schema: .string, + encoding: nil + ) + ) + + let emptyPositionalEncoding = OpenAPI.Content( + itemSchema: .string, + prefixEncoding: [], + itemEncoding: nil + ) + XCTAssertEqual(emptyPositionalEncoding.encoding, nil) } func test_contentMap() { diff --git a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift index 03caa192b..07f6f0a8e 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -123,6 +123,27 @@ final class DereferencedContentTests: XCTestCase { ) } + func test_inlineItemSchema() throws { + let t1 = try OpenAPI.Content(itemSchema: .string).dereferenced(in: .noComponents) + XCTAssertEqual(t1.itemSchema, .string(.init(), .init())) + } + + func test_referencedItemSchema() throws { + let components = OpenAPI.Components( + schemas: ["schema1": .string] + ) + let t1 = try OpenAPI.Content(itemSchema: .reference(.component(named: "schema1"))).dereferenced(in: components) + XCTAssertEqual(t1.itemSchema, .string(.init(vendorExtensions: ["x-component-name": "schema1"]), .init())) + } + + func test_missingItemSchema() { + XCTAssertThrowsError( + try OpenAPI.Content( + itemSchema: .reference(.component(named: "missing")) + ).dereferenced(in: .noComponents) + ) + } + func test_inlineEncoding() throws { let t1 = try OpenAPI.Content(schema: .string, encoding: ["test": .init()]).dereferenced(in: .noComponents) XCTAssertNotNil(t1.encodingMap?["test"]) @@ -153,6 +174,20 @@ final class DereferencedContentTests: XCTestCase { XCTAssertEqual(t1.encodingMap?["test"]?.style, OpenAPI.Content.Encoding.defaultStyle) } + func test_inlinePrefixEncoding() throws { + let t1 = try OpenAPI.Content(schema: .string, prefixEncoding: [.init()]).dereferenced(in: .noComponents) + XCTAssertNil(t1.encodingMap?["test"]) + XCTAssertEqual(t1.prefixEncoding?.count, 1) + XCTAssertNil(t1.itemEncoding) + } + + func test_inlineItemEncoding() throws { + let t1 = try OpenAPI.Content(schema: .string, itemEncoding: .init()).dereferenced(in: .noComponents) + XCTAssertNil(t1.encodingMap?["test"]) + XCTAssertEqual(t1.prefixEncoding, []) + XCTAssertNotNil(t1.itemEncoding) + } + func test_missingHeaderInEncoding() { XCTAssertThrowsError( try OpenAPI.Content( From 2e382313819758ed836e0e4210813dc7de1af00b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 22 Nov 2025 20:27:40 -0600 Subject: [PATCH 05/12] add missing tests for HttpMethod core type --- .../OpenAPIKitCoreTests/HttpMethodTests.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Tests/OpenAPIKitCoreTests/HttpMethodTests.swift diff --git a/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift b/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift new file mode 100644 index 000000000..66ff38332 --- /dev/null +++ b/Tests/OpenAPIKitCoreTests/HttpMethodTests.swift @@ -0,0 +1,125 @@ +// +// HttpTypeTests.swift +// + +import OpenAPIKitCore +import XCTest + +final class HttpMethodTests: XCTestCase { + func test_builtinInits() { + let methods: [Shared.HttpMethod] = [ + .get, + .post, + .patch, + .put, + .delete, + .head, + .options, + .trace, + .query + ] + + XCTAssertEqual(methods, [ + .builtin(.get), + .builtin(.post), + .builtin(.patch), + .builtin(.put), + .builtin(.delete), + .builtin(.head), + .builtin(.options), + .builtin(.trace), + .builtin(.query) + ]) + + XCTAssertEqual(methods.map(\.rawValue), [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY" + ]) + + XCTAssertEqual(methods, [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY" + ]) + + XCTAssertEqual(methods.map(Optional.some), [ + .init(rawValue: "GET"), + .init(rawValue: "POST"), + .init(rawValue: "PATCH"), + .init(rawValue: "PUT"), + .init(rawValue: "DELETE"), + .init(rawValue: "HEAD"), + .init(rawValue: "OPTIONS"), + .init(rawValue: "TRACE"), + .init(rawValue: "QUERY") + ]) + } + + func test_otherInit() { + let otherMethod = Shared.HttpMethod.other("LINK") + XCTAssertEqual(otherMethod, Shared.HttpMethod(rawValue: "LINK")) + XCTAssertEqual(otherMethod, "LINK") + XCTAssertEqual(otherMethod.rawValue, "LINK") + } + + func test_knownBadCasing() { + XCTAssertNil(Shared.HttpMethod(rawValue: "link")) + XCTAssertEqual(Shared.HttpMethod.other("link"), "link") + XCTAssertEqual(Shared.HttpMethod.problem(with: "link"), "'link' must be uppercased") + } + + func test_encoding() throws { + let methods: [Shared.HttpMethod] = [ + .get, + .post, + .patch, + .put, + .delete, + .head, + .options, + .trace, + .query, + "LINK" + ] + + for method in methods { + let encoded = try orderUnstableTestStringFromEncoding(of: method) + + XCTAssertEqual(encoded, "\"\(method.rawValue)\"") + } + } + + func test_decoding() throws { + let methods: [String] = [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE", + "HEAD", + "OPTIONS", + "TRACE", + "QUERY", + "LINK" + ] + + for method in methods { + let decoded = try orderUnstableDecode(Shared.HttpMethod.self, from: "\"\(method)\"".data(using: .utf8)!) + + XCTAssertEqual(decoded, Shared.HttpMethod(rawValue: method)) + } + } +} From e4b4b3534055c2b512b5d0daf98e0a32fc36154e Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 23 Nov 2025 20:03:00 -0600 Subject: [PATCH 06/12] update migration guide --- .../migration_guides/v5_migration_guide.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/documentation/migration_guides/v5_migration_guide.md b/documentation/migration_guides/v5_migration_guide.md index 096fac59d..245a119ec 100644 --- a/documentation/migration_guides/v5_migration_guide.md +++ b/documentation/migration_guides/v5_migration_guide.md @@ -215,6 +215,84 @@ can be found in the Components Object (likely very uncommon), you will now need to handle two possibilities: the key path either refers to an object (of generic type `T`) or it refers to an `Either, T>`. +### Media Type Object (`OpenAPI.Content`) +#### Schema property +The `schema` property has changed from either an `OpenAPI.Reference` or a +`JOSNSchema` to just a `JSONSchema`. This both reflects the specification and +also works just as well because `JSONSchema` has its own `.reference` case. +However, this does result in some necessary code changes. + +You now have one fewer layer to traverse to get to a schema. +```swift +/// BEFORE +let JSONSchema? = content[.json]?.schema?.schemaValue + +/// AFTER +let JSONSchema? = content[.json]?.schema +``` + +Switches over the `schema` now should look directly at the `JSONSchema` `value` +to switch on whether the `schema` is a reference or not. +```swift +/// BEFORE +guard let schema = content[.json]?.schema else { return } +switch schema { +case .a(let reference): + print(reference) +case .b(let schema): + print(schema) +} + +/// AFTER +guard let schema = content[.json]?.schema else { return } +switch schema.value { +case .reference(let reference, _): + print(reference) +default: + print(schema) +} +``` + +The `OpenAPI.Content(schema:example:encoding:vendorExtensions:)` initializer +takes a schema directly so if you were passing in a schema anyway you just +remove one layer of wrapping (the `Either` that was previously there). If you +were passing in a reference, just make sure you are using the `JSONSchema` +`reference()` convenience constructor where it would have previously been the +`Either` `reference()` convenience constructor; they should be source-code +compatible. + +#### Encoding property +The `OpenAPI.Content` `encoding` property has changed from being a map of +encodings (`OrderedDictionary`) to an `Either` in order to +support the new OAS 3.2.0 `prefixEncoding` and `itemEncoding` options which are +mutually exclusive with the existing map of encodings. + +Anywhere you read the `encoding` property in your existing code, you can switch +to the `encodingMap` property if you want a short term solution that compiles +and behaves the same way for any OpenAPI Documents that do not use the new +positional encoding properties. +```swift +/// BEFORE +let encoding: Encoding? = content.encoding + +/// AFTER +let encoding: Encoding? = content.encodingMap +``` + +If you wish to handle the new encoding options, you will need to switch over the +`Either` or otherwise handle the additional `prefixEncoding` and `itemEncoding` +properties. +```swift +guard let encoding = content.encoding else { return } +switch encoding { +case .a(let encodingMap): + print(encodingMap) +case .b(let positionalEncoding): + print(positionalEncoding.prefixEncoding) + print(positionalEncoding.itemEncoding) +} +``` + ### Errors Some error messages have been tweaked in small ways. If you match on the string descriptions of any OpenAPIKit errors, you may need to update the From f54d617ac7eeca9044a7023a00a6bbf8b316f7df Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sun, 23 Nov 2025 20:21:39 -0600 Subject: [PATCH 07/12] shore up specification coverage file --- documentation/specification_coverage.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/documentation/specification_coverage.md b/documentation/specification_coverage.md index 92af640d8..d2ad6e125 100644 --- a/documentation/specification_coverage.md +++ b/documentation/specification_coverage.md @@ -38,6 +38,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### OpenAPI Object (`OpenAPI.Document`) - [x] openapi (`openAPIVersion`) +- [x] $self (`selfRUI`) - [x] info - [ ] jsonSchemaDialect - [x] servers @@ -72,7 +73,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] specification extensions (`vendorExtensions`) ### Server Object (`OpenAPI.Server`) -- [x] url +- [x] url (`urlTemplate`) - [x] description - [x] variables - [x] specification extensions (`vendorExtensions`) @@ -140,18 +141,18 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Parameter Object (`OpenAPI.Parameter`) - [x] name -- [x] in (`context`) +- [x] in (`context.location`) - [x] description -- [x] required (part of `context`) +- [x] required (`context.required`) - [x] deprecated - [x] allowEmptyValue (part of `context`) -- [x] schema (`schemaOrContent`) +- [x] schema (`schemaOrContent` in relevant `context` cases) - [x] style - [x] explode - [x] allowReserved - [x] example - [x] examples -- [x] content (`schemaOrContent`) +- [x] content (`schemaOrContent` in relevant `context` cases) - [x] specification extensions (`vendorExtensions`) ### Request Body Object (`OpenAPI.Request`) @@ -162,9 +163,12 @@ For more information on the OpenAPIKit types, see the [full type documentation]( ### Media Type Object (`OpenAPI.Content`) - [x] schema +- [x] itemSchema - [x] example - [x] examples -- [x] encoding +- [x] encoding (`encoding` first case) +- [x] prefixEncoding (part of `encoding` second case) +- [x] itemEncoding (part of `encoding` second case) - [x] specification extensions (`vendorExtensions`) ### Encoding Object (`OpenAPI.Content.Encoding`) @@ -173,7 +177,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] style - [x] explode - [x] allowReserved -- [ ] specification extensions +- [x] specification extensions ### Responses Object (`OpenAPI.Response.Map`) - [x] default (`Response.StatusCode.Code` `.default` case) @@ -181,6 +185,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - ~[ ] specification extensions~ (not a planned addition) ### Response Object (`OpenAPI.Response`) +- [x] summary - [x] description - [x] headers - [x] content @@ -240,7 +245,7 @@ For more information on the OpenAPIKit types, see the [full type documentation]( - [x] remote (different file) reference (`external` case) - [x] encode - [x] decode - - [ ] dereference + - [x] dereference ### Schema Object (`JSONSchema`) - [x] Mostly complete support for JSON Schema inherited keywords (select ones enumerated below) From 6d677587f5f5dd3347bcd59f7bb7c875c5395fe8 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Mon, 24 Nov 2025 10:15:33 -0600 Subject: [PATCH 08/12] shore up test coverage of content and externally dereferenced content --- .../Content/ContentTests.swift | 24 +++++++++- .../ExternalDereferencingDocumentTests.swift | 44 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c95ef9337..c6b92ae75 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -71,7 +71,7 @@ final class ContentTests: XCTestCase { ) XCTAssertNil(t5.schema?.reference) - let _ = OpenAPI.Content( + let withEncodingMap = OpenAPI.Content( schema: .init(.string), example: nil, encoding: [ @@ -84,6 +84,16 @@ final class ContentTests: XCTestCase { ) ] ) + XCTAssertEqual( + withEncodingMap.encodingMap?["hello"], + .init( + contentTypes: [.json], + headers: [ + "world": .init(OpenAPI.Header(schemaOrContent: .init(.header(.string)))) + ], + allowReserved: true + ) + ) let withPrefixEncoding = OpenAPI.Content( itemSchema: .string, @@ -133,6 +143,18 @@ final class ContentTests: XCTestCase { itemEncoding: nil ) XCTAssertEqual(emptyPositionalEncoding.encoding, nil) + + let emptyPositionalEncoding2 = OpenAPI.Content( + itemSchema: .string, + examples: ["hi": .example(summary: "hi example")], + prefixEncoding: [], + itemEncoding: nil + ) + XCTAssertEqual(emptyPositionalEncoding2.encoding, nil) + XCTAssertEqual(emptyPositionalEncoding2.encodingMap, nil) + XCTAssertEqual(emptyPositionalEncoding2.prefixEncoding, nil) + XCTAssertEqual(emptyPositionalEncoding2.itemEncoding, nil) + XCTAssertNotNil(emptyPositionalEncoding2.examples) } func test_contentMap() { diff --git a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift index 4f977a3d2..bcb18f564 100644 --- a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift @@ -74,6 +74,41 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "type": "object" } """, + "requests_hello_json": """ + { + "content": { + "application/json": { + "itemSchema": { + "type": "object", + "properties": { + "body": { + "$ref": "file://./schemas/string_param.json" + } + } + }, + "prefixEncoding": [ + { + "style": "form" + } + ], + "itemEncoding": { + "headers": { + "head1": { + "$ref": "file://./headers/hello.json" + } + } + } + } + } + } + """, + "headers_hello_json": """ + { + "schema": { + "$ref": "file://./schemas/string_param.json" + } + } + """, "paths_webhook_json": """ { "summary": "just a webhook", @@ -220,6 +255,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { ], get: .init( operationId: "helloOp", + requestBody: .reference(.external(URL(string: "file://./requests/hello.json")!)), responses: [:], callbacks: [ "callback1": .reference(.external(URL(string: "file://./callbacks/one.json")!)) @@ -248,6 +284,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted + // do 4 times var docCopy1 = document try await docCopy1.externallyDereference(with: ExampleLoader.self) try await docCopy1.externallyDereference(with: ExampleLoader.self) @@ -255,14 +292,17 @@ final class ExternalDereferencingDocumentTests: XCTestCase { try await docCopy1.externallyDereference(with: ExampleLoader.self) docCopy1.components.sort() + // do to depth of 4 var docCopy2 = document try await docCopy2.externallyDereference(with: ExampleLoader.self, depth: 4) docCopy2.components.sort() + // do until done var docCopy3 = document let messages = try await docCopy3.externallyDereference(with: ExampleLoader.self, depth: .full) docCopy3.components.sort() + // for this document, depth of 4 is enough for all the above to compare equally XCTAssertEqual(docCopy1, docCopy2) XCTAssertEqual(docCopy2, docCopy3) @@ -270,6 +310,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { messages.sorted(), ["file://./callbacks/one.json", "file://./examples/good.json", + "file://./headers/hello.json", "file://./headers/webhook.json", "file://./headers/webhook2.json", "file://./links/first.json", @@ -278,6 +319,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./paths/callback.json", "file://./paths/webhook.json", "file://./paths/webhook.json", + "file://./requests/hello.json", "file://./requests/webhook.json", "file://./responses/webhook.json", "file://./schemas/basic_object.json", @@ -285,6 +327,8 @@ final class ExternalDereferencingDocumentTests: XCTestCase { "file://./schemas/string_param.json", "file://./schemas/string_param.json", "file://./schemas/string_param.json", + "file://./schemas/string_param.json", + "file://./schemas/string_param.json", "file://./schemas/string_param.json#"] ) } From 708777f29e14b22947acc039916dbc4831ed9700 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:23:35 -0600 Subject: [PATCH 09/12] add a bit of missing document test coverage --- .../Document/DocumentTests.swift | 13 +++++++++++++ .../OpenAPIKitTests/Document/DocumentTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift b/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift index 761b7f398..c0e81cbe9 100644 --- a/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKit30Tests/Document/DocumentTests.swift @@ -202,6 +202,19 @@ final class DocumentTests: XCTestCase { XCTAssertEqual(t.allServers, []) } + func test_allServersExternalPathItem() { + let t = OpenAPI.Document( + info: .init(title: "test", version: "1.0"), + servers: [], + paths: [ + "/hello/world": .reference(.external(URL(string: "file://./hello_world.json")!)) + ], + components: .noComponents + ) + + XCTAssertEqual(t.allServers, []) + } + func test_allServers_onlyRoot() { let s1 = OpenAPI.Server(url: URL(string: "https://website.com")!) let s2 = OpenAPI.Server(url: URL(string: "https://website2.com")!) diff --git a/Tests/OpenAPIKitTests/Document/DocumentTests.swift b/Tests/OpenAPIKitTests/Document/DocumentTests.swift index 18d493db8..afd10edcf 100644 --- a/Tests/OpenAPIKitTests/Document/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/Document/DocumentTests.swift @@ -483,6 +483,22 @@ final class DocumentTests: XCTestCase { XCTAssertNoThrow(try orderUnstableDecode(OpenAPI.Document.self, from: docData)) } + + func test_initVersionWrongNumberOfComponents() { + XCTAssertNil(OpenAPI.Document.Version(rawValue: "")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2")) + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2.3.4")) + } + + func test_initPatchVersionNotInteger() { + XCTAssertNil(OpenAPI.Document.Version(rawValue: "1.2.a")) + } + + func test_versionOutsideKnownBoundsStillSerializesToString() { + XCTAssertEqual(OpenAPI.Document.Version.v3_1_x(x: 1000).rawValue, "3.1.1000") + XCTAssertEqual(OpenAPI.Document.Version.v3_2_x(x: 1000).rawValue, "3.2.1000") + } } // MARK: - Codable From 22b0a7c9aea605926b9e01c99f467cd7bf576a32 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:26:41 -0600 Subject: [PATCH 10/12] add support for additional common sequential media types --- .../OpenAPIKitCore/Shared/ContentType.swift | 36 ++++++++++++++++--- .../ContentTypeTests.swift | 4 +++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIKitCore/Shared/ContentType.swift b/Sources/OpenAPIKitCore/Shared/ContentType.swift index ab70fa763..feb7eb684 100644 --- a/Sources/OpenAPIKitCore/Shared/ContentType.swift +++ b/Sources/OpenAPIKitCore/Shared/ContentType.swift @@ -107,21 +107,30 @@ public extension Shared.ContentType { static let csv: Self = .init(.csv) static let doc: Self = .init(.doc) static let docx: Self = .init(.docx) + /// Event Stream (e.g. Server-Sent Events) + static let eventStream: Self = .init(.eventStream) /// URL-encoded form data. See also: `multipartForm`. static let form: Self = .init(.form) /// Graphics Interchange Format static let gif: Self = .init(.gif) /// geojson as defined in GeoJSON standard (RFC 7946) - /// /// see: https://datatracker.ietf.org/doc/html/rfc7946#section-12 static let geojson: Self = .init(.geojson) static let html: Self = .init(.html) static let javascript: Self = .init(.javascript) /// JPEG image static let jpg: Self = .init(.jpg) + /// JSON + /// See https://www.rfc-editor.org/rfc/rfc8259.html static let json: Self = .init(.json) /// JSON:API Document static let jsonapi: Self = .init(.jsonapi) + /// JSON Lines + /// See https://jsonlines.org + static let jsonl: Self = .init(.jsonl) + /// json-seq (text sequences) + /// See https://www.rfc-editor.org/rfc/rfc7464.html + static let json_seq: Self = .init(.json_seq) /// Quicktime video static let mov: Self = .init(.mov) /// MP3 audio @@ -132,13 +141,14 @@ public extension Shared.ContentType { static let mpg: Self = .init(.mpg) /// Multipart form data. See also: `form`. static let multipartForm: Self = .init(.multipartForm) + /// Multipart mixed data. + static let multipartMixed: Self = .init(.multipartMixed) /// OpenType font static let otf: Self = .init(.otf) static let pdf: Self = .init(.pdf) /// PNG image static let png: Self = .init(.png) /// Protocol Buffers - /// /// See: https://protobuf.dev/ static let protobuf: Self = .init(.protobuf) /// RAR archive @@ -194,10 +204,11 @@ extension Shared.ContentType { case csv case doc case docx + /// Event Stream (e.g. Server-Sent Events) + case eventStream /// URL-encoded form data. See also: `multipartForm`. case form /// geojson as defined in GeoJSON standard (RFC 7946) - /// /// see: https://datatracker.ietf.org/doc/html/rfc7946#section-12 case geojson /// Graphics Interchange Format @@ -206,9 +217,17 @@ extension Shared.ContentType { case javascript /// JPEG image case jpg + /// JSON + /// See https://www.rfc-editor.org/rfc/rfc8259.html case json /// JSON:API Document case jsonapi + /// JSON Lines + /// See https://jsonlines.org + case jsonl + /// json-seq (text sequences) + /// See https://www.rfc-editor.org/rfc/rfc7464.html + case json_seq /// Quicktime video case mov /// MP3 audio @@ -219,13 +238,14 @@ extension Shared.ContentType { case mpg /// Multipart form data. See also: `form`. case multipartForm + /// Multipart mixed data. + case multipartMixed /// OpenType font case otf case pdf /// PNG image case png /// Protocol Buffers - /// /// See: https://protobuf.dev/ case protobuf /// RAR archive @@ -277,6 +297,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .csv: return "text/csv" case .doc: return "application/msword" case .docx: return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case .eventStream: return "text/event-stream" case .form: return "application/x-www-form-urlencoded" case .geojson: return "application/geo+json" case .gif: return "image/gif" @@ -285,11 +306,14 @@ extension Shared.ContentType.Builtin: RawRepresentable { case .jpg: return "image/jpeg" case .json: return "application/json" case .jsonapi: return "application/vnd.api+json" + case .jsonl: return "application/jsonl" + case .json_seq: return "application/json-seq" case .mov: return "video/quicktime" case .mp3: return "audio/mpeg" case .mp4: return "video/mp4" case .mpg: return "video/mpeg" case .multipartForm: return "multipart/form-data" + case .multipartMixed: return "multipart/mixed" case .otf: return "font/otf" case .pdf: return "application/pdf" case .png: return "image/png" @@ -330,6 +354,7 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "text/csv": self = .csv case "application/msword": self = .doc case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": self = .docx + case "text/event-stream": self = .eventStream case "application/x-www-form-urlencoded": self = .form case "application/geo+json": self = .geojson case "image/gif": self = .gif @@ -338,11 +363,14 @@ extension Shared.ContentType.Builtin: RawRepresentable { case "image/jpeg": self = .jpg case "application/json": self = .json case "application/vnd.api+json": self = .jsonapi + case "application/jsonl": self = .jsonl + case "application/json-seq": self = .json_seq case "video/quicktime": self = .mov case "audio/mpeg": self = .mp3 case "video/mp4": self = .mp4 case "video/mpeg": self = .mpg case "multipart/form-data": self = .multipartForm + case "multipart/mixed": self = .multipartMixed case "font/otf": self = .otf case "application/pdf": self = .pdf case "image/png": self = .png diff --git a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift index e4c201153..598d43956 100644 --- a/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift +++ b/Tests/OpenAPIKitCoreTests/ContentTypeTests.swift @@ -26,6 +26,7 @@ final class ContentTypeTests: XCTestCase { .csv, .doc, .docx, + .eventStream, .form, .gif, .html, @@ -33,11 +34,14 @@ final class ContentTypeTests: XCTestCase { .jpg, .json, .jsonapi, + .jsonl, + .json_seq, .mov, .mp3, .mp4, .mpg, .multipartForm, + .multipartMixed, .otf, .pdf, .rar, From 53f3b8544aea91b6819c957a1f79dbb773e78cf5 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Tue, 25 Nov 2025 09:47:48 -0600 Subject: [PATCH 11/12] Add conditional warnings for OAS 3.2.0 Media Type Object properties --- Sources/OpenAPIKit/Content/Content.swift | 71 ++++++++++++++++++- .../Content/ContentTests.swift | 5 ++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIKit/Content/Content.swift b/Sources/OpenAPIKit/Content/Content.swift index f923b9510..d394250b6 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -11,7 +11,7 @@ extension OpenAPI { /// OpenAPI Spec "Media Type Object" /// /// See [OpenAPI Media Type Object](https://spec.openapis.org/oas/v3.1.1.html#media-type-object). - public struct Content: Equatable, CodableVendorExtendable, Sendable { + public struct Content: HasConditionalWarnings, CodableVendorExtendable, Sendable { /// A schema describing the complete content of the request, response, /// parameter, or header. public var schema: JSONSchema? @@ -45,6 +45,8 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + public let conditionalWarnings: [(any Condition, OpenAPI.Warning)] + /// The encoding of this `Content` (Media Type Object) if it is a map /// from property names to encoding information. /// @@ -84,6 +86,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema, a reference to a schema, or no @@ -106,6 +110,8 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } /// Create `Content` with a schema, a reference to a schema, or no @@ -127,6 +133,8 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } /// Create `Content` with a reference to a schema and optionally @@ -142,6 +150,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a single @@ -159,6 +169,8 @@ extension OpenAPI { self.examples = nil self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema, a reference to a schema, or no @@ -183,6 +195,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a reference to a schema and optionally @@ -198,6 +212,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -215,6 +231,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -231,6 +249,8 @@ extension OpenAPI { self.example = examples.flatMap(Self.firstExample(from:)) self.encoding = encoding.map(Either.a) self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a map @@ -252,10 +272,52 @@ extension OpenAPI { self.encoding = nil } self.vendorExtensions = vendorExtensions + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: prefixEncoding, itemEncoding: itemEncoding) } } } +extension OpenAPI.Content: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.schema == rhs.schema + && lhs.itemSchema == rhs.itemSchema + && lhs.example == rhs.example + && lhs.examples == rhs.examples + && lhs.encoding == rhs.encoding + && lhs.vendorExtensions == rhs.vendorExtensions + } +} + + +extension OpenAPI.Content { + fileprivate static func conditionalWarnings(itemSchema: JSONSchema?, prefixEncoding: [Encoding]?, itemEncoding: Encoding?) -> [(any Condition, OpenAPI.Warning)] { + let itemSchemaWarning: (any Condition, OpenAPI.Warning)? = + itemSchema.map { _ in + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object itemSchema property") + } + let prefixEncodingWarning : (any Condition, OpenAPI.Warning)? = + prefixEncoding.flatMap { prefixEncoding in + if prefixEncoding == [] { + nil + } else { + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object prefixEncoding property") + } + } + + let itemEncodingWarning : (any Condition, OpenAPI.Warning)? = + itemEncoding.map { _ in + OpenAPI.Document.ConditionalWarnings.version(lessThan: .v3_2_0, doesNotSupport: "The Media Type Object itemEncoding property") + } + + return [ + itemSchemaWarning, + prefixEncodingWarning, + itemEncodingWarning + ].compactMap { $0 } + } +} + extension OpenAPI.Content { public typealias Map = OrderedDictionary } @@ -342,12 +404,17 @@ extension OpenAPI.Content: Decodable { schema = try container.decodeIfPresent(JSONSchema.self, forKey: .schema) itemSchema = try container.decodeIfPresent(JSONSchema.self, forKey: .itemSchema) + var maybePrefixEncoding: [Encoding]? = nil + var maybeItemEncoding: Encoding? = nil if container.contains(.encoding) { encoding = .a(try container.decode(OrderedDictionary.self, forKey: .encoding)) } else if container.contains(.prefixEncoding) || container.contains(.itemEncoding) { let prefixEncoding = try container.decodeIfPresent([Encoding].self, forKey: .prefixEncoding) ?? [] let itemEncoding = try container.decodeIfPresent(Encoding.self, forKey: .itemEncoding) + maybePrefixEncoding = prefixEncoding + maybeItemEncoding = itemEncoding + encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) } else { encoding = nil @@ -363,6 +430,8 @@ extension OpenAPI.Content: Decodable { } vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: maybePrefixEncoding, itemEncoding: maybeItemEncoding) } } diff --git a/Tests/OpenAPIKitTests/Content/ContentTests.swift b/Tests/OpenAPIKitTests/Content/ContentTests.swift index c6b92ae75..7a5a3d197 100644 --- a/Tests/OpenAPIKitTests/Content/ContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/ContentTests.swift @@ -43,6 +43,7 @@ final class ContentTests: XCTestCase { // is the order in which the examples are given: XCTAssertEqual(withExamples.example?.value as? String, "world") XCTAssertEqual(withExamples.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + XCTAssertEqual(withExamples.conditionalWarnings.count, 0) let withExamples2 = OpenAPI.Content( itemSchema: .string, @@ -58,6 +59,7 @@ final class ContentTests: XCTestCase { // is the order in which the examples are given: XCTAssertEqual(withExamples2.example?.value as? String, "world") XCTAssertEqual(withExamples2.examples?["hello"]?.exampleValue, .init(value: .init("world"))) + XCTAssertEqual(withExamples2.conditionalWarnings.count, 1) let t4 = OpenAPI.Content( schemaReference: .external(URL(string: "hello.json#/world")!), @@ -103,6 +105,7 @@ final class ContentTests: XCTestCase { XCTAssertNil(withPrefixEncoding.schema) XCTAssertEqual(withPrefixEncoding.itemSchema, .string) XCTAssertEqual(withPrefixEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: nil)) + XCTAssertEqual(withPrefixEncoding.conditionalWarnings.count, 2) let withItemEncoding = OpenAPI.Content( itemSchema: .string, @@ -112,6 +115,7 @@ final class ContentTests: XCTestCase { XCTAssertNil(withItemEncoding.schema) XCTAssertEqual(withItemEncoding.itemSchema, .string) XCTAssertEqual(withItemEncoding.encoding?.positionalValue, .init(itemEncoding: .init())) + XCTAssertEqual(withItemEncoding.conditionalWarnings.count, 2) let withPrefixAndItemEncoding = OpenAPI.Content( itemSchema: .string, @@ -124,6 +128,7 @@ final class ContentTests: XCTestCase { XCTAssertEqual(withPrefixAndItemEncoding.encoding?.positionalValue, .init(prefixEncoding: [.init()], itemEncoding: .init())) XCTAssertEqual(withPrefixAndItemEncoding.prefixEncoding, [.init()]) XCTAssertEqual(withPrefixAndItemEncoding.itemEncoding, .init()) + XCTAssertEqual(withPrefixAndItemEncoding.conditionalWarnings.count, 3) XCTAssertEqual( OpenAPI.Content( From 566988e194c2b9e26294dbd8e45fceb941446e7b Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Sat, 29 Nov 2025 20:05:12 -0600 Subject: [PATCH 12/12] stop running jammy because it has a bug in async execution --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a41f29e2..7fb41677c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: - swift:6.2-jammy - swift:6.2-noble - swiftlang/swift:nightly-focal - - swiftlang/swift:nightly-jammy +# - swiftlang/swift:nightly-jammy container: ${{ matrix.image }} steps: - name: Checkout code