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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions Sources/OpenAPIKit/JSONDynamicReference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//
// JSONDynamicReference.swift
//
//
// Created by Mathew Polzin on 3/19/24.
//

import OpenAPIKitCore

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

/// A `JSONDynamicReference` represents a JSON Schema `$dynamicRef`
/// (JSON Schema 2020-12, [§7.7](https://json-schema.org/draft/2020-12/json-schema-core#section-7.7)).
///
/// Like `JSONReference`, a dynamic reference can point either to a component
/// in the Components Object, to another location within the same document
/// (including a `$dynamicAnchor`), or to another file.
///
/// OpenAPIKit parses and round-trips `$dynamicRef`; it does not perform
/// dynamic-scope evaluation, which is a runtime concern belonging to JSON
/// Schema validators rather than the OpenAPI document model. Local
/// dereferencing (`locallyDereferenced()`) resolves a `$dynamicRef` to the
/// outermost in-scope `$dynamicAnchor` where the dynamic scope can be
/// determined from the document's static structure; on cycles and
/// unresolvable references the dynamic reference is preserved as-is.
@dynamicMemberLookup
public struct JSONDynamicReference: Equatable, Hashable, Sendable {
public let jsonReference: JSONReference<JSONSchema>

public init(_ reference: JSONReference<JSONSchema>) {
self.jsonReference = reference
}

public subscript<T>(dynamicMember path: KeyPath<JSONReference<JSONSchema>, T>) -> T {
return jsonReference[keyPath: path]
}

/// Reference a component of type `JSONSchema` 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 `$dynamicAnchor` (or `$anchor`) local to this document.
///
/// - Important: `anchor` does not contain a leading '#'.
public static func anchor(_ anchor: String) -> Self {
return .init(.internal(.anchor(anchor)))
}

/// Reference a path internal to this file but not within the Components Object.
public static func `internal`(path: JSONReference<JSONSchema>.Path) -> Self {
return .init(.internal(.path(path)))
}

/// 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
}
}

public extension JSONReference where ReferenceType == JSONSchema {
/// Create a `JSONDynamicReference` from this `JSONReference`.
var dynamicReference: JSONDynamicReference {
JSONDynamicReference(self)
}
}

// MARK: - Codable

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 jsonReference {
case .internal(let reference):
try container.encode(reference.rawValue, forKey: .dynamicRef)
case .external(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<JSONSchema>.InternalReference(rawValue: referenceString) else {
throw GenericError(
subjectName: "JSON Dynamic Reference",
details: "Failed to parse a JSON Dynamic Reference from '\(referenceString)'",
codingPath: container.codingPath
)
}
self = .init(.internal(internalReference))
} else {
let externalReference: URL?
#if canImport(FoundationEssentials)
externalReference = URL(string: referenceString, encodingInvalidCharacters: false)
#elseif os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
externalReference = URL(string: referenceString, encodingInvalidCharacters: false)
} else {
externalReference = URL(string: referenceString)
}
#else
externalReference = URL(string: referenceString)
#endif
guard let externalReference else {
throw GenericError(
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))
}
}
}

extension JSONDynamicReference: Validatable {}
13 changes: 13 additions & 0 deletions Sources/OpenAPIKit/JSONReference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ public enum JSONReference<ReferenceType: ComponentDictionaryLocatable>: Equatabl
case component(name: String)
/// The reference refers to some path outside the Components Object.
case path(Path)
/// The reference refers to a plain URI fragment identifying a
/// `$dynamicAnchor` or `$anchor` (e.g. `#category`).
case anchor(String)

/// Get the name of the referenced object.
///
Expand All @@ -149,6 +152,8 @@ public enum JSONReference<ReferenceType: ComponentDictionaryLocatable>: Equatabl
return name
case .path(let path):
return path.components.last?.stringValue
case .anchor(let name):
return name
}
}

Expand All @@ -166,6 +171,12 @@ public enum JSONReference<ReferenceType: ComponentDictionaryLocatable>: Equatabl
}
let fragment = rawValue.dropFirst()
guard fragment.starts(with: "/components") else {
// A fragment that does not start with '/' (e.g. "#category")
// is a plain anchor reference used by `$anchor` / `$dynamicAnchor`.
guard fragment.first == "/" else {
self = .anchor(String(fragment))
return
}
self = .path(Path(rawValue: String(fragment)))
return
}
Expand All @@ -192,6 +203,8 @@ public enum JSONReference<ReferenceType: ComponentDictionaryLocatable>: Equatabl
return "#/components/\(ReferenceType.openAPIComponentsKey)/\(name)"
case .path(let path):
return "#\(path.rawValue)"
case .anchor(let name):
return "#\(name)"
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
indirect case one(of: [DereferencedJSONSchema], core: CoreContext<JSONTypeFormat.AnyFormat>)
indirect case any(of: [DereferencedJSONSchema], core: CoreContext<JSONTypeFormat.AnyFormat>)
indirect case not(DereferencedJSONSchema, core: CoreContext<JSONTypeFormat.AnyFormat>)
/// A `$dynamicRef` that survived local dereferencing, either because its
/// dynamic scope could not be determined from the static document or
/// because resolving it would reintroduce a cycle. Dynamic references are
/// only resolved by `locallyDereferenced()` where the dynamic scope is
/// known; a `DereferencedJSONSchema` retains one when it cannot be
/// meaningfully inlined.
case dynamicReference(JSONDynamicReference, CoreContext<JSONTypeFormat.AnyFormat>)
/// Schemas without a `type`.
case fragment(CoreContext<JSONTypeFormat.AnyFormat>) // This is the "{}" case where not even a type constraint is given.

Expand Down Expand Up @@ -65,6 +72,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
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)
}
Expand Down Expand Up @@ -96,6 +105,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
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())
}
}

Expand Down Expand Up @@ -182,6 +193,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
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
}
Expand Down Expand Up @@ -212,6 +225,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
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))
}
Expand Down Expand Up @@ -242,6 +257,8 @@ public enum DereferencedJSONSchema: Equatable, JSONSchemaContext, Sendable {
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))
}
Expand Down Expand Up @@ -508,6 +525,10 @@ extension JSONSchema: LocallyDereferenceable {
dereferenced = dereferenced.with(vendorExtensions: extensions)

return dereferenced
case .dynamicReference(let reference, let context):
// Parse/preserve only: a `$dynamicRef` survives local dereferencing
// unchanged. Dynamic-scope resolution is tracked as a follow-up in #359.
return .dynamicReference(reference, addComponentNameExtension(to: context))
case .boolean(let context):
return .boolean(addComponentNameExtension(to: context))
case .object(let coreContext, let objectContext):
Expand Down Expand Up @@ -665,6 +686,10 @@ extension JSONSchema: ExternallyDereferenceable {
newSchema = .init(
schema: .reference(newReference, core)
)
case .dynamicReference(let reference, let core):
newComponents = .noComponents
newSchema = self
newMessages = []
case .fragment(_):
newComponents = .noComponents
newSchema = self
Expand Down
6 changes: 5 additions & 1 deletion Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -202,6 +204,8 @@ internal struct FragmentCombiner {

case (_, .any), (.any, _), (_, .not), (.not, _), (_, .one), (.one, _):
throw JSONSchemaResolutionError(.unsupported(because: "not, any(of:), and one(of:) are not yet supported for schema resolution"))
case (_, .dynamicReference), (.dynamicReference, _):
throw JSONSchemaResolutionError(.unsupported(because: "$dynamicRef is not supported for schema simplification"))
case (.boolean, _),
(.integer, _),
(.number, _),
Expand Down Expand Up @@ -238,7 +242,7 @@ internal struct FragmentCombiner {

let jsonSchema: JSONSchema
switch combinedFragment.value {
case .fragment, .reference, .null:
case .fragment, .reference, .dynamicReference, .null:
jsonSchema = combinedFragment
case .boolean(let coreContext):
jsonSchema = .boolean(try coreContext.validatedContext())
Expand Down
Loading