diff --git a/Package.swift b/Package.swift index 03e1cfd05..218350b9c 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "OpenAPIKit", platforms: [ - .macOS(.v10_13), + .macOS(.v10_15), .iOS(.v11) ], products: [ diff --git a/README.md b/README.md index 07e427694..5f0eccb65 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ You can create an external reference with `JSONReference.external(URL)`. Interna You can check whether a given `JSONReference` exists in the Components Object with `document.components.contains()`. You can access a referenced object in the Components Object with `document.components[reference]`. -You can create references from the Components Object with `document.components.reference(named:ofType:)`. This method will throw an error if the given component does not exist in the ComponentsObject. +References can be created from the Components Object with `document.components.reference(named:ofType:)`. This method will throw an error if the given component does not exist in the ComponentsObject. You can use `document.components.lookup()` or the `Components` type's `subscript` to turn an `Either` containing either a reference or a component into an optional value of that component's type (having either pulled it out of the `Either` or looked it up in the Components Object). The `lookup()` method throws when it can't find an item whereas `subscript` returns `nil`. @@ -284,7 +284,7 @@ let document = OpenAPI.Document( ``` #### Specification Extensions -Many OpenAPIKit types support [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. +Many OpenAPIKit types support [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#specification-extensions). As described in the OpenAPI Specification, these extensions must be objects that are keyed with the prefix "x-". For example, a property named "specialProperty" on the root OpenAPI Object (`OpenAPI.Document`) is invalid but the property "x-specialProperty" is a valid specification extension. You can get or set specification extensions via the `vendorExtensions` property on any object that supports this feature. The keys are `Strings` beginning with the aforementioned "x-" prefix and the values are `AnyCodable`. If you set an extension without using the "x-" prefix, the prefix will be added upon encoding. @@ -323,11 +323,51 @@ try encodeEqual(URL(string: "https://website.com"), AnyCodable(URL(string: "http ``` ### Dereferencing & Resolving +#### External References +This is currently only available for OAS 3.1 documents (supported by the `OpenAPIKit` module (as opposed to the `OpenAPIKit30` moudle). External dereferencing does not resolve any local (internal) references, it just loads external references into the Document. It does this by storing any loaded externally referenced objects in the Components Object and transforming the reference being resolved from an external reference to an internal one. That way, you can always run internal dereferencing as a second step if you want a fully dereferenced document, but if you simply wanted to load additional referenced files then you can stop after external dereferencing. + +OpenAPIKit leaves it to you to decide how to load external files and where to store the results in the Components Object. It does this by requiring that you provide an implementation of the `ExternalLoader` protocol. You provide a `load` function and a `componentKey` function, both of which accept as input the `URL` to load. A simple mock example implementation from the OpenAPIKit tests will go a long way to showing how the `ExternalLoader` can be set up: + +```swift +struct ExampleLoader: ExternalLoader { + static func load(_ url: URL) async throws -> T where T : Decodable { + // load data from file, perhaps. we will just mock that up for the test: + let data = try await mockData(componentKey(type: T.self, at: url)) + + // We use the YAML decoder mostly for order-stability in this case but it is + // also nice that it will handle both YAML and JSON data. + let decoded = try YAMLDecoder().decode(T.self, from: data) + let finished: T + // while unnecessary, a loader may likely want to attatch some extra info + // to keep track of where a reference was loaded from. + if var extendable = decoded as? VendorExtendable { + extendable.vendorExtensions["x-source-url"] = AnyCodable(url) + finished = extendable as! T + } else { + finished = decoded + } + return finished + } + + static func componentKey(type: T.Type, at url: URL) throws -> OpenAPIKit.OpenAPI.ComponentKey { + // do anything you want here to determine what key the new component should be stored at. + // for the example, we will just transform the URL path into a valid components key: + let urlString = url.pathComponents + .joined(separator: "_") + .replacingOccurrences(of: ".", with: "_") + return try .forceInit(rawValue: urlString) + } +} +``` + +Once you have an `ExternalLoader`, you can call an `OpenAPI.Document`'s `externallyDereference()` method to externally dereference it. You get to choose whether to only load references to a certain depth or to fully resolve references until you run out of them; any given referenced document may itself contain references and these references may point back to things loaded into the Document previously so dereferencing is done recursively up to a given depth (or until fully dereferenced if you use the `.full` depth). + +#### Internal References In addition to looking something up in the `Components` object, you can entirely derefererence many OpenAPIKit types. A dereferenced type has had all of its references looked up (and all of its properties' references, all the way down). You use a value's `dereferenced(in:)` method to fully dereference it. -You can even dereference the whole document with the `OpenAPI.Document` `locallyDereferenced()` method. As the name implies, you can only derefence whole documents that are contained within one file (which is another way of saying that all references are "local"). Specifically, all references must be located within the document's Components Object. +You can even dereference the whole document with the `OpenAPI.Document` `locallyDereferenced()` method. As the name implies, you can only derefence whole documents that are contained within one file (which is another way of saying that all references are "local"). Specifically, all references must be located within the document's Components Object. External dereferencing is done as a separeate step, but you can first dereference externally and then dereference internally if you'd like to perform both. Unlike what happens when you lookup an individual component using the `lookup()` method on `Components`, dereferencing a whole `OpenAPI.Document` will result in type-level changes that guarantee all references are removed. `OpenAPI.Document`'s `locallyDereferenced()` method returns a `DereferencedDocument` which exposes `DereferencedPathItem`s which have `DereferencedParameter`s and `DereferencedOperation`s and so on. diff --git a/Sources/OpenAPIKit/Callbacks.swift b/Sources/OpenAPIKit/Callbacks.swift index 3d1edf793..7a671bdeb 100644 --- a/Sources/OpenAPIKit/Callbacks.swift +++ b/Sources/OpenAPIKit/Callbacks.swift @@ -37,3 +37,9 @@ extension OpenAPI.CallbackURL: LocallyDereferenceable { } } +// The following conformance is theoretically unnecessary but the compiler is +// only able to find the conformance if we explicitly declare it here, though +// it is apparently able to determine the conformance is already satisfied here +// at least. +extension OpenAPI.Callbacks: ExternallyDereferenceable { } + diff --git a/Sources/OpenAPIKit/CodableVendorExtendable.swift b/Sources/OpenAPIKit/CodableVendorExtendable.swift index 9cfa2e0e0..1c75c293e 100644 --- a/Sources/OpenAPIKit/CodableVendorExtendable.swift +++ b/Sources/OpenAPIKit/CodableVendorExtendable.swift @@ -18,7 +18,7 @@ public protocol VendorExtendable { /// These should be of the form: /// `[ "x-extensionKey": ]` /// where the values are anything codable. - var vendorExtensions: VendorExtensions { get } + var vendorExtensions: VendorExtensions { get set } } public enum VendorExtensionsConfiguration { diff --git a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift index 35790e8f2..c1a56f0f0 100644 --- a/Sources/OpenAPIKit/Components Object/Components+Locatable.swift +++ b/Sources/OpenAPIKit/Components Object/Components+Locatable.swift @@ -6,6 +6,7 @@ // import OpenAPIKitCore +import Foundation /// Anything conforming to ComponentDictionaryLocatable knows /// where to find resources of its type in the Components Dictionary. @@ -15,57 +16,57 @@ public protocol ComponentDictionaryLocatable: SummaryOverridable { /// This can be used to create a JSON path /// like `#/name1/name2/name3` static var openAPIComponentsKey: String { get } - static var openAPIComponentsKeyPath: KeyPath> { get } + static var openAPIComponentsKeyPath: WritableKeyPath> { get } } extension JSONSchema: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "schemas" } - public static var openAPIComponentsKeyPath: KeyPath> { \.schemas } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.schemas } } extension OpenAPI.Response: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "responses" } - public static var openAPIComponentsKeyPath: KeyPath> { \.responses } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.responses } } extension OpenAPI.Callbacks: ComponentDictionaryLocatable & SummaryOverridable { public static var openAPIComponentsKey: String { "callbacks" } - public static var openAPIComponentsKeyPath: KeyPath> { \.callbacks } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.callbacks } } extension OpenAPI.Parameter: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "parameters" } - public static var openAPIComponentsKeyPath: KeyPath> { \.parameters } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.parameters } } extension OpenAPI.Example: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "examples" } - public static var openAPIComponentsKeyPath: KeyPath> { \.examples } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.examples } } extension OpenAPI.Request: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "requestBodies" } - public static var openAPIComponentsKeyPath: KeyPath> { \.requestBodies } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.requestBodies } } extension OpenAPI.Header: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "headers" } - public static var openAPIComponentsKeyPath: KeyPath> { \.headers } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.headers } } extension OpenAPI.SecurityScheme: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "securitySchemes" } - public static var openAPIComponentsKeyPath: KeyPath> { \.securitySchemes } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.securitySchemes } } extension OpenAPI.Link: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "links" } - public static var openAPIComponentsKeyPath: KeyPath> { \.links } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.links } } extension OpenAPI.PathItem: ComponentDictionaryLocatable { public static var openAPIComponentsKey: String { "pathItems" } - public static var openAPIComponentsKeyPath: KeyPath> { \.pathItems } + public static var openAPIComponentsKeyPath: WritableKeyPath> { \.pathItems } } /// A dereferenceable type can be recursively looked up in diff --git a/Sources/OpenAPIKit/Components Object/Components.swift b/Sources/OpenAPIKit/Components Object/Components.swift index 07cbc0da6..a92862d76 100644 --- a/Sources/OpenAPIKit/Components Object/Components.swift +++ b/Sources/OpenAPIKit/Components Object/Components.swift @@ -71,6 +71,57 @@ extension OpenAPI { } } +extension OpenAPI.Components { + public struct ComponentCollision: Swift.Error { + public let componentType: String + public let existingComponent: String + public let newComponent: String + } + + private func detectCollision(type: String) throws -> (_ old: T, _ new: T) throws -> T { + return { old, new in + // theoretically we can detect collisions here, but we would need to compare + // for equality up-to but not including the difference between an external and + // internal reference which is not supported yet. +// if(old == new) { return old } +// throw ComponentCollision(componentType: type, existingComponent: String(describing:old), newComponent: String(describing:new)) + + // Given we aren't ensuring there are no collisions, the old version is going to be + // the one more likely to have been _further_ dereferenced than the new record, so + // we keep that version. + return old + } + } + + public mutating func merge(_ other: OpenAPI.Components) throws { + try schemas.merge(other.schemas, uniquingKeysWith: detectCollision(type: "schema")) + try responses.merge(other.responses, uniquingKeysWith: detectCollision(type: "responses")) + try parameters.merge(other.parameters, uniquingKeysWith: detectCollision(type: "parameters")) + try examples.merge(other.examples, uniquingKeysWith: detectCollision(type: "examples")) + try requestBodies.merge(other.requestBodies, uniquingKeysWith: detectCollision(type: "requestBodies")) + try headers.merge(other.headers, uniquingKeysWith: detectCollision(type: "headers")) + try securitySchemes.merge(other.securitySchemes, uniquingKeysWith: detectCollision(type: "securitySchemes")) + try links.merge(other.links, uniquingKeysWith: detectCollision(type: "links")) + try callbacks.merge(other.callbacks, uniquingKeysWith: detectCollision(type: "callbacks")) + try pathItems.merge(other.pathItems, uniquingKeysWith: detectCollision(type: "pathItems")) + try vendorExtensions.merge(other.vendorExtensions, uniquingKeysWith: detectCollision(type: "vendorExtensions")) + } + + /// Sort the components within each type by the component key. + public mutating func sort() { + schemas.sortKeys() + responses.sortKeys() + parameters.sortKeys() + examples.sortKeys() + requestBodies.sortKeys() + headers.sortKeys() + securitySchemes.sortKeys() + links.sortKeys() + callbacks.sortKeys() + pathItems.sortKeys() + } +} + extension OpenAPI.Components { /// The extension name used to store a Components Object name (the key something is stored under /// within the Components Object). This is used by OpenAPIKit to store the previous Component name @@ -268,4 +319,83 @@ extension OpenAPI.Components { } } +extension OpenAPI.Components { + internal mutating func externallyDereference(in context: Context.Type, depth: ExternalDereferenceDepth = .iterations(1)) async throws { + if case let .iterations(number) = depth, + number <= 0 { + return + } + + let oldSchemas = schemas + let oldResponses = responses + let oldParameters = parameters + let oldExamples = examples + let oldRequestBodies = requestBodies + let oldHeaders = headers + let oldSecuritySchemes = securitySchemes + let oldCallbacks = callbacks + let oldPathItems = pathItems + + async let (newSchemas, c1) = oldSchemas.externallyDereferenced(with: context) + async let (newResponses, c2) = oldResponses.externallyDereferenced(with: context) + async let (newParameters, c3) = oldParameters.externallyDereferenced(with: context) + async let (newExamples, c4) = oldExamples.externallyDereferenced(with: context) + async let (newRequestBodies, c5) = oldRequestBodies.externallyDereferenced(with: context) + async let (newHeaders, c6) = oldHeaders.externallyDereferenced(with: context) + async let (newSecuritySchemes, c7) = oldSecuritySchemes.externallyDereferenced(with: context) + async let (newCallbacks, c8) = oldCallbacks.externallyDereferenced(with: context) + async let (newPathItems, c9) = oldPathItems.externallyDereferenced(with: context) + + schemas = try await newSchemas + responses = try await newResponses + parameters = try await newParameters + examples = try await newExamples + requestBodies = try await newRequestBodies + headers = try await newHeaders + securitySchemes = try await newSecuritySchemes + callbacks = try await newCallbacks + pathItems = try await newPathItems + + let c1Resolved = try await c1 + let c2Resolved = try await c2 + let c3Resolved = try await c3 + let c4Resolved = try await c4 + let c5Resolved = try await c5 + let c6Resolved = try await c6 + let c7Resolved = try await c7 + let c8Resolved = try await c8 + let c9Resolved = try await c9 + + let noNewComponents = + c1Resolved.isEmpty + && c2Resolved.isEmpty + && c3Resolved.isEmpty + && c4Resolved.isEmpty + && c5Resolved.isEmpty + && c6Resolved.isEmpty + && c7Resolved.isEmpty + && c8Resolved.isEmpty + && c9Resolved.isEmpty + + if noNewComponents { return } + + try merge(c1Resolved) + try merge(c2Resolved) + try merge(c3Resolved) + try merge(c4Resolved) + try merge(c5Resolved) + try merge(c6Resolved) + try merge(c7Resolved) + try merge(c8Resolved) + try merge(c9Resolved) + + switch depth { + case .iterations(let number): + try await externallyDereference(in: context, depth: .iterations(number - 1)) + case .full: + try await externallyDereference(in: context, depth: .full) + } + } +} + extension OpenAPI.Components: Validatable {} diff --git a/Sources/OpenAPIKit/Content/DereferencedContent.swift b/Sources/OpenAPIKit/Content/DereferencedContent.swift index 20fb7c45d..992eea1a7 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContent.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContent.swift @@ -75,3 +75,30 @@ extension OpenAPI.Content: LocallyDereferenceable { return try DereferencedContent(self, resolvingIn: components, following: references) } } + +extension OpenAPI.Content: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let oldSchema = schema + + async let (newSchema, c1) = oldSchema.externallyDereferenced(with: loader) + + var newContent = self + var newComponents = try await c1 + + newContent.schema = try await newSchema + + if let oldExamples = examples { + let (newExamples, c2) = try await oldExamples.externallyDereferenced(with: loader) + newContent.examples = newExamples + try newComponents.merge(c2) + } + + if let oldEncoding = encoding { + let (newEncoding, c3) = try await oldEncoding.externallyDereferenced(with: loader) + newContent.encoding = newEncoding + try newComponents.merge(c3) + } + + return (newContent, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Content/DereferencedContentEncoding.swift b/Sources/OpenAPIKit/Content/DereferencedContentEncoding.swift index fdd0b1bbc..f6d43cb5e 100644 --- a/Sources/OpenAPIKit/Content/DereferencedContentEncoding.swift +++ b/Sources/OpenAPIKit/Content/DereferencedContentEncoding.swift @@ -56,3 +56,27 @@ extension OpenAPI.Content.Encoding: LocallyDereferenceable { return try DereferencedContentEncoding(self, resolvingIn: components, following: references) } } + +extension OpenAPI.Content.Encoding: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let newHeaders: OpenAPI.Header.Map? + let newComponents: OpenAPI.Components + + if let oldHeaders = headers { + (newHeaders, newComponents) = try await oldHeaders.externallyDereferenced(with: loader) + } else { + newHeaders = nil + newComponents = .init() + } + + let newEncoding = OpenAPI.Content.Encoding( + contentType: contentType, + headers: newHeaders, + style: style, + explode: explode, + allowReserved: allowReserved + ) + + return (newEncoding, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Document/Document.swift b/Sources/OpenAPIKit/Document/Document.swift index d9c293429..75a703363 100644 --- a/Sources/OpenAPIKit/Document/Document.swift +++ b/Sources/OpenAPIKit/Document/Document.swift @@ -324,6 +324,17 @@ extension OpenAPI.Document { } } +public enum ExternalDereferenceDepth { + case iterations(Int) + case full +} + +extension ExternalDereferenceDepth: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .iterations(value) + } +} + extension OpenAPI.Document { /// Create a locally-dereferenced OpenAPI /// Document. @@ -350,6 +361,26 @@ extension OpenAPI.Document { public func locallyDereferenced() throws -> DereferencedDocument { return try DereferencedDocument(self) } + + public mutating func externallyDereference(in context: Context.Type, depth: ExternalDereferenceDepth = .iterations(1)) async throws { + if case let .iterations(number) = depth, + number <= 0 { + return + } + + let oldPaths = paths + let oldWebhooks = webhooks + + async let (newPaths, c1) = oldPaths.externallyDereferenced(with: context) + async let (newWebhooks, c2) = oldWebhooks.externallyDereferenced(with: context) + + paths = try await newPaths + webhooks = try await newWebhooks + try await components.merge(c1) + try await components.merge(c2) + + try await components.externallyDereference(in: context, depth: depth) + } } extension OpenAPI { diff --git a/Sources/OpenAPIKit/Either/Either+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Either/Either+ExternallyDereferenceable.swift new file mode 100644 index 000000000..5dfe12868 --- /dev/null +++ b/Sources/OpenAPIKit/Either/Either+ExternallyDereferenceable.swift @@ -0,0 +1,23 @@ +// +// Either+ExternallyDereferenceable.swift +// +// +// Created by Mathew Polzin on 2/28/21. +// + +import OpenAPIKitCore + +// MARK: - ExternallyDereferenceable +extension Either: ExternallyDereferenceable where A: ExternallyDereferenceable, B: ExternallyDereferenceable { + + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + switch self { + case .a(let a): + let (newA, components) = try await a.externallyDereferenced(with: loader) + return (.a(newA), components) + case .b(let b): + let (newB, components) = try await b.externallyDereferenced(with: loader) + return (.b(newB), components) + } + } +} diff --git a/Sources/OpenAPIKit/Example.swift b/Sources/OpenAPIKit/Example.swift index 0ef738ee2..d61fc8392 100644 --- a/Sources/OpenAPIKit/Example.swift +++ b/Sources/OpenAPIKit/Example.swift @@ -25,7 +25,7 @@ extension OpenAPI { /// These should be of the form: /// `[ "x-extensionKey": ]` /// where the values are anything codable. - public let vendorExtensions: [String: AnyCodable] + public var vendorExtensions: [String: AnyCodable] public init( summary: String? = nil, @@ -208,4 +208,10 @@ extension OpenAPI.Example: LocallyDereferenceable { } } +extension OpenAPI.Example: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + return (self, .init()) + } +} + extension OpenAPI.Example: Validatable {} diff --git a/Sources/OpenAPIKit/ExternalLoader.swift b/Sources/OpenAPIKit/ExternalLoader.swift new file mode 100644 index 000000000..2b62c1699 --- /dev/null +++ b/Sources/OpenAPIKit/ExternalLoader.swift @@ -0,0 +1,34 @@ +// +// ExternalLoader.swift +// +// +// Created by Mathew Polzin on 7/30/2023. +// + +import OpenAPIKitCore +import Foundation + +/// An `ExternalLoader` enables `OpenAPIKit` to load external references +/// without knowing the details of what decoder is being used or how new internal +/// references should be named. +public protocol ExternalLoader { + /// Load the given URL and decode it as Type `T`. All Types `T` are `Decodable`, so + /// the only real responsibility of a `load` function is to locate and load the given + /// `URL` and pass its `Data` or `String` (depending on the decoder) to an appropriate + /// `Decoder` for the given file type. + static func load(_: URL) async throws -> T where T: Decodable + + /// Determine the next Component Key (where to store something in the + /// Components Object) for a new object of the given type that was loaded + /// at the given external URL. + /// + /// - Important: Ideally, this function returns distinct keys for all different objects + /// but the same key for all equal objects. In practice, this probably means that any + /// time the same type and URL pair are passed in the same `ComponentKey` should be + /// returned. + static func componentKey(type: T.Type, at url: URL) throws -> OpenAPI.ComponentKey +} + +public protocol ExternallyDereferenceable { + func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) +} diff --git a/Sources/OpenAPIKit/Header/DereferencedHeader.swift b/Sources/OpenAPIKit/Header/DereferencedHeader.swift index 18f453a6c..554a5b266 100644 --- a/Sources/OpenAPIKit/Header/DereferencedHeader.swift +++ b/Sources/OpenAPIKit/Header/DereferencedHeader.swift @@ -82,3 +82,36 @@ extension OpenAPI.Header: LocallyDereferenceable { return try DereferencedHeader(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } + +extension OpenAPI.Header: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + + // if not for a Swift bug, this whole next bit would just be the + // next line: +// let (newSchemaOrContent, components) = try await schemaOrContent.externallyDereferenced(with: loader) + + let newSchemaOrContent: Either + let newComponents: OpenAPI.Components + + switch schemaOrContent { + case .a(let schemaContext): + let (context, components) = try await schemaContext.externallyDereferenced(with: loader) + newSchemaOrContent = .a(context) + newComponents = components + case .b(let contentMap): + let (map, components) = try await contentMap.externallyDereferenced(with: loader) + newSchemaOrContent = .b(map) + newComponents = components + } + + let newHeader = OpenAPI.Header( + schemaOrContent: newSchemaOrContent, + description: description, + required: required, + deprecated: deprecated, + vendorExtensions: vendorExtensions + ) + + return (newHeader, newComponents) + } +} diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index 9d89efd83..75621e00a 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -537,6 +537,21 @@ extension JSONReference: LocallyDereferenceable where ReferenceType: LocallyDere } } +extension JSONReference: ExternallyDereferenceable where ReferenceType: ExternallyDereferenceable & Decodable & Equatable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + switch self { + case .internal(let ref): + return (.internal(ref), .init()) + case .external(let url): + let componentKey = try loader.componentKey(type: ReferenceType.self, at: url) + let component: ReferenceType = try await loader.load(url) + var components = OpenAPI.Components() + components[keyPath: ReferenceType.openAPIComponentsKeyPath][componentKey] = component + return (try components.reference(named: componentKey.rawValue, ofType: ReferenceType.self).jsonReference, components) + } + } +} + extension OpenAPI.Reference: LocallyDereferenceable where ReferenceType: LocallyDereferenceable { /// Look up the component this reference points to and then /// dereference it. @@ -564,4 +579,11 @@ extension OpenAPI.Reference: LocallyDereferenceable where ReferenceType: Locally } } +extension OpenAPI.Reference: ExternallyDereferenceable where ReferenceType: ExternallyDereferenceable & Decodable & Equatable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let (newRef, components) = try await jsonReference.externallyDereferenced(with: loader) + return (.init(newRef), components) + } +} + extension OpenAPI.Reference: Validatable where ReferenceType: Validatable {} diff --git a/Sources/OpenAPIKit/Link.swift b/Sources/OpenAPIKit/Link.swift index d1d2ac4b4..be416dcff 100644 --- a/Sources/OpenAPIKit/Link.swift +++ b/Sources/OpenAPIKit/Link.swift @@ -20,18 +20,18 @@ extension OpenAPI { /// The **OpenAPI**` `operationRef` or `operationId` field, depending on whether /// a `URL` of a remote or local Operation Object or a `operationId` (String) of an /// operation defined in the same document is given. - public let operation: Either + public var operation: Either /// A map from parameter names to either runtime expressions that evaluate to values or /// constant values (`AnyCodable`). /// /// See the docuemntation for the [OpenAPI Link Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#link-object) for more details. /// /// Empty dictionaries will be omitted from encoding. - public let parameters: OrderedDictionary> + public var parameters: OrderedDictionary> /// A literal value or expression to use as a request body when calling the target operation. - public let requestBody: Either? + public var requestBody: Either? public var description: String? - public let server: Server? + public var server: Server? /// Dictionary of vendor extensions. /// @@ -289,4 +289,15 @@ extension OpenAPI.Link: LocallyDereferenceable { } } +extension OpenAPI.Link: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let (newServer, newComponents) = try await server.externallyDereferenced(with: loader) + + var newLink = self + newLink.server = newServer + + return (newLink, newComponents) + } +} + extension OpenAPI.Link: Validatable {} diff --git a/Sources/OpenAPIKit/Operation/DereferencedOperation.swift b/Sources/OpenAPIKit/Operation/DereferencedOperation.swift index da2a06050..dfdd14c9d 100644 --- a/Sources/OpenAPIKit/Operation/DereferencedOperation.swift +++ b/Sources/OpenAPIKit/Operation/DereferencedOperation.swift @@ -124,3 +124,43 @@ extension OpenAPI.Operation: LocallyDereferenceable { return try DereferencedOperation(self, resolvingIn: components, following: references) } } + +extension OpenAPI.Operation: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let oldParameters = parameters + let oldRequestBody = requestBody + let oldResponses = responses + + async let (newParameters, c1) = oldParameters.externallyDereferenced(with: loader) + async let (newRequestBody, c2) = oldRequestBody.externallyDereferenced(with: loader) + async let (newResponses, c3) = oldResponses.externallyDereferenced(with: loader) + async let (newCallbacks, c4) = callbacks.externallyDereferenced(with: loader) +// let (newServers, c6) = try await servers.externallyDereferenced(with: loader) + + var newOperation = self + var newComponents = try await c1 + + newOperation.parameters = try await newParameters + newOperation.requestBody = try await newRequestBody + try await newComponents.merge(c2) + newOperation.responses = try await newResponses + try await newComponents.merge(c3) + newOperation.callbacks = try await newCallbacks + try await newComponents.merge(c4) + + if let oldServers = servers { + let (newServers, c6) = try await oldServers.externallyDereferenced(with: loader) + newOperation.servers = newServers + try newComponents.merge(c6) + } + + // should not be necessary but current Swift compiler can't figure out conformance of ExternallyDereferenceable: + if let oldServers = servers { + let (newServers, c6) = try await oldServers.externallyDereferenced(with: loader) + newOperation.servers = newServers + try newComponents.merge(c6) + } + + return (newOperation, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Operation/Operation.swift b/Sources/OpenAPIKit/Operation/Operation.swift index 4efdc5899..c5adb49fa 100644 --- a/Sources/OpenAPIKit/Operation/Operation.swift +++ b/Sources/OpenAPIKit/Operation/Operation.swift @@ -76,7 +76,7 @@ extension OpenAPI { /// The key is a unique identifier for the Callback Object. Each value in the /// map is a Callback Object that describes a request that may be initiated /// by the API provider and the expected responses. - public let callbacks: OpenAPI.CallbacksMap + public var callbacks: OpenAPI.CallbacksMap /// Indicates that the operation is deprecated or not. /// diff --git a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift index f2cdf232a..b2b6b9604 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedParameter.swift @@ -82,3 +82,31 @@ extension OpenAPI.Parameter: LocallyDereferenceable { return try DereferencedParameter(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } + +extension OpenAPI.Parameter: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + + // if not for a Swift bug, this whole function would just be the + // next line: +// let (newSchemaOrContent, components) = try await schemaOrContent.externallyDereferenced(with: loader) + + let newSchemaOrContent: Either + let newComponents: OpenAPI.Components + + switch schemaOrContent { + case .a(let schemaContext): + let (context, components) = try await schemaContext.externallyDereferenced(with: loader) + newSchemaOrContent = .a(context) + newComponents = components + case .b(let contentMap): + let (map, components) = try await contentMap.externallyDereferenced(with: loader) + newSchemaOrContent = .b(map) + newComponents = components + } + + var newParameter = self + newParameter.schemaOrContent = newSchemaOrContent + + return (newParameter, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift b/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift index 9801a401a..cea39b921 100644 --- a/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/DereferencedSchemaContext.swift @@ -69,3 +69,24 @@ extension OpenAPI.Parameter.SchemaContext: LocallyDereferenceable { return try DereferencedSchemaContext(self, resolvingIn: components, following: references) } } + +extension OpenAPI.Parameter.SchemaContext: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let oldSchema = schema + + async let (newSchema, c1) = oldSchema.externallyDereferenced(with: loader) + + var newSchemaContext = self + var newComponents = try await c1 + + newSchemaContext.schema = try await newSchema + + if let oldExamples = examples { + let (newExamples, c2) = try await oldExamples.externallyDereferenced(with: loader) + newSchemaContext.examples = newExamples + try newComponents.merge(c2) + } + + return (newSchemaContext, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Parameter/Parameter.swift b/Sources/OpenAPIKit/Parameter/Parameter.swift index 608126efb..9c4cf5611 100644 --- a/Sources/OpenAPIKit/Parameter/Parameter.swift +++ b/Sources/OpenAPIKit/Parameter/Parameter.swift @@ -21,6 +21,8 @@ extension OpenAPI { /// parameters in the given location. public var context: Context public var description: String? + /// Whether or not the parameter is deprecated. Defaults to false + /// if unspecified and only gets encoded if true. public var deprecated: Bool // default is false /// OpenAPI Spec "content" or "schema" properties. @@ -46,7 +48,10 @@ extension OpenAPI { /// where the values are anything codable. public var vendorExtensions: [String: AnyCodable] + /// Whether or not this parameter is required. See the context + /// which determines whether the parameter is required or not. public var required: Bool { context.required } + /// The location (e.g. "query") of the parameter. /// /// See the `context` property for more details on the diff --git a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift index 336305738..cd65f89c5 100644 --- a/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift +++ b/Sources/OpenAPIKit/Parameter/ParameterSchemaContext.swift @@ -13,13 +13,13 @@ extension OpenAPI.Parameter { /// See [OpenAPI Parameter Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#parameter-object) /// and [OpenAPI Style Values](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#style-values). public struct SchemaContext: Equatable { - public let style: Style - public let explode: Bool - public let allowReserved: Bool //defaults to false - public let schema: Either, JSONSchema> + public var style: Style + public var explode: Bool + public var allowReserved: Bool //defaults to false + public var schema: Either, JSONSchema> - public let example: AnyCodable? - public let examples: OpenAPI.Example.Map? + public var example: AnyCodable? + public var examples: OpenAPI.Example.Map? public init(_ schema: JSONSchema, style: Style, diff --git a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift index 93767b025..fc284b1df 100644 --- a/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift +++ b/Sources/OpenAPIKit/Path Item/DereferencedPathItem.swift @@ -6,6 +6,7 @@ // import OpenAPIKitCore +import Foundation /// An `OpenAPI.PathItem` type that guarantees /// its `parameters` and operations are inlined instead of @@ -137,3 +138,93 @@ extension OpenAPI.PathItem: LocallyDereferenceable { return try DereferencedPathItem(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } + +extension OpenAPI.PathItem: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let oldParameters = parameters + let oldServers = servers + let oldGet = get + let oldPut = put + let oldPost = post + let oldDelete = delete + let oldOptions = options + let oldHead = head + let oldPatch = patch + let oldTrace = trace + + async let (newParameters, c1) = oldParameters.externallyDereferenced(with: loader) +// async let (newServers, c2) = oldServers.externallyDereferenced(with: loader) +// async let (newGet, c3) = oldGet.externallyDereferenced(with: loader) +// async let (newPut, c4) = oldPut.externallyDereferenced(with: loader) +// async let (newPost, c5) = oldPost.externallyDereferenced(with: loader) +// async let (newDelete, c6) = oldDelete.externallyDereferenced(with: loader) +// async let (newOptions, c7) = oldOptions.externallyDereferenced(with: loader) +// async let (newHead, c8) = oldHead.externallyDereferenced(with: loader) +// async let (newPatch, c9) = oldPatch.externallyDereferenced(with: loader) +// async let (newTrace, c10) = oldTrace.externallyDereferenced(with: loader) + + var pathItem = self + var newComponents = try await c1 + + // ideally we would async let all of the props above and then set them here, + // but for now since there seems to be some sort of compiler bug we will do + // most of them in if lets below + pathItem.parameters = try await newParameters + + if let oldServers { + async let (newServers, c2) = oldServers.externallyDereferenced(with: loader) + pathItem.servers = try await newServers + try await newComponents.merge(c2) + } + + if let oldGet { + async let (newGet, c3) = oldGet.externallyDereferenced(with: loader) + pathItem.get = try await newGet + try await newComponents.merge(c3) + } + + if let oldPut { + async let (newPut, c4) = oldPut.externallyDereferenced(with: loader) + pathItem.put = try await newPut + try await newComponents.merge(c4) + } + + if let oldPost { + async let (newPost, c5) = oldPost.externallyDereferenced(with: loader) + pathItem.post = try await newPost + try await newComponents.merge(c5) + } + + if let oldDelete { + async let (newDelete, c6) = oldDelete.externallyDereferenced(with: loader) + pathItem.delete = try await newDelete + try await newComponents.merge(c6) + } + + if let oldOptions { + async let (newOptions, c7) = oldOptions.externallyDereferenced(with: loader) + pathItem.options = try await newOptions + try await newComponents.merge(c7) + } + + if let oldHead { + async let (newHead, c8) = oldHead.externallyDereferenced(with: loader) + pathItem.head = try await newHead + try await newComponents.merge(c8) + } + + if let oldPatch { + async let (newPatch, c9) = oldPatch.externallyDereferenced(with: loader) + pathItem.patch = try await newPatch + try await newComponents.merge(c9) + } + + if let oldTrace { + async let (newTrace, c10) = oldTrace.externallyDereferenced(with: loader) + pathItem.trace = try await newTrace + try await newComponents.merge(c10) + } + + return (pathItem, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Request/DereferencedRequest.swift b/Sources/OpenAPIKit/Request/DereferencedRequest.swift index e7da192a5..a5dce43bd 100644 --- a/Sources/OpenAPIKit/Request/DereferencedRequest.swift +++ b/Sources/OpenAPIKit/Request/DereferencedRequest.swift @@ -61,3 +61,14 @@ extension OpenAPI.Request: LocallyDereferenceable { return try DereferencedRequest(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } + +extension OpenAPI.Request: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + var newRequest = self + + let (newContent, components) = try await content.externallyDereferenced(with: loader) + + newRequest.content = newContent + return (newRequest, components) + } +} diff --git a/Sources/OpenAPIKit/Response/DereferencedResponse.swift b/Sources/OpenAPIKit/Response/DereferencedResponse.swift index c61ae5c7b..363e33448 100644 --- a/Sources/OpenAPIKit/Response/DereferencedResponse.swift +++ b/Sources/OpenAPIKit/Response/DereferencedResponse.swift @@ -76,3 +76,28 @@ extension OpenAPI.Response: LocallyDereferenceable { return try DereferencedResponse(self, resolvingIn: components, following: references, dereferencedFromComponentNamed: name) } } + +extension OpenAPI.Response: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let oldContent = content + let oldLinks = links + + async let (newContent, c1) = oldContent.externallyDereferenced(with: loader) + async let (newLinks, c2) = oldLinks.externallyDereferenced(with: loader) + + var response = self + response.content = try await newContent + response.links = try await newLinks + + var components = try await c1 + try await components.merge(c2) + + if let oldHeaders = headers { + let (newHeaders, c3) = try await oldHeaders.externallyDereferenced(with: loader) + response.headers = newHeaders + try components.merge(c3) + } + + return (response, components) + } +} diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 270d89287..faeccde28 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -534,3 +534,103 @@ extension JSONSchema: LocallyDereferenceable { return try? dereferenced(in: .noComponents) } } + +extension JSONSchema: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + let newSchema: JSONSchema + let newComponents: OpenAPI.Components + + switch value { + case .null(_): + newComponents = .noComponents + newSchema = self + case .boolean(_): + newComponents = .noComponents + newSchema = self + case .number(_, _): + newComponents = .noComponents + newSchema = self + case .integer(_, _): + newComponents = .noComponents + newSchema = self + case .string(_, _): + newComponents = .noComponents + newSchema = self + case .object(let core, let object): + var components = OpenAPI.Components() + + let (newProperties, c1) = try await object.properties.externallyDereferenced(with: loader) + try components.merge(c1) + + let newAdditionalProperties: Either? + if case .b(let schema) = object.additionalProperties { + let (additionalProperties, c2) = try await schema.externallyDereferenced(with: loader) + try components.merge(c2) + newAdditionalProperties = .b(additionalProperties) + } else { + newAdditionalProperties = object.additionalProperties + } + newComponents = components + newSchema = .init( + schema: .object( + core, + .init( + properties: newProperties, + additionalProperties: newAdditionalProperties, + maxProperties: object.maxProperties, + minProperties: object._minProperties + ) + ) + ) + case .array(let core, let array): + let (newItems, components) = try await array.items.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .array( + core, + .init( + items: newItems, + maxItems: array.maxItems, + minItems: array._minItems, + uniqueItems: array._uniqueItems + ) + ) + ) + case .all(let schema, let core): + let (newSubschemas, components) = try await schema.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .all(of: newSubschemas, core: core) + ) + case .one(let schema, let core): + let (newSubschemas, components) = try await schema.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .one(of: newSubschemas, core: core) + ) + case .any(let schema, let core): + let (newSubschemas, components) = try await schema.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .any(of: newSubschemas, core: core) + ) + case .not(let schema, let core): + let (newSubschema, components) = try await schema.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .not(newSubschema, core: core) + ) + case .reference(let reference, let core): + let (newReference, components) = try await reference.externallyDereferenced(with: loader) + newComponents = components + newSchema = .init( + schema: .reference(newReference, core) + ) + case .fragment(_): + newComponents = .noComponents + newSchema = self + } + + return (newSchema, newComponents) + } +} diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index 139d08622..cba7fe6d6 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -438,7 +438,12 @@ extension JSONSchema: VendorExtendable { /// `[ "x-extensionKey": ]` /// where the values are anything codable. public var vendorExtensions: VendorExtensions { - coreContext.vendorExtensions + get { + coreContext.vendorExtensions + } + set { + coreContext.vendorExtensions + } } public func with(vendorExtensions: [String: AnyCodable]) -> JSONSchema { diff --git a/Sources/OpenAPIKit/Security/SecurityScheme.swift b/Sources/OpenAPIKit/Security/SecurityScheme.swift index ee15c6f0c..a304bccfd 100644 --- a/Sources/OpenAPIKit/Security/SecurityScheme.swift +++ b/Sources/OpenAPIKit/Security/SecurityScheme.swift @@ -273,3 +273,9 @@ extension OpenAPI.SecurityScheme: LocallyDereferenceable { return ret } } + +extension OpenAPI.SecurityScheme: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + return (self, .init()) + } +} diff --git a/Sources/OpenAPIKit/Server.swift b/Sources/OpenAPIKit/Server.swift index 2b7d17b2c..4bea3607f 100644 --- a/Sources/OpenAPIKit/Server.swift +++ b/Sources/OpenAPIKit/Server.swift @@ -259,5 +259,11 @@ extension OpenAPI.Server.Variable { } } +extension OpenAPI.Server: ExternallyDereferenceable { + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + return (self, .init()) + } +} + extension OpenAPI.Server: Validatable {} extension OpenAPI.Server.Variable: Validatable {} diff --git a/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift new file mode 100644 index 000000000..1435c2c95 --- /dev/null +++ b/Sources/OpenAPIKit/Utility/Array+ExternallyDereferenceable.swift @@ -0,0 +1,30 @@ +// +// Array+ExternallyDereferenceable.swift +// + +import OpenAPIKitCore + +extension Array where Element: ExternallyDereferenceable { + + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + try await withThrowingTaskGroup(of: (Int, (Element, OpenAPI.Components)).self) { group in + for (idx, elem) in zip(self.indices, self) { + group.addTask { + return try await (idx, elem.externallyDereferenced(with: loader)) + } + } + + var newElems = Array<(Int, Element)>() + var newComponents = OpenAPI.Components() + + for try await (idx, (elem, components)) in group { + newElems.append((idx, elem)) + try newComponents.merge(components) + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + newElems.sort { left, right in left.0 < right.0 } + return (newElems.map { $0.1 }, newComponents) + } + } +} diff --git a/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift new file mode 100644 index 000000000..2ba0a8fcb --- /dev/null +++ b/Sources/OpenAPIKit/Utility/Dictionary+ExternallyDereferenceable.swift @@ -0,0 +1,29 @@ +// +// Dictionary+ExternallyDereferenceable.swift +// OpenAPI +// + +import OpenAPIKitCore + +extension Dictionary where Value: ExternallyDereferenceable { + + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components) + } + } + + var newDict = Self() + var newComponents = OpenAPI.Components() + + for try await (key, newRef, components) in group { + newDict[key] = newRef + try newComponents.merge(components) + } + return (newDict, newComponents) + } + } +} diff --git a/Sources/OpenAPIKit/Utility/Optional+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/Optional+ExternallyDereferenceable.swift new file mode 100644 index 000000000..adf2b1816 --- /dev/null +++ b/Sources/OpenAPIKit/Utility/Optional+ExternallyDereferenceable.swift @@ -0,0 +1,13 @@ +// +// Optional+ExternallyDereferenceable.swift +// + +import OpenAPIKitCore + +extension Optional where Wrapped: ExternallyDereferenceable { + + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + guard let wrapped = self else { return (nil, .init()) } + return try await wrapped.externallyDereferenced(with: loader) + } +} diff --git a/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift new file mode 100644 index 000000000..283aab817 --- /dev/null +++ b/Sources/OpenAPIKit/Utility/OrderedDictionary+ExternallyDereferenceable.swift @@ -0,0 +1,34 @@ +// +// OrderedDictionary+ExternallyDereferenceable.swift +// OpenAPI +// +// Created by Mathew Polzin on 08/05/2023. +// + +import OpenAPIKitCore + +extension OrderedDictionary where Value: ExternallyDereferenceable { + + public func externallyDereferenced(with loader: Context.Type) async throws -> (Self, OpenAPI.Components) { + try await withThrowingTaskGroup(of: (Key, Value, OpenAPI.Components).self) { group in + for (key, value) in self { + group.addTask { + let (newRef, components) = try await value.externallyDereferenced(with: loader) + return (key, newRef, components) + } + } + + var newDict = Self() + var newComponents = OpenAPI.Components() + + for try await (key, newRef, components) in group { + newDict[key] = newRef + try newComponents.merge(components) + } + // things may come in out of order because of concurrency + // so we reorder after completing all entries. + try newDict.applyOrder(self) + return (newDict, newComponents) + } + } +} diff --git a/Sources/OpenAPIKit30/Components Object/Components.swift b/Sources/OpenAPIKit30/Components Object/Components.swift index ca03ff146..93bcad2f5 100644 --- a/Sources/OpenAPIKit30/Components Object/Components.swift +++ b/Sources/OpenAPIKit30/Components Object/Components.swift @@ -260,4 +260,25 @@ extension OpenAPI.Components { } } +public extension OpenAPI.Components { + struct ValueCollision: Swift.Error { + let value1: T + let value2: T + } + + mutating func merge(_ components: OpenAPI.Components) throws { + try schemas.merge(components.schemas, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try responses.merge(components.responses, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try parameters.merge(components.parameters, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try examples.merge(components.examples, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try requestBodies.merge(components.requestBodies, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try headers.merge(components.headers, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try securitySchemes.merge(components.securitySchemes, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try links.merge(components.links, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try callbacks.merge(components.callbacks, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try pathItems.merge(components.pathItems, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + try vendorExtensions.merge(components.vendorExtensions, uniquingKeysWith: { (v1, v2) in throw OpenAPI.Components.ValueCollision(value1: v1, value2: v2) }) + } +} + extension OpenAPI.Components: Validatable {} diff --git a/Sources/OpenAPIKitCore/OrderedDictionary/OrderedDictionary.swift b/Sources/OpenAPIKitCore/OrderedDictionary/OrderedDictionary.swift index 8779f30ec..8a9cd91a8 100644 --- a/Sources/OpenAPIKitCore/OrderedDictionary/OrderedDictionary.swift +++ b/Sources/OpenAPIKitCore/OrderedDictionary/OrderedDictionary.swift @@ -159,6 +159,30 @@ public struct OrderedDictionary: HasWarnings where Key: Hashable { } return ret } + + struct KeysDontMatch : Swift.Error {} + + /// Given two ordered dictionaries with the exact same keys, + /// apply the ordering of one to the other. This will throw if + /// the dictionary keys are not the same. + public mutating func applyOrder(_ other: Self) throws { + guard other.orderedKeys.count == orderedKeys.count, + other.orderedKeys.allSatisfy({ orderedKeys.contains($0) }) else { + throw KeysDontMatch() + } + + orderedKeys = other.orderedKeys + } + + public mutating func sortKeys(by sort: (Key, Key) throws -> Bool) rethrows { + try orderedKeys.sort(by: sort) + } +} + +extension OrderedDictionary where Key: Comparable { + public mutating func sortKeys() { + orderedKeys.sort() + } } // MARK: - Dictionary Literal @@ -205,6 +229,18 @@ extension OrderedDictionary: Collection { } } +extension OrderedDictionary { + public mutating func merge(_ other: OrderedDictionary, uniquingKeysWith resolve: (Value, Value) throws -> Value) rethrows { + for (key, value) in other { + if let conflict = self[key] { + self[key] = try resolve(conflict, value) + } else { + self[key] = value + } + } + } +} + // MARK: - Iterator extension OrderedDictionary { public struct Iterator: Sequence, IteratorProtocol { @@ -239,7 +275,6 @@ extension OrderedDictionary: Equatable where Value: Equatable { } // MARK: - Codable - public struct AnyCodingKey: CodingKey { public let stringValue: String diff --git a/Sources/OpenAPIKitCore/Shared/ComponentKey.swift b/Sources/OpenAPIKitCore/Shared/ComponentKey.swift index f8ff7f48e..251ad343a 100644 --- a/Sources/OpenAPIKitCore/Shared/ComponentKey.swift +++ b/Sources/OpenAPIKitCore/Shared/ComponentKey.swift @@ -31,6 +31,16 @@ extension Shared { self.rawValue = rawValue } + public static func forceInit(rawValue: String?) throws -> ComponentKey { + guard let rawValue = rawValue else { + throw InvalidComponentKey() + } + guard let value = ComponentKey(rawValue: rawValue) else { + throw InvalidComponentKey(Self.problem(with: rawValue), rawValue: rawValue) + } + return value + } + public static func problem(with proposedString: String) -> String? { if Self(rawValue: proposedString) == nil { return "Keys for components in the Components Object must conform to the regex `^[a-zA-Z0-9\\.\\-_]+$`. '\(proposedString)' does not.." @@ -66,4 +76,23 @@ extension Shared { try container.encode(rawValue) } } + + public struct InvalidComponentKey: Swift.Error { + public let description: String + + internal init() { + description = "Failed to create a ComponentKey" + } + + internal init(_ message: String?, rawValue: String) { + description = message + ?? "Failed to create a ComponentKey from \(rawValue)" + } + } +} + +extension Shared.ComponentKey: Comparable { + public static func < (lhs: Shared.ComponentKey, rhs: Shared.ComponentKey) -> Bool { + lhs.rawValue < rhs.rawValue + } } diff --git a/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift new file mode 100644 index 000000000..914e575cc --- /dev/null +++ b/Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift @@ -0,0 +1,252 @@ +// +// ExternalDereferencingDocumentTests.swift +// + +import Foundation +import Yams +import OpenAPIKit +import XCTest + +final class ExternalDereferencingDocumentTests: XCTestCase { + // temporarily test with an example of the new interface + func test_example() async throws { + + /// An example of implementing a loader context for loading external references + /// into an OpenAPI document. + struct ExampleLoader: ExternalLoader { + static func load(_ url: URL) async throws -> T where T : Decodable { + // load data from file, perhaps. we will just mock that up for the test: + let data = try await mockData(componentKey(type: T.self, at: url)) + + // We use the YAML decoder purely for order-stability. + let decoded = try YAMLDecoder().decode(T.self, from: data) + let finished: T + // while unnecessary, a loader may likely want to attatch some extra info + // to keep track of where a reference was loaded from. This test makes sure + // the following strategy of using vendor extensions works. + if var extendable = decoded as? VendorExtendable { + extendable.vendorExtensions["x-source-url"] = AnyCodable(url) + finished = extendable as! T + } else { + finished = decoded + } + return finished + } + + static func componentKey(type: T.Type, at url: URL) throws -> OpenAPIKit.OpenAPI.ComponentKey { + // do anything you want here to determine what key the new component should be stored at. + // for the example, we will just transform the URL into a valid components key: + let urlString = url.pathComponents.dropFirst() + .joined(separator: "_") + .replacingOccurrences(of: ".", with: "_") + return try .forceInit(rawValue: urlString) + } + + /// Mock up some data, just for the example. + static func mockData(_ key: OpenAPIKit.OpenAPI.ComponentKey) async throws -> Data { + return try XCTUnwrap(files[key.rawValue]) + } + + static let files: [String: Data] = [ + "params_name_json": """ + { + "name": "name", + "description": "a lonely parameter", + "in": "path", + "required": true, + "schema": { + "$ref": "file://./schemas/string_param.json#" + } + } + """, + "schemas_string_param_json": """ + { + "oneOf": [ + { "type": "string" }, + { "$ref": "file://./schemas/basic_object.json" } + ] + } + """, + "schemas_basic_object_json": """ + { + "type": "object" + } + """, + "paths_webhook_json": """ + { + "summary": "just a webhook", + "get": { + "requestBody": { + "$ref": "file://./requests/webhook.json" + }, + "responses": { + "200": { + "$ref": "file://./responses/webhook.json" + } + } + } + } + """, + "requests_webhook_json": """ + { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "$ref": "file://./schemas/string_param.json" + } + } + }, + "examples": { + "good": { + "$ref": "file://./examples/good.json" + } + }, + "encoding": { + "enc1": { + "headers": { + "head1": { + "$ref": "file://./headers/webhook.json" + } + } + } + } + } + } + } + """, + "responses_webhook_json": """ + { + "description": "webhook response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "length": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "headers": { + "X-Hello": { + "$ref": "file://./headers/webhook.json" + } + } + } + """, + "headers_webhook_json": """ + { + "schema": { + "$ref": "file://./schemas/string_param.json" + } + } + """, + "examples_good_json": """ + { + "value": "{\\"body\\": \\"request me\\"}" + } + """, + "callbacks_one_json": """ + { + "https://callback.site.com/callback": { + "$ref": "file://./paths/callback.json" + } + } + """, + "paths_callback_json": """ + { + "summary": "just a callback", + "get": { + "responses": { + "200": { + "description": "callback response", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "links": { + "link1": { + "$ref": "file://./links/first.json" + } + } + } + } + } + } + """, + "links_first_json": """ + { + "operationId": "helloOp" + } + """ + ].mapValues { $0.data(using: .utf8)! } + } + + let document = OpenAPI.Document( + info: .init(title: "test document", version: "1.0.0"), + servers: [], + paths: [ + "/hello/{name}": .init( + parameters: [ + .reference(.external(URL(string: "file://./params/name.json")!)) + ], + get: .init( + operationId: "helloOp", + responses: [:], + callbacks: [ + "callback1": .reference(.external(URL(string: "file://./callbacks/one.json")!)) + ] + ) + ), + "/goodbye/{name}": .init( + parameters: [ + .reference(.external(URL(string: "file://./params/name.json")!)) + ] + ), + "/webhook": .reference(.external(URL(string: "file://./paths/webhook.json")!)) + ], + webhooks: [ + "webhook": .reference(.external(URL(string: "file://./paths/webhook.json")!)) + ], + components: .init( + schemas: [ + "name_param": .reference(.external(URL(string: "file://./schemas/string_param.json")!)) + ], + // just to show, no parameters defined within document components : + parameters: [:] + ) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + var docCopy1 = document + try await docCopy1.externallyDereference(in: ExampleLoader.self) + try await docCopy1.externallyDereference(in: ExampleLoader.self) + try await docCopy1.externallyDereference(in: ExampleLoader.self) + docCopy1.components.sort() + + var docCopy2 = document + try await docCopy2.externallyDereference(in: ExampleLoader.self, depth: 3) + docCopy2.components.sort() + + var docCopy3 = document + try await docCopy3.externallyDereference(in: ExampleLoader.self, depth: .full) + docCopy3.components.sort() + + XCTAssertEqual(docCopy1, docCopy2) + XCTAssertEqual(docCopy2, docCopy3) + } +} diff --git a/Tests/OpenAPIKitTests/JSONReferenceTests.swift b/Tests/OpenAPIKitTests/JSONReferenceTests.swift index 53c7a8b64..c5e74f18f 100644 --- a/Tests/OpenAPIKitTests/JSONReferenceTests.swift +++ b/Tests/OpenAPIKitTests/JSONReferenceTests.swift @@ -377,9 +377,52 @@ extension JSONReferenceTests { } } +// MARK: - External Dereferencing +extension JSONReferenceTests { + func test_externalDerefNoFragment() async throws { + let reference: JSONReference = .external(.init(string: "./schema.json")!) + + let (newReference, components) = try await reference.externallyDereferenced(with: SchemaLoader.self) + + XCTAssertEqual(newReference, .component(named: "__schema_json")) + XCTAssertEqual(components, .init(schemas: ["__schema_json": .string])) + } + + func test_externalDerefFragment() async throws { + let reference: JSONReference = .external(.init(string: "./schema.json#/test")!) + + let (newReference, components) = try await reference.externallyDereferenced(with: SchemaLoader.self) + + XCTAssertEqual(newReference, .component(named: "__schema_json__test")) + XCTAssertEqual(components, .init(schemas: ["__schema_json__test": .string])) + } + + func test_externalDerefExternalComponents() async throws { + let reference: JSONReference = .external(.init(string: "./schema.json#/components/schemas/test")!) + + let (newReference, components) = try await reference.externallyDereferenced(with: SchemaLoader.self) + + XCTAssertEqual(newReference, .component(named: "__schema_json__components_schemas_test")) + XCTAssertEqual(components, .init(schemas: ["__schema_json__components_schemas_test": .string])) + } +} + // MARK: - Test Types extension JSONReferenceTests { struct ReferenceWrapper: Codable, Equatable { let reference: JSONReference } + + struct SchemaLoader: ExternalLoader { + static func load(_ url: URL) -> T where T: Decodable { + return JSONSchema.string as! T + } + + static func componentKey(type: T.Type, at url: URL) throws -> OpenAPI.ComponentKey { + return try .forceInit(rawValue: url.absoluteString + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "#", with: "_") + .replacingOccurrences(of: ".", with: "_")) + } + } } diff --git a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift index db97d6b4a..22c9e2601 100644 --- a/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift +++ b/Tests/OpenAPIKitTests/Path Item/PathItemTests.swift @@ -7,6 +7,7 @@ import XCTest import OpenAPIKit +import Foundation final class PathItemTests: XCTestCase { func test_initializePathComponents() { diff --git a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift index f907b433c..389ee2ddf 100644 --- a/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift +++ b/Tests/OpenAPIKitTests/Validator/BuiltinValidationTests.swift @@ -755,7 +755,7 @@ final class BuiltinValidationTests: XCTestCase { components: .noComponents ) - // NOTE this is part of default validation + // NOTE these are part of default validation XCTAssertThrowsError(try document.validate()) { error in let error = error as? ValidationErrorCollection XCTAssertEqual(error?.values.count, 8) diff --git a/Tests/OpenAPIKitTests/VendorExtendableTests.swift b/Tests/OpenAPIKitTests/VendorExtendableTests.swift index 5a49e5714..b42b0c0bc 100644 --- a/Tests/OpenAPIKitTests/VendorExtendableTests.swift +++ b/Tests/OpenAPIKitTests/VendorExtendableTests.swift @@ -145,7 +145,7 @@ private struct TestStruct: Codable, CodableVendorExtendable { } } - public let vendorExtensions: Self.VendorExtensions + public var vendorExtensions: Self.VendorExtensions init(vendorExtensions: Self.VendorExtensions) { self.vendorExtensions = vendorExtensions