Skip to content

Commit 8508f98

Browse files
authored
Merge pull request #440 from mattpolzin/feature/436/document-changes
Add Document's $self property
2 parents e285b2a + 38b3deb commit 8508f98

File tree

2 files changed

+146
-6
lines changed

2 files changed

+146
-6
lines changed

Sources/OpenAPIKit/Document/Document.swift

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import OpenAPIKitCore
9+
import Foundation
910

1011
extension OpenAPI {
1112
/// The root of an OpenAPI 3.1 document.
@@ -45,14 +46,17 @@ extension OpenAPI {
4546
///
4647
/// See the documentation on `DereferencedDocument.resolved()` for more.
4748
///
48-
public struct Document: HasWarnings, CodableVendorExtendable, Sendable {
49+
public struct Document: HasConditionalWarnings, HasWarnings, CodableVendorExtendable, Sendable {
4950
/// OpenAPI Spec "openapi" field.
5051
///
5152
/// OpenAPIKit only explicitly supports versions that can be found in
5253
/// the `Version` enum. Other versions may or may not be decodable
5354
/// by OpenAPIKit to a certain extent.
5455
public var openAPIVersion: Version
5556

57+
/// OpenAPI Spec "$self" field.
58+
public var selfURI: URL?
59+
5660
/// Information about the API described by this OpenAPI Document.
5761
///
5862
/// Licensing, Terms of Service, contact information, API version (the
@@ -142,9 +146,11 @@ extension OpenAPI {
142146
public var vendorExtensions: [String: AnyCodable]
143147

144148
public let warnings: [Warning]
149+
public let conditionalWarnings: [(any Condition, OpenAPI.Warning)]
145150

146151
public init(
147152
openAPIVersion: Version = .v3_1_1,
153+
selfURI: URL? = nil,
148154
info: Info,
149155
servers: [Server],
150156
paths: PathItem.Map,
@@ -156,6 +162,7 @@ extension OpenAPI {
156162
vendorExtensions: [String: AnyCodable] = [:]
157163
) {
158164
self.openAPIVersion = openAPIVersion
165+
self.selfURI = selfURI
159166
self.info = info
160167
self.servers = servers
161168
self.paths = paths
@@ -167,13 +174,28 @@ extension OpenAPI {
167174
self.vendorExtensions = vendorExtensions
168175

169176
self.warnings = []
177+
178+
self.conditionalWarnings = [
179+
// If $self is non-nil, the document must be OAS version 3.2.0 or greater
180+
nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0),
181+
].compactMap { $0 }
170182
}
171183
}
172184
}
173185

186+
fileprivate func nonNilVersionWarning<Subject>(fieldName: String, value: Subject?, minimumVersion: OpenAPI.Document.Version) -> (any Condition, OpenAPI.Warning)? {
187+
value.map { _ in
188+
OpenAPI.Document.ConditionalWarnings.version(
189+
lessThan: minimumVersion,
190+
doesNotSupport: "The Document \(fieldName) field"
191+
)
192+
}
193+
}
194+
174195
extension OpenAPI.Document: Equatable {
175196
public static func == (lhs: Self, rhs: Self) -> Bool {
176197
lhs.openAPIVersion == rhs.openAPIVersion
198+
&& lhs.selfURI == rhs.selfURI
177199
&& lhs.info == rhs.info
178200
&& lhs.servers == rhs.servers
179201
&& lhs.paths == rhs.paths
@@ -602,6 +624,9 @@ extension OpenAPI.Document: Encodable {
602624
var container = encoder.container(keyedBy: CodingKeys.self)
603625

604626
try container.encode(openAPIVersion, forKey: .openAPIVersion)
627+
628+
try container.encodeIfPresent(selfURI?.absoluteString, forKey: .selfURI)
629+
605630
try container.encode(info, forKey: .info)
606631

607632
try container.encodeIfPresent(externalDocs, forKey: .externalDocs)
@@ -661,6 +686,11 @@ extension OpenAPI.Document: Decodable {
661686
)
662687
}
663688

689+
let selfURIString: String? = try container.decodeIfPresent(String.self, forKey: .selfURI)
690+
selfURI = try selfURIString.map {
691+
try decodeURIString($0, forKey: CodingKeys.selfURI, atPath: decoder.codingPath)
692+
}
693+
664694
info = try container.decode(OpenAPI.Document.Info.self, forKey: .info)
665695
servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? []
666696

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

682712
self.warnings = warnings
683713

714+
self.conditionalWarnings = [
715+
// If $self is non-nil, the document must be OAS version 3.2.0 or greater
716+
nonNilVersionWarning(fieldName: "$self", value: selfURI, minimumVersion: .v3_2_0),
717+
].compactMap { $0 }
718+
684719
} catch let error as OpenAPI.Error.Decoding.Path {
685720

686721
throw OpenAPI.Error.Decoding.Document(error)
@@ -697,9 +732,34 @@ extension OpenAPI.Document: Decodable {
697732
}
698733
}
699734

735+
fileprivate func decodeURIString(_ str: String, forKey key: CodingKey, atPath path: [CodingKey]) throws -> URL {
736+
let uri: URL?
737+
#if canImport(FoundationEssentials)
738+
uri = URL(string: str, encodingInvalidCharacters: false)
739+
#elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
740+
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
741+
uri = URL(string: str, encodingInvalidCharacters: false)
742+
} else {
743+
uri = URL(string: str)
744+
}
745+
#else
746+
uri = URL(string: str)
747+
#endif
748+
guard let uri else {
749+
throw GenericError(
750+
subjectName: key.stringValue,
751+
details: "Failed to parse a valid URI from '\(str)'",
752+
codingPath: path
753+
)
754+
}
755+
756+
return uri
757+
}
758+
700759
extension OpenAPI.Document {
701760
internal enum CodingKeys: ExtendableCodingKey {
702761
case openAPIVersion
762+
case selfURI
703763
case info
704764
case jsonSchemaDialect // TODO: implement parsing (https://github.com/mattpolzin/OpenAPIKit/issues/202)
705765
case servers
@@ -714,6 +774,7 @@ extension OpenAPI.Document {
714774
static var allBuiltinKeys: [CodingKeys] {
715775
return [
716776
.openAPIVersion,
777+
.selfURI,
717778
.info,
718779
.jsonSchemaDialect,
719780
.servers,
@@ -734,6 +795,8 @@ extension OpenAPI.Document {
734795
switch stringValue {
735796
case "openapi":
736797
self = .openAPIVersion
798+
case "$self":
799+
self = .selfURI
737800
case "info":
738801
self = .info
739802
case "jsonSchemaDialect":
@@ -761,6 +824,8 @@ extension OpenAPI.Document {
761824
switch self {
762825
case .openAPIVersion:
763826
return "openapi"
827+
case .selfURI:
828+
return "$self"
764829
case .info:
765830
return "info"
766831
case .jsonSchemaDialect:

Tests/OpenAPIKitTests/Document/DocumentTests.swift

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ final class DocumentTests: XCTestCase {
2020

2121
let _ = OpenAPI.Document(
2222
openAPIVersion: .v3_1_0,
23+
selfURI: .init(string: "https://example.com/openapi")!,
2324
info: .init(title: "hi", version: "1.0"),
2425
servers: [
2526
.init(url: URL(string: "https://google.com")!)
@@ -135,7 +136,12 @@ final class DocumentTests: XCTestCase {
135136
"/hello": .init(
136137
get: .init(operationId: nil, responses: [:])),
137138
"/hello/world": .init(
138-
put: .init(operationId: nil, responses: [:]))
139+
put: .init(operationId: nil, responses: [:])),
140+
"/hi/mom": .init(
141+
additionalOperations: [
142+
"LINK": .init(operationId: nil, responses: [:])
143+
]
144+
)
139145
],
140146
components: .noComponents
141147
)
@@ -150,7 +156,12 @@ final class DocumentTests: XCTestCase {
150156
"/hello": .init(
151157
get: .init(operationId: "test", responses: [:])),
152158
"/hello/world": .init(
153-
put: .init(operationId: nil, responses: [:]))
159+
put: .init(operationId: nil, responses: [:])),
160+
"/hi/mom": .init(
161+
additionalOperations: [
162+
"LINK": .init(operationId: nil, responses: [:])
163+
]
164+
)
154165
],
155166
components: .noComponents
156167
)
@@ -165,12 +176,17 @@ final class DocumentTests: XCTestCase {
165176
"/hello": .init(
166177
get: .init(operationId: "test", responses: [:])),
167178
"/hello/world": .init(
168-
put: .init(operationId: "two", responses: [:]))
179+
put: .init(operationId: "two", responses: [:])),
180+
"/hi/mom": .init(
181+
additionalOperations: [
182+
"LINK": .init(operationId: "three", responses: [:])
183+
]
184+
)
169185
],
170186
components: .noComponents
171187
)
172188

173-
XCTAssertEqual(t3.allOperationIds, ["test", "two"])
189+
XCTAssertEqual(t3.allOperationIds, ["test", "two", "three"])
174190

175191
// paths, one operation id (first one nil), no components, no webhooks
176192
let t4 = OpenAPI.Document(
@@ -180,7 +196,12 @@ final class DocumentTests: XCTestCase {
180196
"/hello": .init(
181197
get: .init(operationId: nil, responses: [:])),
182198
"/hello/world": .init(
183-
put: .init(operationId: "two", responses: [:]))
199+
put: .init(operationId: "two", responses: [:])),
200+
"/hi/mom": .init(
201+
additionalOperations: [
202+
"LINK": .init(operationId: nil, responses: [:])
203+
]
204+
)
184205
],
185206
components: .noComponents
186207
)
@@ -690,6 +711,60 @@ extension DocumentTests {
690711
)
691712
}
692713

714+
func test_specifySelfURI_encode() throws {
715+
let document = OpenAPI.Document(
716+
selfURI: .init(string: "https://example.com/openapi")!,
717+
info: .init(title: "API", version: "1.0"),
718+
servers: [],
719+
paths: [:],
720+
components: .noComponents
721+
)
722+
let encodedDocument = try orderUnstableTestStringFromEncoding(of: document)
723+
724+
assertJSONEquivalent(
725+
encodedDocument,
726+
"""
727+
{
728+
"$self" : "https:\\/\\/example.com\\/openapi",
729+
"info" : {
730+
"title" : "API",
731+
"version" : "1.0"
732+
},
733+
"openapi" : "3.1.1"
734+
}
735+
"""
736+
)
737+
}
738+
739+
func test_specifySelfURI_decode() throws {
740+
let documentData =
741+
"""
742+
{
743+
"$self": "https://example.com/openapi",
744+
"info" : {
745+
"title" : "API",
746+
"version" : "1.0"
747+
},
748+
"openapi" : "3.1.1",
749+
"paths" : {
750+
751+
}
752+
}
753+
""".data(using: .utf8)!
754+
let document = try orderUnstableDecode(OpenAPI.Document.self, from: documentData)
755+
756+
XCTAssertEqual(
757+
document,
758+
OpenAPI.Document(
759+
selfURI: .init(string: "https://example.com/openapi")!,
760+
info: .init(title: "API", version: "1.0"),
761+
servers: [],
762+
paths: [:],
763+
components: .noComponents
764+
)
765+
)
766+
}
767+
693768
func test_specifyPaths_encode() throws {
694769
let document = OpenAPI.Document(
695770
info: .init(title: "API", version: "1.0"),

0 commit comments

Comments
 (0)