diff --git a/Sources/OpenAPIKit/JSONDynamicReference.swift b/Sources/OpenAPIKit/JSONDynamicReference.swift new file mode 100644 index 000000000..8373c8665 --- /dev/null +++ b/Sources/OpenAPIKit/JSONDynamicReference.swift @@ -0,0 +1,125 @@ +// +// JSONDynamicReference.swift +// +// +// Created by Mathew Polzin. +// + +import OpenAPIKitCore +import Foundation + +@dynamicMemberLookup +public struct JSONDynamicReference: Equatable, Hashable { + public let jsonReference: JSONReference + + public init( + _ reference: JSONReference + ) { + self.jsonReference = reference + } + + public subscript(dynamicMember path: KeyPath, T>) -> T { + return jsonReference[keyPath: path] + } + + /// Reference a component of type `ReferenceType` in the + /// Components Object. + /// + /// Example: + /// + /// JSONDynamicReference.component(named: "greetings") + /// // encoded string: "#/components/schemas/greetings" + /// // Swift: `document.components.schemas["greetings"]` + public static func component(named name: String) -> Self { + return .init(.internal(.component(name: name))) + } + + /// Reference a dynamic anchor local to this file. + /// + /// - Important: The anchor does not contain a leading '#'. + public static func anchor(_ anchor: String) -> Self { + return .init(.internal(.anchor(anchor))) + } + + /// Reference an external URL. + public static func external(_ url: URL) -> Self { + return .init(.external(url)) + } + + /// `true` for internal references, `false` for + /// external references (i.e. to another file). + public var isInternal: Bool { + return jsonReference.isInternal + } + + /// `true` for external references, `false` for + /// internal references. + public var isExternal: Bool { + return jsonReference.isExternal + } + + /// Get the name of the referenced object. This method returns optional + /// because a reference to an external file might not have any path if the + /// file itself is the referenced component. + public var name: String? { + return jsonReference.name + } + + /// The absolute value of an external reference's + /// URL or the path fragment string for a local + /// reference as defined in [RFC 3986](https://tools.ietf.org/html/rfc3986). + public var absoluteString: String { + return jsonReference.absoluteString + } +} + +extension JSONDynamicReference { + private enum CodingKeys: String, CodingKey { + case dynamicRef = "$dynamicRef" + } +} + +extension JSONDynamicReference: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self.jsonReference { + case .internal(let reference): + try container.encode(reference.rawValue, forKey: .dynamicRef) + case .external(uri: let url): + try container.encode(url.absoluteString, forKey: .dynamicRef) + } + } +} + +extension JSONDynamicReference: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let referenceString = try container.decode(String.self, forKey: .dynamicRef) + + guard referenceString.count > 0 else { + throw DecodingError.dataCorruptedError(forKey: .dynamicRef, in: container, debugDescription: "Expected a reference string, but found an empty string instead.") + } + + if referenceString.first == "#" { + guard let internalReference = JSONReference.InternalReference(rawValue: referenceString) else { + throw InconsistencyError( + subjectName: "JSON Dynamic Reference", + details: "Failed to parse a JSON Dynamic Reference from '\(referenceString)'", + codingPath: container.codingPath + ) + } + self = .init(.internal(internalReference)) + } else { + guard let externalReference = URL(string: referenceString) else { + throw InconsistencyError( + subjectName: "JSON Dynamic Reference", + details: "Failed to parse a valid URI for a JSON Dynamic Reference from '\(referenceString)'", + codingPath: container.codingPath + ) + } + self = .init(.external(externalReference)) + } + } +} diff --git a/Sources/OpenAPIKit/JSONReference.swift b/Sources/OpenAPIKit/JSONReference.swift index 9d89efd83..ab138a23d 100644 --- a/Sources/OpenAPIKit/JSONReference.swift +++ b/Sources/OpenAPIKit/JSONReference.swift @@ -129,6 +129,8 @@ public enum JSONReference: Equatabl case component(name: String) /// The reference refers to some path outside the Components Object. case path(Path) + /// The reference refers to some anchor anywhere in the local Schema. + case anchor(String) /// Get the name of the referenced object. /// @@ -147,6 +149,8 @@ public enum JSONReference: Equatabl return name case .path(let path): return path.components.last?.stringValue + case .anchor(let name): + return name } } @@ -164,6 +168,10 @@ public enum JSONReference: Equatabl } let fragment = rawValue.dropFirst() guard fragment.starts(with: "/components") else { + guard fragment.first == "/" else { + self = .anchor(String(fragment)) + return + } self = .path(Path(rawValue: String(fragment))) return } @@ -190,6 +198,8 @@ public enum JSONReference: Equatabl return "#/components/\(ReferenceType.openAPIComponentsKey)/\(name)" case .path(let path): return "#\(path.rawValue)" + case .anchor(let name): + return "#\(name)" } } } @@ -395,6 +405,13 @@ public extension JSONReference { } } +public extension JSONReference where ReferenceType == JSONSchema { + /// Create a dynamic JSON Reference from the given JSONReference. + var dynamicReference: JSONDynamicReference { + JSONDynamicReference(self) + } +} + /// `SummaryOverridable` exists to provide a parent protocol to `OpenAPIDescribable` /// and `OpenAPISummarizable`. The structure is designed to provide default no-op /// implementations of both the members of this protocol to all types that implement either diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index cee14a77d..c70daa62a 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -27,6 +27,7 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { indirect case one(of: [DereferencedJSONSchema], core: CoreContext) indirect case any(of: [DereferencedJSONSchema], core: CoreContext) indirect case not(DereferencedJSONSchema, core: CoreContext) + case dynamicReference(JSONDynamicReference, CoreContext) /// Schemas without a `type`. case fragment(CoreContext) // This is the "{}" case where not even a type constraint is given. @@ -65,6 +66,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { return .any(of: schemas.map { $0.jsonSchema }, core: coreContext) case .not(let schema, core: let coreContext): return .not(schema.jsonSchema, core: coreContext) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext) case .fragment(let context): return .fragment(context) } @@ -96,6 +99,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { return .any(of: schemas, core: core.optionalContext()) case .not(let schema, core: let core): return .not(schema, core: core.optionalContext()) + case .dynamicReference(let reference, let core): + return .dynamicReference(reference, core.optionalContext()) } } @@ -185,6 +190,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { return coreContext.vendorExtensions case .not(_, core: let coreContext): return coreContext.vendorExtensions + case .dynamicReference(_, let coreContext): + return coreContext.vendorExtensions case .fragment(let context): return context.vendorExtensions } @@ -215,6 +222,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { return .any(of: schemas, core: coreContext.with(description: description)) case .not(let schema, core: let coreContext): return .not(schema, core: coreContext.with(description: description)) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext.with(description: description)) case .fragment(let context): return .fragment(context.with(description: description)) } @@ -245,6 +254,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext { return .any(of: schemas, core: coreContext.with(vendorExtensions: vendorExtensions)) case .not(let schema, core: let coreContext): return .not(schema, core: coreContext.with(vendorExtensions: vendorExtensions)) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, coreContext.with(vendorExtensions: vendorExtensions)) case .fragment(let context): return .fragment(context.with(vendorExtensions: vendorExtensions)) } @@ -526,6 +537,8 @@ extension JSONSchema: LocallyDereferenceable { return .any(of: schemas, core: addComponentNameExtension(to: coreContext)) case .not(let jsonSchema, core: let coreContext): return .not(try jsonSchema._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil), core: addComponentNameExtension(to: coreContext)) + case .dynamicReference(let reference, let coreContext): + return .dynamicReference(reference, addComponentNameExtension(to: coreContext)) case .fragment(let context): return .fragment(addComponentNameExtension(to: context)) } diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift index a8f48b3a5..d461d5949 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift @@ -173,6 +173,8 @@ internal struct FragmentCombiner { self.combinedFragment = .array(try leftCoreContext.combined(with: rightCoreContext), arrayContext) case (.fragment(let leftCoreContext), .object(let rightCoreContext, let objectContext)): self.combinedFragment = .object(try leftCoreContext.combined(with: rightCoreContext), objectContext) + case (.fragment(let leftCoreContext), .dynamicReference(let reference, let rightCoreContext)): + self.combinedFragment = .dynamicReference(reference, try leftCoreContext.combined(with: rightCoreContext)) case (.boolean(let leftCoreContext), .boolean(let rightCoreContext)): self.combinedFragment = .boolean(try leftCoreContext.combined(with: rightCoreContext)) @@ -208,7 +210,8 @@ internal struct FragmentCombiner { (.string, _), (.array, _), (.object, _), - (.null, _): + (.null, _), + (.dynamicReference, _): throw ( zip(combinedFragment.jsonType, fragment.jsonType).map { JSONSchemaResolutionError(.typeConflict(original: $0, new: $1)) @@ -258,6 +261,8 @@ internal struct FragmentCombiner { jsonSchema = try .any(of: schemas, core: coreContext.validatedContext()) case .one(of: let schemas, core: let coreContext): jsonSchema = try .one(of: schemas, core: coreContext.validatedContext()) + case .dynamicReference(let reference, let coreContext): + jsonSchema = try .dynamicReference(reference, coreContext.validatedContext()) case .not: throw JSONSchemaResolutionError(.unsupported(because: "`.not` is not yet supported for schema simplification")) } diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index 499cacf90..b9320c9fd 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -63,6 +63,9 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings { public static func reference(_ reference: JSONReference, _ context: CoreContext) -> Self { .init(schema: .reference(reference, context)) } + public static func dynamicReference(_ reference: JSONDynamicReference, _ context: CoreContext) -> Self { + .init(schema: .dynamicReference(reference, context)) + } /// Schemas without a `type`. public static func fragment(_ core: CoreContext) -> Self { .init(schema: .fragment(core)) @@ -83,6 +86,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings { indirect case any(of: [JSONSchema], core: CoreContext) indirect case not(JSONSchema, core: CoreContext) case reference(JSONReference, CoreContext) + case dynamicReference(JSONDynamicReference, CoreContext) /// Schemas without a `type`. case fragment(CoreContext) // This allows for the "{}" case and also fragments of schemas that will later be combined with `all(of:)`. } @@ -104,7 +108,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings { return .integer(context.format) case .string(let context, _): return .string(context.format) - case .all, .one, .any, .not, .reference, .fragment: + case .all, .one, .any, .not, .reference, .dynamicReference, .fragment: return nil } } @@ -145,7 +149,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings { .any(of: _, core: let context), .not(_, core: let context): return context.format.rawValue - case .reference, .null: + case .reference, .dynamicReference, .null: return nil } } @@ -176,7 +180,7 @@ public struct JSONSchema: JSONSchemaContext, HasWarnings { .any(of: _, core: let context as JSONSchemaContext), .not(_, core: let context as JSONSchemaContext): return context.discriminator - case .reference: + case .reference, .dynamicReference: return nil } } @@ -327,10 +331,20 @@ extension JSONSchema { } /// Check if a schema is a `.reference`. + /// + /// This returns `false` if the schema is a + /// `.dynamicReference` even though a + /// "dynamic reference" is a "reference". public var isReference: Bool { guard case .reference = value else { return false } return true } + + /// Check if a schema is a `.dynamicReference`. + public var isDynamicReference: Bool { + guard case .reference = value else { return false } + return true + } } // MARK: - Context Accessors @@ -352,7 +366,8 @@ extension JSONSchema { .one(of: _, core: let context as JSONSchemaContext), .any(of: _, core: let context as JSONSchemaContext), .not(_, core: let context as JSONSchemaContext), - .reference(_, let context as JSONSchemaContext): + .reference(_, let context as JSONSchemaContext), + .dynamicReference(_, let context as JSONSchemaContext): return context } } @@ -481,6 +496,8 @@ extension JSONSchema.Schema { return .not(of, core: core.with(vendorExtensions: vendorExtensions)) case .reference(let context, let coreContext): return .reference(context, coreContext.with(vendorExtensions: vendorExtensions)) + case .dynamicReference(let context, let coreContext): + return .dynamicReference(context, coreContext.with(vendorExtensions: vendorExtensions)) case .fragment(let context): return .fragment(context.with(vendorExtensions: vendorExtensions)) } @@ -552,6 +569,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(reference, context.optionalContext()) ) + case .dynamicReference(let reference, let context): + return .init( + warnings: warnings, + schema: .dynamicReference(reference, context.optionalContext()) + ) case .null(let context): return .init( warnings: warnings, @@ -623,6 +645,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(reference, context.requiredContext()) ) + case .dynamicReference(let reference, let context): + return .init( + warnings: warnings, + schema: .dynamicReference(reference, context.requiredContext()) + ) case .null(let context): return .init( warnings: warnings, @@ -689,7 +716,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.nullableContext()) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -758,6 +785,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(allowedValues: allowedValues)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(allowedValues: allowedValues)) + ) case .null(let core): return .init( warnings: warnings, @@ -829,6 +861,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(defaultValue: defaultValue)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(defaultValue: defaultValue)) + ) case .null(let core): return .init( warnings: warnings, @@ -907,6 +944,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(schema, core.with(examples: examples)) ) + case .dynamicReference(let schema, let core): + return .init( + warnings: warnings, + schema: .dynamicReference(schema, core.with(examples: examples)) + ) case .null(let core): return .init( warnings: warnings, @@ -973,7 +1015,7 @@ extension JSONSchema { warnings: warnings, schema: .not(schema, core: core.with(discriminator: discriminator)) ) - case .reference, .null: + case .reference, .dynamicReference, .null: return self } } @@ -1041,6 +1083,11 @@ extension JSONSchema { warnings: warnings, schema: .reference(ref, referenceContext.with(description: description)) ) + case .dynamicReference(let ref, let referenceContext): + return .init( + warnings: warnings, + schema: .dynamicReference(ref, referenceContext.with(description: description)) + ) case .null(let referenceContext): return .init( warnings: warnings, @@ -1795,6 +1842,21 @@ extension JSONSchema { .init(required: required, title: title, description: description, anchor: anchor, dynamicAnchor: dynamicAnchor, defs: defs) ) } + + /// Construct a dynamic reference schema + public static func dynamicReference( + _ reference: JSONDynamicReference, + required: Bool = true, + title: String? = nil, + description: String? = nil, + anchor: String? = nil, + dynamicAnchor: String? = nil + ) -> JSONSchema { + return .dynamicReference( + reference, + .init(required: required, title: title, description: description, anchor: anchor, dynamicAnchor: dynamicAnchor) + ) + } } // MARK: - Describable @@ -1890,6 +1952,10 @@ extension JSONSchema: Encodable { try core.encode(to: encoder) try reference.encode(to: encoder) + case .dynamicReference(let reference, let core): + try core.encode(to: encoder) + try reference.encode(to: encoder) + case .fragment(let context): var container = encoder.singleValueContainer() @@ -1928,6 +1994,11 @@ extension JSONSchema: Decodable { self = .init(warnings: coreContext.warnings, schema: .reference(ref, coreContext)) return } + if let dynamicRef = try? JSONDynamicReference(from: decoder) { + let coreContext = try CoreContext(from: decoder) + self = .init(warnings: coreContext.warnings, schema: .dynamicReference(dynamicRef, coreContext)) + return + } let container = try decoder.container(keyedBy: SubschemaCodingKeys.self) diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift new file mode 100644 index 000000000..a61fcf2d0 --- /dev/null +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaDynamicReferenceTests.swift @@ -0,0 +1,119 @@ +// +// JSONSchemaDynamicReferenceTests.swift +// +// +// Created by Mathew Polzin. +// + +import Foundation +import XCTest +import OpenAPIKit + +final class SchemaObjectDynamicReferenceTests: XCTestCase { + func test_tmp() throws { + let testComponents = OpenAPI.Components( + schemas: [ + "genericList": .array( + items: .dynamicReference(.anchor("T")), + defs: [ + "generic-param": .all(of: [], core: .init(dynamicAnchor: "T")) + ] + ), + "intList": .reference( + .component(named: "genericList"), + defs: [ + "string-param": .string(dynamicAnchor: "T") + ] + ) + ] + ) + + let jsonString = """ + { + "schemas" : { + "genericList" : { + "$defs" : { + "T" : { + "$dynamicAnchor" : "T", + "allOf" : [ + + ] + } + }, + "items" : { + "$dynamicRef" : "#T" + }, + "type" : "array" + }, + "intList" : { + "$defs" : { + "T" : { + "$dynamicAnchor" : "T", + "type" : "string" + } + }, + "$ref" : "#/components/schemas/genericList" + } + } + } + """ + + XCTAssertEqual( + testComponents, + try orderUnstableDecode(OpenAPI.Components.self, from: jsonString.data(using: .utf8)!) + ) + } + + func test_tmp2() throws { + let testComponents = OpenAPI.Components( + schemas: [ + "genericList": .array( + items: .dynamicReference(.anchor("T")), + defs: [ + "generic-param": .all(of: [], core: .init(dynamicAnchor: "T")) + ] + ), + "intList": .reference( + .component(named: "genericList"), + defs: [ + "string-param": .string(dynamicAnchor: "T") + ] + ) + ] + ) + + let testDoc = OpenAPI.Document( + info: .init(title: "test", version: "1.0.0"), + servers: [], + paths: [:], + components: testComponents + ) + + let dereferenced = try testDoc.locallyDereferenced() + + print(dereferenced.components.schemas["intList"]!.defs) + } + + func buildDynamicContext(_ defs: OrderedDictionary) -> [String: JSONSchema] { + var dynamicContext: [String: JSONSchema] = [:] + for (_, def) in defs { + if let anchor = def.dynamicAnchor { + dynamicContext[anchor] = def + } + } + return dynamicContext + } + + func t(_ source: JSONSchema, _ components: OpenAPI.Components) throws -> JSONSchema { + return try tp( + source, + dynamicContext: buildDynamicContext(components.schemas) + ) + } + + func tp(_ source: JSONSchema, dynamicContext: [String: JSONSchema]) throws -> JSONSchema { + var localDynamicContext = buildDynamicContext(source.defs) + + localDynamicContext.merge(dynamicContext, uniquingKeysWith: { local, global in local }) + } +}