Swift DynamicJSON

repository·main·Indexed 41 days ago

https://github.com/objecthub/swift-dynamicjson

A comprehensive framework for representing, querying, and manipulating generic JSON values in Swift. It provides robust support for JSON Path (RFC 9535), JSON Pointer (RFC 6901), JSON Patch (RFC 6902), JSON Merge Patch (RFC 7396), and JSON Schema (2020-12 Internet Draft). The library features a core JSON enumeration and a Mutation API for efficient in-place updates.

Tokens
7.6K
Snippets
21
Records
27
Agent score
52%

What's inside Swift DynamicJSON

  1. Overview of Swift DynamicJSON

    main

    Swift DynamicJSON is a framework for representing, querying, and manipulating generic JSON values in Swift. It provides a generic representation of JSON values as defined by RFC 8259 and includes implementations for several key JSON standards:

    • JSON Pointer (RFC 6901): For locating values within a JSON document.
    • JSON Path (RFC 9535): For querying JSON data.
    • JSON Patch (RFC 6902): For mutating JSON data.
    • JSON Merge Patch (RFC 7396): For merging JSON data with patches.
    • JSON Schema (2020-12 Internet Draft): For validating JSON data.

    The framework supports reading/writing JSON data and converting between typed Swift models and untyped JSON representations.

  2. Retrieve and use JSON schema default values

    main

    When validating JSON, JSONSchemaValidationResult collects default values defined by the default keyword in the schema. These are accessible via the defaults property.

    defaults is a map where the key is a JSONLocation and the value is a tuple (exists: Bool, values: Set<JSON>):

    • exists: true if a value already exists at that location (meaning the default should not be injected); false if the location is missing and the default should be applied.
    • values: A set of all valid default values defined by the schema for that location (useful for oneOf or anyOf scenarios).
    let res2 = try instance2.validate(with: schema)
    
    for (location, (exists, values)) in res2.defaults {
      if exists {
        print("\(location) exists; defaults: \(values)")
      } else {
        print("\(location) does not exist; defaults: \(values)")
      }
    }
  3. Representing JSON Data with the JSON enum

    main

    All JSON values in the framework are represented using the JSON enumeration. This enum covers all standard JSON types including null, boolean, integer, float, string, array, and object.

    You can construct JSON values in several ways:

    1. Swift Literal Syntax: Use Swift's native dictionary and array literals to create JSON objects directly.
    2. String/Data Initializers: Initialize JSON from a JSON-encoded String or Data object.
    3. Encodable Initializer: Convert any Codable Swift type into a JSON value using init(encodable:) or the generic init() which coerces basic types.
    // 1. Using Swift literal syntax
    let json0: JSON = [
      "foo": true,
      "str": "one two",
      "object": [
        "value": nil,
        "arr": [1, 2, 3],
        "obj": [ "x" : 17.6 ]
      ]
    ]
    
    // 2. Initializing from a JSON String
    let json1 = try JSON(string: """
      {
        "foo": true,
        "str": "one two",
        "object": {
          "value": null,
          "arr": [1, 2, 3],
          "obj": { "x" : 17.6 }
        }
      }
    """)
    
    // 3. Converting a Codable type to JSON
    struct Person: Codable {
      let name: String
      let age: Int
      let children: [Person]
    }
    let person = Person(name: "John", age: 34, children: [])
    let json2 = try JSON(encodable: person)
  4. How the `mutate` method enables efficient in-place updates

    main

    To avoid the performance overhead of copying large JSON structures during mutation, DynamicJSON provides two primary mutate patterns. These methods use closures that operate on inout references.

    1. Generic Mutation

    Use this when you want to perform a custom set of operations on a specific location.

    mutating func mutate(_ ref: JSONReference, with proc: (inout JSON) throws -> Void) throws

    2. Type-Specific Mutation

    Use this when you know the target type (Array or Object) and want to use specialized closures for those types. If the target is neither an array nor an object, the other closure is called.

    mutating func mutate(_ ref: JSONReference, 
                         array arrProc: ((inout [JSON]) throws -> Void)? = nil, 
                         object objProc: ((inout [String : JSON]) throws -> Void)? = nil, 
                         other proc: ((inout JSON) throws -> Void)? = nil) throws
    // Using the specialized mutate method to update an object
    try json.mutate("/user/profile" as JSONReference, object: { obj in
        obj["last_login"] = .string("2025-01-01")
    })
  5. Understand the `isRefinement(of:)` relationship

    main

    The isRefinement(of:) method on the JSON enum defines a relationship where one JSON value (a) is a more specific version of another (b).

    a.isRefinement(of: b) is true if:

    1. Both are the same type.
    2. For Arrays: They have the same length n, and every element a[i] is a refinement of b[i].
    3. For Objects: For every member m in b, a must also contain member m such that a[m].isRefinement(of: b[m]).
    4. For Other types: They are equal (a == b).

    This is useful for verifying if a JSON structure satisfies the requirements of a more general schema or template.

    let a = try JSON(string: #"""
      { "a": [1, { "b": 2 }], "c": { "d": [{}] } }
    """#)
    let b = try JSON(string: #"""
      { "a": [1, { "b": 2, "e": 4 }], "c": { "d": [{"f": 5}] } }
    """#)
    
    b.isRefinement(of: a) // true
  6. How JSON references work in DynamicJSON

    main

    A JSONReference is an abstraction used to identify, retrieve, set, or mutate a specific value within a larger JSON document. The library provides two primary protocols for these references:

    1. JSONReference: The base protocol for navigating and modifying values. It defines methods for selecting members/indices, retrieving values (get), replacing values (set), and performing in-place mutations (mutate).
    2. SegmentableJSONReference: An extension of JSONReference that represents a reference as a sequence of segments. This allows for decomposing a path into its constituent parts (e.g., via deselect).

    Common implementations include JSONPointer and JSONLocation.

    protocol JSONReference: CustomStringConvertible {
      func select(member: String) -> Self
      func select(index: Int) -> Self
      func get(from value: JSON) -> JSON?
      func set(to json: JSON, in value: JSON) throws -> JSON
      func mutate(_ json: inout JSON, with proc: (inout JSON) throws -> Void) throws
    }
    
    protocol SegmentableJSONReference: JSONReference {
      associatedtype Segment: JSONReferenceSegment
      var segments: [Segment] { get }
      func select(segment: Segment) -> Self
      var deselect: (Self, Segment)? { get }
    }
  7. Extend JSON Path with custom functions

    main

    DynamicJSON allows you to extend the JSON Path language with custom functions that can be used within query filters. To implement custom functions:

    1. Create a subclass of JSONPathEnvironment.
    2. Override the initialize() method to register your custom functions.
    3. Use a JSONPathEvaluator initialized with your custom environment to execute the queries.
  8. Enable format validation in JSON Schema

    main

    By default, format constraints (like email or date) are collected as annotations but do not trigger a validation failure.

    To make invalid formats cause a validation error, you must use a dialect that enables the https://json-schema.org/draft/2020-12/meta/format-annotation vocabulary. A preconfigured dialect for this is available via JSONSchemaDialect.draft2020Format. Setting this as your registry's default dialect will ensure format annotations are validated.

  9. Apply JSON Patch operations to JSON data

    main

    JSONPatch implements RFC 6902 to express a sequence of operations that transform one JSON document into another.

    Supported Operations (JSONPatchOperation)

    • add(path, value): Adds value at path.
    • remove(path): Removes the value at path.
    • replace(path, value): Replaces the value at path with value.
    • move(path, from): Moves the value from from to path.
    • copy(path, from): Copies the value from from to path.
    • test(path, value): Fails if the value at path does not match value.

    Using JSONPatch

    You can initialize a JSONPatch from a JSON string, data, or a URL, and then apply it to an existing JSON instance using apply(to:).

    To generate a patch between two existing JSON values, use source.patch(to: target).

    let jsonstr = """
      [
        { "op": "test", "path": "/a/b/c", "value": "foo" },
        { "op": "remove", "path": "/a/b/c" },
        { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] },
        { "op": "replace", "path": "/a/b/c", "value": 42 },
        { "op": "move", "from": "/a/b/c", "path": "/a/b/d" },
        { "op": "copy", "from": "/a/b/d", "path": "/a/b/e" }
      ]
    """
    let patch = try JSONPatch(string: jsonstr)
    var json: JSON = ... // your existing JSON
    try json.apply(patch: patch)
  10. Access JSON values using multiple lookup methods

    main

    DynamicJSON allows you to access specific values within a JSON document using several different syntaxes, ranging from native Swift-like member lookup to standardized reference strings.

    To access the first element of an array arr inside an object object within a JSON document json1:

    • Dynamic member lookup: Use dot notation and optional chaining.
    • Keypath lookup: Use Swift KeyPath syntax.
    • Subscript lookup: Use standard string-based subscripts.
    • Reference lookup: Use [ref:] with a JSONPointer, JSONPath, or JSONLocation string/object.
    // Assuming json1 is a JSON document containing { "object": { "arr": [1, 2, 3] } }
    
    // 1. Dynamic member lookup
    json1.object?.arr?[0]
    
    // 2. Keypath lookup
    json1[keyPath: \.object?.arr?[0]]
    
    // 3. Subscript lookup
    json1["object"]?["arr"]?[0]
    
    // 4. Reference lookup (using strings)
    try json1[ref: "/object/arr/0"] // JSON Pointer
    try json1[ref: "$.object.arr[0]"] // JSON Path
    
    // 5. Reference lookup (using JSONReference objects)
    let p: JSONReference = ... 
    json1[ref: p]
  11. Writing JSON Patch Compliance Tests

    main

    When creating new tests for JSON Patch compliance:

    1. Use descriptive comments: Every test should include a comment field.
    2. Keep tests simple: Test specific behaviors individually. To test interacting behaviors, create separate tests for the individual behaviors and one for the interaction.
    3. Define errors carefully: If using the error member, the string should describe the error the implementation is expected to raise. Ensure the string is specific enough to avoid false positives (e.g., an incorrect error being reported that happens to match your string).
  12. Validate JSON data using JSONSchemaRegistry

    main

    For complex validation scenarios involving multiple schemas, external references, or custom dialects, use the JSONSchemaRegistry API.

    1. Initialize a Registry: Create a JSONSchemaRegistry instance.
    2. Register Schemas: Load schemas from strings, files, or via a JSONSchemaProvider (like StaticJSONSchemaFileProvider) to make them discoverable by URI.
    3. Obtain a Validator: Use registry.validator(for: dialect:) to get a JSONSchemaValidator for a specific schema resource.
    4. Perform Validation: Call validator.validate(json) on your JSON instance to receive a JSONSchemaValidationResult.
    // Create a new schema registry
    let registry = JSONSchemaRegistry()
    
    // Register a schema resource from a string literal
    try registry.register(resource: JSONSchemaResource(string: #"""
      {
        "$id": "https://example.com/schema/test",
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
          "prop1": { ... }
        }
      }
    """"))
    
    // Load a schema resource from a file
    try registry.loadSchema(from: URL(filePath: "/Users/objecthub/foo.json"))
    
    // Make JSON schema stored in json files under the given directory discoverable
    registry.register(provider: 
      StaticJSONSchemaFileProvider(
        directory: URL(filePath: "/Users/objecthub/myschema"),
        base: JSONSchemaIdentifier(string: "http://example.com/schemas")!)
    )
    
    // Obtain a validator for a schema
    guard let validator = try? registry.validator(for: "https://example.com/schema/test") else {
      // Handle error
    }
    
    // Validate a JSON instance `json`
    let result = validator.validate(json)
    print("valid = \(result.isValid)")