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..