diff --git a/README.md b/README.md
index e8eb42a..eacbecf 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,15 @@
A Swift library for working with JSON Schema definitions —
especially for declaring schemas for AI tool use.
-This library implements core features of the
-[JSON Schema](https://json-schema.org/) standard,
+This library implements core features of the
+[JSON Schema](https://json-schema.org/) standard,
targeting the **draft-2020-12** version.
🙅♀️ This library specifically **does not** support the following features:
- Document validation
- Reference resolution
-- Conditional validation keywords, like
+- Conditional validation keywords, like
`dependentRequired`, `dependentSchemas`, and `if`/`then`/`else`
- Custom vocabularies and meta-schemas
@@ -195,17 +195,93 @@ let decoder = JSONDecoder()
let decodedSchema = try decoder.decode(JSONSchema.self, from: jsonData)
```
+### Preserving Property Order
+
+According to [the JSON spec](https://www.ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf) (emphasis added):
+
+> ## 6. Objects
+> [...] The JSON syntax does not impose any restrictions on the strings used as names,
+> does not require that name strings be unique,
+> and **does not assign any significance to the ordering of name/value pairs**. [...]
+
+And yet,
+JSON Schema documents often _do_ assign significance to the order of properties.
+In such cases, it may be desireable to preserve this ordering.
+For example,
+ensuring that an auto-generated form for a `createEvent` tool
+lists `start` before `end`.
+For this reason,
+the associated value for `JSONSchema.object` properties use
+[the `OrderedDictionary` type from `apple/swift-collections`](https://github.com/apple/swift-collections)
+
+By default,
+`JSONDecoder` doesn't guarantee stable ordering of keys.
+However, this package provides the following affordances
+to decode `JSONSchema` objects with properties
+in order they appear in the JSON string:
+
+- A static `extractSchemaPropertyOrder` method
+ that extracts property order from the top-level `"properties"` field
+ of a JSON Schema object.
+- A static `extractPropertyOrder` method
+ that extracts property order from any JSON object at a specified keypath.
+- A static `propertyOrderUserInfoKey` constant
+ that you can pass to `JSONDecoder`
+ (determined with either extraction method or some other means)
+ to guide the ordering of JSON Schema object properties.
+
+```swift
+let json = """
+{
+ "type": "object",
+ "properties": {
+ "firstName": {"type": "string"},
+ "lastName": {"type": "string"},
+ "age": {"type": "integer"},
+ "email": {"type": "string", "format": "email"}
+ }
+}
+""".data(using: .utf8)!
+
+// Extract property order from a JSON Schema object's "properties" field
+if let propertyOrder = JSONSchema.extractSchemaPropertyOrder(from: jsonData) {
+ // Configure decoder to preserve order
+ let decoder = JSONDecoder()
+ decoder.userInfo[JSONSchema.propertyOrderUserInfoKey] = propertyOrder
+
+ // Decode with preserved property order
+ let schema = try decoder.decode(JSONSchema.self, from: data)
+
+ // Properties will maintain their original order: `firstName`, `lastName`, `age`, `email`
+}
+
+// Or extract from a nested object using a keypath
+let nestedJSONData = """
+{
+ "definitions": {
+ "person": {
+ "firstName": "John",
+ "lastName": "Doe"
+ }
+ }
+}
+""".data(using: .utf8)!
+
+let keyOrder = JSONSchema.extractPropertyOrder(from: nestedJSONData,
+ at: ["definitions", "person"])
+// keyOrder will be ["firstName", "lastName"]
+```
+
## Motivation
-There are a few other packages out there for working with JSON Schema,
+There are a few other packages out there for working with JSON Schema,
but they did more than I needed.
-This library focuses solely on defining and serializing JSON Schema values
+This library focuses solely on defining and serializing JSON Schema values
with a clean, ergonomic API.
_That's it_.
-The [implementation](/Sources/JSONSchema/) is deliberately minimal:
-two files, ~1,000 lines of code total.
+The [implementation](/Sources/JSONSchema/) is deliberately minimal.
At its core is one big `JSONSchema` enumeration
with associated values for most of the JSON Schema keywords you might want.
No result builders, property wrappers, macros, or dynamic member lookup —
diff --git a/Sources/JSONSchema/JSONSchema.swift b/Sources/JSONSchema/JSONSchema.swift
index 52cf9e7..da18af5 100644
--- a/Sources/JSONSchema/JSONSchema.swift
+++ b/Sources/JSONSchema/JSONSchema.swift
@@ -1,6 +1,10 @@
import Foundation
import OrderedCollections
+#if canImport(RegexBuilder)
+ import RegexBuilder
+#endif
+
/// A type that represents a JSON Schema definition.
///
/// Use JSONSchema to create, manipulate, and encode/decode JSON Schema documents.
@@ -254,6 +258,111 @@ import OrderedCollections
}
extension JSONSchema {
+ /// The user info key for specifying property order during JSON decoding.
+ ///
+ /// When decoding JSON Schema objects, the standard `JSONDecoder` does not preserve
+ /// the order of properties from the original JSON. To maintain property order, you
+ /// can provide an array of property names in the decoder's `userInfo` dictionary
+ /// using this key.
+ ///
+ /// ## Example
+ /// ```swift
+ /// let decoder = JSONDecoder()
+ /// decoder.userInfo[JSONSchema.propertyOrderUserInfoKey] = ["name", "age", "email"]
+ /// let schema = try decoder.decode(JSONSchema.self, from: jsonData)
+ /// ```
+ public static let propertyOrderUserInfoKey = CodingUserInfoKey(
+ rawValue: "JSONSchemaPropertyOrder")!
+
+ /// Extracts the order of property keys from a JSON Schema object's "properties" field.
+ ///
+ /// This method is specifically designed for JSON Schema objects and automatically
+ /// extracts property keys from the "properties" field in the order they appear.
+ ///
+ /// - Parameter jsonData: The JSON Schema data to extract property order from.
+ /// - Returns: An array of property keys in the order they appear in the JSON Schema's properties field, or nil if extraction fails.
+ ///
+ /// ## Example
+ /// ```swift
+ /// let jsonString = """
+ /// {
+ /// "type": "object",
+ /// "properties": {
+ /// "name": {"type": "string"},
+ /// "age": {"type": "integer"},
+ /// "email": {"type": "string"}
+ /// }
+ /// }
+ /// """
+ ///
+ /// if let data = jsonString.data(using: .utf8),
+ /// let keyOrder = JSONSchema.extractSchemaPropertyOrder(from: data) {
+ /// let decoder = JSONDecoder()
+ /// decoder.userInfo[JSONSchema.propertyOrderUserInfoKey] = keyOrder
+ /// let schema = try decoder.decode(JSONSchema.self, from: data)
+ /// }
+ /// ```
+ @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *)
+ public static func extractSchemaPropertyOrder(from jsonData: Data) -> [String]? {
+ return extractPropertyOrder(from: jsonData, at: ["properties"])
+ }
+
+ /// Extracts the order of property keys from a JSON object at a specified keypath.
+ ///
+ /// This general-purpose method can extract property key order from any JSON object
+ /// by navigating through the provided keypath.
+ ///
+ /// - Parameters:
+ /// - jsonData: The JSON data to extract keys from.
+ /// - keypath: An array of string keys representing the path to the target object.
+ /// An empty array extracts keys from the root object.
+ /// - Returns: An array of property keys in the order they appear in the JSON, or nil if extraction fails.
+ ///
+ /// ## Example
+ /// ```swift
+ /// let jsonString = """
+ /// {
+ /// "definitions": {
+ /// "person": {
+ /// "name": "John",
+ /// "age": 30,
+ /// "email": "john@example.com"
+ /// }
+ /// }
+ /// }
+ /// """
+ ///
+ /// if let data = jsonString.data(using: .utf8),
+ /// let keyOrder = JSONSchema.extractPropertyOrder(from: data, at: ["definitions", "person"]) {
+ /// // keyOrder will be ["name", "age", "email"]
+ /// }
+ /// ```
+ @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *)
+ public static func extractPropertyOrder(from jsonData: Data, at keypath: [String] = [])
+ -> [String]?
+ {
+ guard let jsonString = String(data: jsonData, encoding: .utf8) else { return nil }
+
+ // First validate it's valid JSON by trying to decode
+ do {
+ let decoded = try JSONDecoder().decode(JSONValue.self, from: jsonData)
+
+ // Verify root is an object
+ guard case .object = decoded else { return nil }
+
+ // Use the parser to extract keys
+ let parser = JSONKeyOrderParser(json: jsonString)
+
+ if keypath.isEmpty {
+ return parser.extractRootKeys()
+ } else {
+ return parser.extractKeys(at: keypath)
+ }
+ } catch {
+ return nil
+ }
+ }
+
/// The title of the schema, if present.
public var title: String? {
switch self {
@@ -461,7 +570,10 @@ extension JSONSchema: Codable {
try encodeIfPresent(`enum`, forKey: .enum, into: &container)
try encodeIfPresent(const, forKey: .const, into: &container)
- try encodeIfNotEmpty(properties, forKey: .properties, into: &container)
+ // Custom encoding for properties to preserve order but encode as object
+ if !properties.isEmpty {
+ try container.encode(PropertyDictionary(properties), forKey: .properties)
+ }
try encodeIfNotEmpty(required, forKey: .required, into: &container)
if let additionalProperties = additionalProperties {
@@ -679,9 +791,9 @@ extension JSONSchema: Codable {
switch type {
case "object":
- let properties =
- try container.decodeIfPresent(
- OrderedDictionary.self, forKey: .properties) ?? [:]
+ let propertiesWrapper = try container.decodeIfPresent(
+ PropertyDictionary.self, forKey: .properties)
+ let properties = propertiesWrapper?.orderedDictionary ?? [:]
let required = try container.decodeIfPresent([String].self, forKey: .required) ?? []
let additionalProperties = try container.decodeIfPresent(
AdditionalProperties.self, forKey: .additionalProperties)
@@ -861,6 +973,68 @@ extension JSONSchema: ExpressibleByNilLiteral {
}
}
+// MARK: - Property Dictionary Wrapper
+
+/// A wrapper around OrderedDictionary that encodes as a JSON object while preserving key order.
+private struct PropertyDictionary: Codable {
+ let orderedDictionary: OrderedDictionary
+
+ init(_ orderedDictionary: OrderedDictionary) {
+ self.orderedDictionary = orderedDictionary
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: DynamicKey.self)
+
+ for (key, value) in orderedDictionary {
+ try container.encode(value, forKey: DynamicKey(stringValue: key)!)
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: DynamicKey.self)
+ var result = OrderedDictionary()
+
+ // Check if property order was provided in userInfo
+ if let keyOrder = decoder.userInfo[JSONSchema.propertyOrderUserInfoKey] as? [String] {
+ // Use the provided order
+ for key in keyOrder {
+ guard let codingKey = DynamicKey(stringValue: key) else { continue }
+ if container.contains(codingKey) {
+ let value = try container.decode(JSONSchema.self, forKey: codingKey)
+ result[key] = value
+ }
+ }
+ // Add any keys that weren't in the provided order
+ for key in container.allKeys where !keyOrder.contains(key.stringValue) {
+ let value = try container.decode(JSONSchema.self, forKey: key)
+ result[key.stringValue] = value
+ }
+ } else {
+ // Fallback to default behavior - order not preserved
+ for key in container.allKeys {
+ let value = try container.decode(JSONSchema.self, forKey: key)
+ result[key.stringValue] = value
+ }
+ }
+
+ self.orderedDictionary = result
+ }
+
+ private struct DynamicKey: CodingKey {
+ let stringValue: String
+ let intValue: Int? = nil
+
+ init?(stringValue: String) {
+ self.stringValue = stringValue
+ }
+
+ init?(intValue: Int) {
+ return nil
+ }
+ }
+}
+
// MARK: -
/// Standard format options for string values in JSON Schema.
@@ -1082,3 +1256,278 @@ extension AdditionalProperties: ExpressibleByDictionaryLiteral {
self = .schema(.object(properties: .init(uniqueKeysWithValues: elements)))
}
}
+
+// MARK: -
+
+/// A simple JSON parser that extracts property keys in order.
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, visionOS 1.0, *)
+private struct JSONKeyOrderParser {
+ private let json: String
+
+ init(json: String) {
+ self.json = json
+ }
+
+ /// Extract keys from the root object
+ func extractRootKeys() -> [String]? {
+ return extractKeysFromObject(json)
+ }
+
+ /// Extract keys from an object at a specific keypath
+ func extractKeys(at keypath: [String]) -> [String]? {
+ guard !keypath.isEmpty else {
+ return extractRootKeys()
+ }
+
+ // Navigate through the keypath to find the target object
+ var currentObject = json
+
+ for key in keypath {
+ guard let nextObject = findObjectAtPath(key, in: currentObject) else {
+ return nil
+ }
+ currentObject = nextObject
+ }
+
+ return extractKeysFromObject(currentObject)
+ }
+
+ /// Find an object value for a given key within a JSON string
+ private func findObjectAtPath(_ targetKey: String, in jsonString: String) -> String? {
+ var index = jsonString.startIndex
+ var inString = false
+ var escapeNext = false
+ var braceDepth = 0
+
+ // Skip to opening brace
+ while index < jsonString.endIndex {
+ if jsonString[index] == "{" {
+ braceDepth = 1
+ index = jsonString.index(after: index)
+ break
+ }
+ index = jsonString.index(after: index)
+ }
+
+ guard braceDepth == 1 else { return nil }
+
+ // Look for the key at root level
+ while index < jsonString.endIndex && braceDepth > 0 {
+ let char = jsonString[index]
+
+ if escapeNext {
+ escapeNext = false
+ } else if inString {
+ if char == "\\" {
+ escapeNext = true
+ } else if char == "\"" {
+ inString = false
+ }
+ } else {
+ switch char {
+ case "\"":
+ if braceDepth == 1 {
+ // Potential key at root level
+ if let (key, keyEnd) = extractString(startingAt: index, in: jsonString) {
+ if key == targetKey {
+ // Found the key, now extract its value
+ if let objectStart = skipToValue(from: keyEnd, in: jsonString) {
+ return extractObject(startingAt: objectStart, in: jsonString)
+ }
+ }
+ index = keyEnd
+ continue
+ }
+ }
+ inString = true
+
+ case "{":
+ braceDepth += 1
+
+ case "}":
+ braceDepth -= 1
+
+ default:
+ break
+ }
+ }
+
+ index = jsonString.index(after: index)
+ }
+
+ return nil
+ }
+
+ /// Extract all top-level keys from a JSON object string
+ private func extractKeysFromObject(_ objectJson: String) -> [String]? {
+ var keys: [String] = []
+ var index = objectJson.startIndex
+ var inString = false
+ var escapeNext = false
+ var braceDepth = 0
+
+ // Skip to opening brace
+ while index < objectJson.endIndex {
+ if objectJson[index] == "{" {
+ braceDepth = 1
+ index = objectJson.index(after: index)
+ break
+ }
+ index = objectJson.index(after: index)
+ }
+
+ guard braceDepth == 1 else { return nil }
+
+ while index < objectJson.endIndex && braceDepth > 0 {
+ let char = objectJson[index]
+
+ if escapeNext {
+ escapeNext = false
+ } else if inString {
+ if char == "\\" {
+ escapeNext = true
+ } else if char == "\"" {
+ inString = false
+ }
+ } else {
+ switch char {
+ case "\"":
+ if braceDepth == 1 {
+ // Extract key at top level
+ if let (key, keyEnd) = extractString(startingAt: index, in: objectJson) {
+ // Verify it's followed by a colon
+ var colonIndex = keyEnd
+ while colonIndex < objectJson.endIndex
+ && objectJson[colonIndex].isWhitespace
+ {
+ colonIndex = objectJson.index(after: colonIndex)
+ }
+
+ if colonIndex < objectJson.endIndex && objectJson[colonIndex] == ":" {
+ keys.append(key)
+ }
+
+ index = keyEnd
+ continue
+ }
+ }
+ inString = true
+
+ case "{":
+ braceDepth += 1
+
+ case "}":
+ braceDepth -= 1
+
+ default:
+ break
+ }
+ }
+
+ index = objectJson.index(after: index)
+ }
+
+ return braceDepth == 0 ? keys : nil
+ }
+
+ /// Extract a quoted string starting at the given index
+ private func extractString(startingAt startIndex: String.Index, in str: String? = nil) -> (
+ String, String.Index
+ )? {
+ let targetString = str ?? json
+ guard startIndex < targetString.endIndex && targetString[startIndex] == "\"" else {
+ return nil
+ }
+
+ var index = targetString.index(after: startIndex)
+ var result = ""
+ var escapeNext = false
+
+ while index < targetString.endIndex {
+ let char = targetString[index]
+
+ if escapeNext {
+ result.append("\\")
+ result.append(char)
+ escapeNext = false
+ } else if char == "\\" {
+ escapeNext = true
+ } else if char == "\"" {
+ return (result, targetString.index(after: index))
+ } else {
+ result.append(char)
+ }
+
+ index = targetString.index(after: index)
+ }
+
+ return nil
+ }
+
+ /// Skip whitespace and colon to get to the value
+ private func skipToValue(from index: String.Index, in jsonString: String) -> String.Index? {
+ var current = index
+
+ // Skip whitespace
+ while current < jsonString.endIndex && jsonString[current].isWhitespace {
+ current = jsonString.index(after: current)
+ }
+
+ // Expect colon
+ guard current < jsonString.endIndex && jsonString[current] == ":" else {
+ return nil
+ }
+
+ current = jsonString.index(after: current)
+
+ // Skip whitespace after colon
+ while current < jsonString.endIndex && jsonString[current].isWhitespace {
+ current = jsonString.index(after: current)
+ }
+
+ return current < jsonString.endIndex ? current : nil
+ }
+
+ /// Extract a complete object starting at the given index
+ private func extractObject(startingAt startIndex: String.Index, in jsonString: String)
+ -> String?
+ {
+ guard startIndex < jsonString.endIndex && jsonString[startIndex] == "{" else {
+ return nil
+ }
+
+ var index = jsonString.index(after: startIndex)
+ var braceDepth = 1
+ var inString = false
+ var escapeNext = false
+
+ while index < jsonString.endIndex && braceDepth > 0 {
+ let char = jsonString[index]
+
+ if escapeNext {
+ escapeNext = false
+ } else if inString {
+ if char == "\\" {
+ escapeNext = true
+ } else if char == "\"" {
+ inString = false
+ }
+ } else {
+ switch char {
+ case "\"":
+ inString = true
+ case "{":
+ braceDepth += 1
+ case "}":
+ braceDepth -= 1
+ default:
+ break
+ }
+ }
+
+ index = jsonString.index(after: index)
+ }
+
+ return braceDepth == 0 ? String(jsonString[startIndex..