Skip to content

Commit cd6b1b6

Browse files
Add RenderNodeVariantOverridesApplier API (#16)
Adds the `RenderNodeVariantOverridesApplier` API, which applies a variant overrides patch onto an encoded render node. rdar://83564599 Co-authored-by: Ethan Kusters <[email protected]>
1 parent 6c14a74 commit cd6b1b6

22 files changed

+919
-88
lines changed

Sources/SwiftDocC/Model/Rendering/RenderNode.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import Foundation
4040
///
4141
/// The overrides are emitted in the [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) format.
4242
///
43+
/// To apply variants onto a render node using `SwiftDocC`, use the ``RenderNodeVariantOverridesApplier`` API.
44+
///
4345
/// ## Topics
4446
///
4547
/// ### General
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
/// A utility type for applying JSON patches.
14+
///
15+
/// Use this type to apply ``JSONPatchOperation`` values onto JSON.
16+
public struct JSONPatchApplier {
17+
/// Creates a new JSON patch applier.
18+
public init() {}
19+
20+
/// Applies the given patch onto the given JSON data.
21+
///
22+
/// - Parameters:
23+
/// - patch: The patch to apply.
24+
/// - jsonData: The data on which to apply the patch.
25+
/// - Returns: The JSON data with the patch applied.
26+
/// - Throws: This function throws an ``Error`` if the application was not successful.
27+
public func apply(_ patch: JSONPatch, to jsonData: Data) throws -> Data {
28+
let json = try JSONDecoder().decode(JSON.self, from: jsonData)
29+
30+
// Apply each patch operation one-by-one to the JSON, and throw an error if one of the patches could not
31+
// be applied.
32+
let appliedJSON = try patch.reduce(json) { json, operation in
33+
guard let newValue = try apply(operation, to: json, originalPointer: operation.pointer) else {
34+
// If the application of the operation onto the top-level JSON element results in a `nil` value (i.e.,
35+
// the entire value was removed), throw an error since this is not supported.
36+
throw Error.invalidPatch
37+
}
38+
return newValue
39+
}
40+
41+
return try JSONEncoder().encode(appliedJSON)
42+
}
43+
44+
private func apply(_ operation: JSONPatchOperation, to json: JSON, originalPointer: JSONPointer) throws -> JSON? {
45+
// If the pointer has no path components left, this is the value we need to update.
46+
guard let component = operation.pointer.pathComponents.first else {
47+
switch operation {
48+
case .replace(_, let value):
49+
if let json = value.value as? JSON {
50+
return json
51+
} else {
52+
// If the value is not encoded as a `JSON` value already, convert it.
53+
let data = try JSONEncoder().encode(value)
54+
return try JSONDecoder().decode(JSON.self, from: data)
55+
}
56+
case .remove(_):
57+
return nil
58+
}
59+
}
60+
61+
let nextOperation = operation.removingPointerFirstPathComponent()
62+
63+
// Traverse the JSON element and apply the operation recursively.
64+
switch json {
65+
case .dictionary(var dictionary):
66+
// If the element is a dictionary, modify the value at the key indicated by the current path component
67+
// of the pointer.
68+
guard let value = dictionary[component] else {
69+
throw Error.invalidObjectPointer(
70+
originalPointer,
71+
component: component,
72+
availableObjectKeys: dictionary.keys
73+
)
74+
}
75+
76+
dictionary[component] = try apply(nextOperation, to: value, originalPointer: originalPointer)
77+
78+
return .dictionary(dictionary)
79+
case .array(var array):
80+
// If the element is an array, modify the value at the index indicated by the current integer path
81+
// component of the pointer.
82+
guard let index = Int(component), array.indices.contains(index) else {
83+
throw Error.invalidArrayPointer(
84+
originalPointer,
85+
index: component,
86+
arrayCount: array.count
87+
)
88+
}
89+
90+
if let newValue = try apply(nextOperation, to: array[index], originalPointer: originalPointer) {
91+
array[index] = newValue
92+
} else {
93+
array.remove(at: index)
94+
}
95+
96+
return .array(array)
97+
default:
98+
// The pointer is invalid because it has a non-empty path component, but the JSON element is not
99+
// traversable, i.e., it's a number, string, boolean, or null value.
100+
throw Error.invalidValuePointer(
101+
originalPointer,
102+
component: component,
103+
jsonValue: String(describing: json)
104+
)
105+
}
106+
}
107+
108+
/// An error that occured during the application of a JSON patch.
109+
public enum Error: DescribedError {
110+
/// An error indicating that the pointer of a patch operation is invalid for a JSON object.
111+
///
112+
/// - Parameters:
113+
/// - component: The component that's causing the pointer to be invalid in the JSON object.
114+
/// - availableKeys: The keys available in the JSON object.
115+
case invalidObjectPointer(JSONPointer, component: String, availableKeys: [String])
116+
117+
118+
/// An error indicating that the pointer of a patch operation is invalid for a JSON object.
119+
///
120+
/// - Parameters:
121+
/// - component: The component that's causing the pointer to be invalid in the JSON object.
122+
/// - availableObjectKeys: The keys available in the JSON object.
123+
public static func invalidObjectPointer<Keys: Collection>(
124+
_ pointer: JSONPointer,
125+
component: String,
126+
availableObjectKeys: Keys
127+
) -> Self where Keys.Element == String {
128+
return .invalidObjectPointer(pointer, component: component, availableKeys: Array(availableObjectKeys))
129+
}
130+
131+
/// An error indicating that the pointer of a patch operation is invalid for a JSON array.
132+
///
133+
/// - Parameters:
134+
/// - index: The index component that's causing the pointer to be invalid in the JSON array.
135+
/// - arrayCount: The size of the JSON array.
136+
case invalidArrayPointer(JSONPointer, index: String, arrayCount: Int)
137+
138+
/// An error indicating that the pointer of a patch operation is invalid for a JSON value.
139+
///
140+
/// - Parameters:
141+
/// - component: The component that's causing the pointer to be invalid, since the JSON element is a non-traversable value.
142+
/// - jsonValue: The string-encoded description of the JSON value.
143+
case invalidValuePointer(JSONPointer, component: String, jsonValue: String)
144+
145+
/// An error indicating that a patch operation is invalid.
146+
case invalidPatch
147+
148+
public var errorDescription: String {
149+
switch self {
150+
case .invalidObjectPointer(let pointer, let component, let availableKeys):
151+
return """
152+
Invalid dictionary pointer '\(pointer)'. The component '\(component)' is not valid for the object with \
153+
keys \(availableKeys.sorted().map(\.singleQuoted).list(finalConjunction: .and)).
154+
"""
155+
case .invalidArrayPointer(let pointer, let index, let arrayCount):
156+
return """
157+
Invalid array pointer '\(pointer)'. The index '\(index)' is not valid for array of \(arrayCount) \
158+
elements.
159+
"""
160+
case .invalidValuePointer(let pointer, let component, let jsonValue):
161+
return """
162+
Invalid value pointer '\(pointer)'. The component '\(component)' is not valid for the non-traversable \
163+
value '\(jsonValue)'.
164+
"""
165+
case .invalidPatch:
166+
return "Invalid patch"
167+
}
168+
}
169+
}
170+
}

Sources/SwiftDocC/Model/Rendering/Variants/JSONPatchOperation.swift

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,53 @@
1111
import Foundation
1212

1313
/// A patch to update a JSON value.
14+
public typealias JSONPatch = [JSONPatchOperation]
15+
16+
/// A patch operation to update a JSON value.
1417
///
1518
/// Values of this type follow the [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) format.
16-
public struct JSONPatchOperation: Codable {
17-
/// The operation to apply.
18-
public var operation: PatchOperation
19-
20-
/// The pointer to the value to update.
21-
public var pointer: JSONPointer
22-
23-
/// The new value to use when performing the update.
24-
public var value: AnyCodable
19+
///
20+
/// ## Topics
21+
///
22+
/// ### Applying Patches
23+
///
24+
/// - ``JSONPatchApplier``
25+
///
26+
/// ### Operations
27+
///
28+
/// - ``PatchOperation``
29+
public enum JSONPatchOperation: Codable {
2530

26-
/// Creates a patch to update a JSON value.
27-
///
28-
/// Values of this type follow the [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) format.
31+
/// A replacement operation.
2932
///
3033
/// - Parameters:
31-
/// - operation: The operation to apply.
32-
/// - pointer: The pointer to the value to update.
33-
/// - value: The value to use when performing the update.
34-
public init(operation: PatchOperation, pointer: JSONPointer, value: AnyCodable) {
35-
self.operation = operation
36-
self.pointer = pointer
37-
self.value = value
34+
/// - pointer: The pointer to the value to replace.
35+
/// - value: The value to use in the replacement.
36+
case replace(pointer: JSONPointer, value: AnyCodable)
37+
38+
/// A remove operation.
39+
///
40+
/// - Parameter pointer: The pointer to the value to remove.
41+
case remove(pointer: JSONPointer)
42+
43+
/// The pointer to the value to modify.
44+
public var pointer: JSONPointer {
45+
switch self {
46+
case .replace(let pointer, _):
47+
return pointer
48+
case .remove(let pointer):
49+
return pointer
50+
}
51+
}
52+
53+
/// The operation to apply.
54+
public var operation: PatchOperation {
55+
switch self {
56+
case .replace(_, _):
57+
return .replace
58+
case .remove(_):
59+
return .remove
60+
}
3861
}
3962

4063
/// Creates a patch to update a JSON value.
@@ -45,9 +68,60 @@ public struct JSONPatchOperation: Codable {
4568
/// - variantPatch: The patch to apply.
4669
/// - pointer: The pointer to the value to update.
4770
public init<Value>(variantPatch: VariantPatchOperation<Value>, pointer: JSONPointer) {
48-
self.operation = variantPatch.operation
49-
self.value = AnyCodable(variantPatch.value)
50-
self.pointer = pointer
71+
switch variantPatch {
72+
case .replace(let value):
73+
self = .replace(pointer: pointer, encodableValue: value)
74+
case .remove:
75+
self = .remove(pointer: pointer)
76+
}
77+
}
78+
79+
public init(from decoder: Decoder) throws {
80+
let container = try decoder.container(keyedBy: CodingKeys.self)
81+
let operation = try container.decode(PatchOperation.self, forKey: .operation)
82+
83+
let pointer = try container.decode(JSONPointer.self, forKey: .pointer)
84+
85+
switch operation {
86+
case .replace:
87+
let value = try container.decode(AnyCodable.self, forKey: .value)
88+
self = .replace(pointer: pointer, value: value)
89+
case .remove:
90+
self = .remove(pointer: pointer)
91+
}
92+
}
93+
94+
/// A replacement operation.
95+
///
96+
/// - Parameters:
97+
/// - pointer: The pointer to the value to replace.
98+
/// - encodedValue: The value to use in the replacement.
99+
public static func replace(pointer: JSONPointer, encodableValue: Encodable) -> JSONPatchOperation {
100+
.replace(pointer: pointer, value: AnyCodable(encodableValue))
101+
}
102+
103+
/// Returns the patch operation with the first path component of the pointer removed.
104+
public func removingPointerFirstPathComponent() -> Self {
105+
let newPointer = pointer.removingFirstPathComponent()
106+
switch self {
107+
case .replace(_, let value):
108+
return .replace(pointer: newPointer, value: value)
109+
case .remove(_):
110+
return .remove(pointer: newPointer)
111+
}
112+
}
113+
114+
public func encode(to encoder: Encoder) throws {
115+
var container = encoder.container(keyedBy: CodingKeys.self)
116+
switch self {
117+
case .replace(let pointer, let value):
118+
try container.encode(PatchOperation.replace, forKey: .operation)
119+
try container.encode(pointer, forKey: .pointer)
120+
try container.encode(value, forKey: .value)
121+
case .remove(let pointer):
122+
try container.encode(PatchOperation.remove, forKey: .operation)
123+
try container.encode(pointer, forKey: .pointer)
124+
}
51125
}
52126

53127
public enum CodingKeys: String, CodingKey {

Sources/SwiftDocC/Model/Rendering/Variants/JSONPointer.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,24 @@ import Foundation
1313
/// A pointer to a specific value in a JSON document.
1414
///
1515
/// For more information, see [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
16-
public struct JSONPointer: Codable {
17-
/// The components of the pointer.
18-
public var components: [String]
16+
public struct JSONPointer: Codable, CustomStringConvertible {
17+
/// The path components of the pointer.
18+
public var pathComponents: [String]
19+
20+
public var description: String {
21+
"/\(pathComponents.joined(separator: "/"))"
22+
}
1923

2024
/// Creates a JSON Pointer given its path components.
2125
///
2226
/// The components are assumed to be properly escaped per [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
23-
public init(components: [String]) {
24-
self.components = components
27+
public init<Components: Sequence>(pathComponents: Components) where Components.Element == String {
28+
self.pathComponents = Array(pathComponents)
29+
}
30+
31+
/// Returns the pointer with the first path component removed.
32+
public func removingFirstPathComponent() -> JSONPointer {
33+
JSONPointer(pathComponents: pathComponents.dropFirst())
2534
}
2635

2736
/// An enum representing characters that need escaping in JSON Pointer values.
@@ -53,7 +62,7 @@ public struct JSONPointer: Codable {
5362
/// Use this initializer when creating JSON pointers during encoding. This initializer escapes components as defined by
5463
/// [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
5564
public init(from codingPath: [CodingKey]) {
56-
self.components = codingPath.map { component in
65+
self.pathComponents = codingPath.map { component in
5766
if let intValue = component.intValue {
5867
// If the coding key is an index into an array, emit the index as a string.
5968
return "\(intValue)"
@@ -73,12 +82,12 @@ public struct JSONPointer: Codable {
7382

7483
public func encode(to encoder: Encoder) throws {
7584
var container = encoder.singleValueContainer()
76-
try container.encode("/\(components.joined(separator: "/"))")
85+
try container.encode(description)
7786
}
7887

7988
public init(from decoder: Decoder) throws {
8089
let container = try decoder.singleValueContainer()
8190
let stringValue = try container.decode(String.self)
82-
self.components = stringValue.removingLeadingSlash.components(separatedBy: "/")
91+
self.pathComponents = stringValue.removingLeadingSlash.components(separatedBy: "/")
8392
}
8493
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
/// The patch operation to apply.
14+
///
15+
/// Values of this type follow the [JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) format.
16+
///
17+
/// > Warning: The cases of this enumeration are non-exhaustive for the supported operations of JSON Patch schema. Further JSON Patch operations may
18+
/// be added in the future.
19+
public enum PatchOperation: String, Codable {
20+
/// A replacement operation.
21+
case replace
22+
23+
/// A removal operation.
24+
case remove
25+
}

0 commit comments

Comments
 (0)