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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions Sources/OpenAPIKit/JSONDynamicReference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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`. Dynamic-scope *evaluation*
/// is a runtime concern belonging to JSON Schema validators; local
/// dereferencing (`locallyDereferenced()`) does not resolve `$dynamicRef` and
/// fails if it encounters one that cannot be inlined.
@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 `$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)))
}
}

// 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.isEmpty 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))
}
}
}

// Conforms for parity with JSONReference; lets downstream code key
// Validations on dynamic references (a `Validation`'s `Subject` must be `Validatable`).
extension JSONDynamicReference: Validatable {}
12 changes: 11 additions & 1 deletion 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,7 +171,10 @@ public enum JSONReference<ReferenceType: ComponentDictionaryLocatable>: Equatabl
}
let fragment = rawValue.dropFirst()
guard fragment.starts(with: "/components") else {
self = .path(Path(rawValue: String(fragment)))
// A fragment without a leading '/' is a plain anchor (#category).
self = fragment.first == "/"
? .path(Path(rawValue: String(fragment)))
: .anchor(String(fragment))
return
}
guard fragment.starts(with: "/components/\(ReferenceType.openAPIComponentsKey)") else {
Expand All @@ -192,6 +200,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
16 changes: 16 additions & 0 deletions Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,16 @@ extension JSONSchema: LocallyDereferenceable {
dereferenced = dereferenced.with(vendorExtensions: extensions)

return dereferenced
case .dynamicReference(let reference, _):
// A `DereferencedJSONSchema` must not contain references. Dynamic-scope
// resolution is not yet implemented (see #359), so a `$dynamicRef`
// cannot be inlined; dereferencing fails rather than retaining the
// reference and breaking the `Dereferenced...` invariant.
throw GenericError(
subjectName: "JSONSchema",
details: "Cannot dereference `$dynamicRef` ('\(reference.absoluteString)'): dynamic references are not resolved by local dereferencing.",
codingPath: []
)
case .boolean(let context):
return .boolean(addComponentNameExtension(to: context))
case .object(let coreContext, let objectContext):
Expand Down Expand Up @@ -665,6 +675,12 @@ extension JSONSchema: ExternallyDereferenceable {
newSchema = .init(
schema: .reference(newReference, core)
)
case .dynamicReference:
// TODO: external dereferencing of `$dynamicRef` is not implemented;
// deferred alongside local dynamic-scope resolution (see #359).
newComponents = .noComponents
Comment thread
mattpolzin marked this conversation as resolved.
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