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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion Sources/OpenAPIKit/Document/Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import OpenAPIKitCore
import Foundation

extension OpenAPI {
/// The root of an OpenAPI 3.1 document.
Expand Down Expand Up @@ -45,14 +46,17 @@ extension OpenAPI {
///
/// See the documentation on `DereferencedDocument.resolved()` for more.
///
public struct Document: HasWarnings, CodableVendorExtendable, Sendable {
public struct Document: HasConditionalWarnings, HasWarnings, CodableVendorExtendable, Sendable {
/// OpenAPI Spec "openapi" field.
///
/// OpenAPIKit only explicitly supports versions that can be found in
/// the `Version` enum. Other versions may or may not be decodable
/// by OpenAPIKit to a certain extent.
public var openAPIVersion: Version

/// OpenAPI Spec "$self" field.
public var selfURI: URL?

/// Information about the API described by this OpenAPI Document.
///
/// Licensing, Terms of Service, contact information, API version (the
Expand Down Expand Up @@ -142,9 +146,11 @@ extension OpenAPI {
public var vendorExtensions: [String: AnyCodable]

public let warnings: [Warning]
public let conditionalWarnings: [(any Condition, OpenAPI.Warning)]

public init(
openAPIVersion: Version = .v3_1_1,
selfURI: URL? = nil,
info: Info,
servers: [Server],
paths: PathItem.Map,
Expand All @@ -156,6 +162,7 @@ extension OpenAPI {
vendorExtensions: [String: AnyCodable] = [:]
) {
self.openAPIVersion = openAPIVersion
self.selfURI = selfURI
self.info = info
self.servers = servers
self.paths = paths
Expand All @@ -167,13 +174,28 @@ extension OpenAPI {
self.vendorExtensions = vendorExtensions

self.warnings = []

self.conditionalWarnings = [
// If $self is non-nil, the document must be OAS version 3.2.0 or greater
nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0),
].compactMap { $0 }
}
}
}

fileprivate func nonNilVersionWarning<Subject>(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? {
value.map { _ in
OpenAPI.Document.ConditionalWarnings.version(
lessThan: minimumVersion,
doesNotSupport: "The Document \(fieldName) field"
)
}
}

extension OpenAPI.Document: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.openAPIVersion == rhs.openAPIVersion
&& lhs.selfURI == rhs.selfURI
&& lhs.info == rhs.info
&& lhs.servers == rhs.servers
&& lhs.paths == rhs.paths
Expand Down Expand Up @@ -602,6 +624,9 @@ extension OpenAPI.Document: Encodable {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encode(openAPIVersion, forKey: .openAPIVersion)

try container.encodeIfPresent(selfURI?.absoluteString, forKey: .selfURI)

try container.encode(info, forKey: .info)

try container.encodeIfPresent(externalDocs, forKey: .externalDocs)
Expand Down Expand Up @@ -661,6 +686,11 @@ extension OpenAPI.Document: Decodable {
)
}

let selfURIString: String? = try container.decodeIfPresent(String.self, forKey: .selfURI)
selfURI = try selfURIString.map {
try decodeURIString($0, forKey: CodingKeys.selfURI, atPath: decoder.codingPath)
}

info = try container.decode(OpenAPI.Document.Info.self, forKey: .info)
servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? []

Expand All @@ -681,6 +711,11 @@ extension OpenAPI.Document: Decodable {

self.warnings = warnings

self.conditionalWarnings = [
// If $self is non-nil, the document must be OAS version 3.2.0 or greater
nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0),
].compactMap { $0 }

} catch let error as OpenAPI.Error.Decoding.Path {

throw OpenAPI.Error.Decoding.Document(error)
Expand All @@ -697,9 +732,34 @@ extension OpenAPI.Document: Decodable {
}
}

fileprivate func decodeURIString(_ str: String, forKey key: CodingKey, atPath path: [CodingKey]) throws -> URL {
let uri: URL?
#if canImport(FoundationEssentials)
uri = URL(string: str, encodingInvalidCharacters: false)
#elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
uri = URL(string: str, encodingInvalidCharacters: false)
} else {
uri = URL(string: str)
}
#else
uri = URL(string: str)
#endif
guard let uri else {
throw GenericError(
subjectName: key.stringValue,
details: "Failed to parse a valid URI from '\(str)'",
codingPath: path
)
}

return uri
}

extension OpenAPI.Document {
internal enum CodingKeys: ExtendableCodingKey {
case openAPIVersion
case selfURI
case info
case jsonSchemaDialect // TODO: implement parsing (https://github.com/mattpolzin/OpenAPIKit/issues/202)
case servers
Expand All @@ -714,6 +774,7 @@ extension OpenAPI.Document {
static var allBuiltinKeys: [CodingKeys] {
return [
.openAPIVersion,
.selfURI,
.info,
.jsonSchemaDialect,
.servers,
Expand All @@ -734,6 +795,8 @@ extension OpenAPI.Document {
switch stringValue {
case "openapi":
self = .openAPIVersion
case "$self":
self = .selfURI
case "info":
self = .info
case "jsonSchemaDialect":
Expand Down Expand Up @@ -761,6 +824,8 @@ extension OpenAPI.Document {
switch self {
case .openAPIVersion:
return "openapi"
case .selfURI:
return "$self"
case .info:
return "info"
case .jsonSchemaDialect:
Expand Down
85 changes: 80 additions & 5 deletions Tests/OpenAPIKitTests/Document/DocumentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class DocumentTests: XCTestCase {

let _ = OpenAPI.Document(
openAPIVersion: .v3_1_0,
selfURI: .init(string: "https://example.com/openapi")!,
info: .init(title: "hi", version: "1.0"),
servers: [
.init(url: URL(string: "https://google.com")!)
Expand Down Expand Up @@ -135,7 +136,12 @@ final class DocumentTests: XCTestCase {
"/hello": .init(
get: .init(operationId: nil, responses: [:])),
"/hello/world": .init(
put: .init(operationId: nil, responses: [:]))
put: .init(operationId: nil, responses: [:])),
"/hi/mom": .init(
additionalOperations: [
"LINK": .init(operationId: nil, responses: [:])
]
)
],
components: .noComponents
)
Expand All @@ -150,7 +156,12 @@ final class DocumentTests: XCTestCase {
"/hello": .init(
get: .init(operationId: "test", responses: [:])),
"/hello/world": .init(
put: .init(operationId: nil, responses: [:]))
put: .init(operationId: nil, responses: [:])),
"/hi/mom": .init(
additionalOperations: [
"LINK": .init(operationId: nil, responses: [:])
]
)
],
components: .noComponents
)
Expand All @@ -165,12 +176,17 @@ final class DocumentTests: XCTestCase {
"/hello": .init(
get: .init(operationId: "test", responses: [:])),
"/hello/world": .init(
put: .init(operationId: "two", responses: [:]))
put: .init(operationId: "two", responses: [:])),
"/hi/mom": .init(
additionalOperations: [
"LINK": .init(operationId: "three", responses: [:])
]
)
],
components: .noComponents
)

XCTAssertEqual(t3.allOperationIds, ["test", "two"])
XCTAssertEqual(t3.allOperationIds, ["test", "two", "three"])

// paths, one operation id (first one nil), no components, no webhooks
let t4 = OpenAPI.Document(
Expand All @@ -180,7 +196,12 @@ final class DocumentTests: XCTestCase {
"/hello": .init(
get: .init(operationId: nil, responses: [:])),
"/hello/world": .init(
put: .init(operationId: "two", responses: [:]))
put: .init(operationId: "two", responses: [:])),
"/hi/mom": .init(
additionalOperations: [
"LINK": .init(operationId: nil, responses: [:])
]
)
],
components: .noComponents
)
Expand Down Expand Up @@ -690,6 +711,60 @@ extension DocumentTests {
)
}

func test_specifySelfURI_encode() throws {
let document = OpenAPI.Document(
selfURI: .init(string: "https://example.com/openapi")!,
info: .init(title: "API", version: "1.0"),
servers: [],
paths: [:],
components: .noComponents
)
let encodedDocument = try orderUnstableTestStringFromEncoding(of: document)

assertJSONEquivalent(
encodedDocument,
"""
{
"$self" : "https:\\/\\/example.com\\/openapi",
"info" : {
"title" : "API",
"version" : "1.0"
},
"openapi" : "3.1.1"
}
"""
)
}

func test_specifySelfURI_decode() throws {
let documentData =
"""
{
"$self": "https://example.com/openapi",
"info" : {
"title" : "API",
"version" : "1.0"
},
"openapi" : "3.1.1",
"paths" : {

}
}
""".data(using: .utf8)!
let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData)

XCTAssertEqual(
document,
OpenAPI.Document(
selfURI: .init(string: "https://example.com/openapi")!,
info: .init(title: "API", version: "1.0"),
servers: [],
paths: [:],
components: .noComponents
)
)
}

func test_specifyPaths_encode() throws {
let document = OpenAPI.Document(
info: .init(title: "API", version: "1.0"),
Expand Down