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 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..d394250b6 100644 --- a/Sources/OpenAPIKit/Content/Content.swift +++ b/Sources/OpenAPIKit/Content/Content.swift @@ -11,11 +11,32 @@ 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 var schema: Either, JSONSchema>? + public struct Content: HasConditionalWarnings, 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,19 +45,96 @@ 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. + /// + /// 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: Either, JSONSchema>?, + 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 + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) + } + + /// 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 - self.encoding = encoding + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + 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 + /// 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 + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + 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 @@ -47,26 +145,32 @@ extension OpenAPI { encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schemaReference) + 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 + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) } /// Create `Content` with a schema and optionally provide a single /// example. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, example: AnyCodable? = nil, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + self.schema = schema + self.itemSchema = itemSchema self.example = example self.examples = nil - self.encoding = encoding + 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 @@ -77,11 +181,22 @@ 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 + 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 @@ -92,30 +207,117 @@ 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 + 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 /// of examples. public init( schema: JSONSchema, + itemSchema: JSONSchema? = nil, examples: Example.Map?, encoding: OrderedDictionary? = nil, vendorExtensions: [String: AnyCodable] = [:] ) { - self.schema = .init(schema) + 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 + + self.conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: nil, itemEncoding: nil) + } + + /// 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 + 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 + /// 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:)) + if itemEncoding != nil || prefixEncoding != [] { + self.encoding = .b(.init(prefixEncoding: prefixEncoding, itemEncoding: itemEncoding)) + } else { + 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 } @@ -150,6 +352,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 @@ -159,7 +362,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) @@ -179,9 +393,32 @@ extension OpenAPI.Content: Decodable { ) } - schema = try container.decodeIfPresent(Either, JSONSchema>.self, forKey: .schema) + 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 + ) + } - encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) + 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 + } if container.contains(.example) { example = try container.decode(AnyCodable.self, forKey: .example) @@ -193,19 +430,32 @@ extension OpenAPI.Content: Decodable { } vendorExtensions = try Self.extensions(from: decoder) + + conditionalWarnings = Self.conditionalWarnings(itemSchema: itemSchema, prefixEncoding: maybePrefixEncoding, itemEncoding: maybeItemEncoding) } } 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 { @@ -216,12 +466,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) } @@ -231,12 +487,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/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/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/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/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/OpenAPIKitCompatTests/DocumentConversionTests.swift b/Tests/OpenAPIKitCompatTests/DocumentConversionTests.swift index 9568e15e1..0103e4eb5 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))") } @@ -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/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, 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)) + } + } +} 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..7a5a3d197 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), @@ -46,22 +43,37 @@ 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, + 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"))) + XCTAssertEqual(withExamples2.conditionalWarnings.count, 1) let t4 = OpenAPI.Content( schemaReference: .external(URL(string: "hello.json#/world")!), 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( + let withEncodingMap = OpenAPI.Content( schema: .init(.string), example: nil, encoding: [ @@ -74,6 +86,80 @@ 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, + prefixEncoding: [.init()] + ) + + 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, + itemEncoding: .init() + ) + + 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, + prefixEncoding: [.init()], + itemEncoding: .init() + ) + + 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(withPrefixAndItemEncoding.conditionalWarnings.count, 3) + + 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) + + 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() { @@ -98,7 +184,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 +204,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 +230,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() { @@ -177,6 +263,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) @@ -352,6 +504,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), @@ -402,6 +588,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..07f6f0a8e 100644 --- a/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift +++ b/Tests/OpenAPIKitTests/Content/DereferencedContentTests.swift @@ -123,10 +123,31 @@ 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.encoding?["test"]) - XCTAssertNil(t1.encoding?["test"]?.headers) + XCTAssertNotNil(t1.encodingMap?["test"]) + XCTAssertNil(t1.encodingMap?["test"]?.headers) } func test_referencedHeaderInEncoding() throws { @@ -146,11 +167,25 @@ 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_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() { 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 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#"] ) } 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/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() ) } 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/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 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) 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