├── .gitignore ├── .spi.yml ├── Sources ├── JSONSchemaBuilder │ ├── JSONComponent │ │ ├── Modifier │ │ │ ├── JSONComponents.swift │ │ │ ├── Constant.swift │ │ │ ├── AnySchemaComponent.swift │ │ │ ├── OptionalComponent.swift │ │ │ ├── PassthroughComponent.swift │ │ │ ├── Conditional.swift │ │ │ ├── Map.swift │ │ │ ├── Enum.swift │ │ │ ├── CompactMap.swift │ │ │ ├── FlatMap.swift │ │ │ ├── MergedComponent.swift │ │ │ ├── AdditionalProperties.swift │ │ │ ├── PatternProperties.swift │ │ │ └── PropertyNames.swift │ │ ├── TypeSpecific │ │ │ ├── JSONNull.swift │ │ │ ├── JSONBoolean.swift │ │ │ ├── JSONString.swift │ │ │ └── JSONNumber.swift │ │ ├── JSONAnyValue.swift │ │ ├── JSONBooleanSchema.swift │ │ ├── JSONSchemaComponent+Content.swift │ │ ├── RuntimeComponent.swift │ │ ├── JSONSchema.swift │ │ ├── JSONSchemaComponent+Conditionals.swift │ │ ├── JSONSchemaComponent+Identifiers.swift │ │ ├── ConditionalSchema.swift │ │ ├── JSONDynamicReference.swift │ │ ├── SchemaValue.swift │ │ ├── JSONReference.swift │ │ ├── JSONSchemaComponent.swift │ │ └── JSONSchemaComponent+Annotations.swift │ ├── JSONPropertyComponent │ │ ├── Modifier │ │ │ ├── JSONPropertyComponents.swift │ │ │ ├── PropertyOptionalComponent.swift │ │ │ ├── PropertyConditional.swift │ │ │ ├── PropertyArray.swift │ │ │ ├── PropertyFlatMap.swift │ │ │ └── PropertyCompactMap.swift │ │ ├── JSONPropertyComponent.swift │ │ └── JSONProperty.swift │ ├── Macros │ │ ├── SchemaOptions │ │ │ ├── ExcludeFromSchema.swift │ │ │ ├── TypeSpecific │ │ │ │ ├── StringOptions.swift │ │ │ │ ├── NumberOptions.swift │ │ │ │ ├── ObjectOptions.swift │ │ │ │ └── ArrayOptions.swift │ │ │ └── SchemaOptions.swift │ │ └── Schemable.swift │ ├── Values │ │ ├── JSONValueRepresentable.swift │ │ ├── TypeSpecific │ │ │ ├── JSONNullValue.swift │ │ │ ├── JSONIntegerValue.swift │ │ │ ├── JSONNumberValue.swift │ │ │ ├── JSONStringValue.swift │ │ │ ├── JSONBooleanValue.swift │ │ │ ├── JSONArrayValue.swift │ │ │ └── JSONObjectValue.swift │ │ └── JSONPropertyValue.swift │ ├── JSONValue+Schema.swift │ ├── Documentation.docc │ │ └── Articles │ │ │ ├── ValueBuilder.md │ │ │ ├── ConditionalValidation.md │ │ │ └── WrapperTypes.md │ ├── Builders │ │ └── JSONPropertyBuilder.swift │ ├── Utils │ │ ├── SchemaAnchorName.swift │ │ └── SchemaReferenceURI.swift │ └── Parsing │ │ ├── ParseIssue.swift │ │ └── Parsed.swift ├── JSONSchema │ ├── Validation │ │ ├── Errors │ │ │ └── SchemaIssue.swift │ │ ├── SchemaDocument.swift │ │ ├── ValidationLocation.swift │ │ ├── ValidatableSchema.swift │ │ └── ValidationResult.swift │ ├── Utilities │ │ ├── Bundle+JSONSchemaResources.swift │ │ └── LockIsolated.swift │ ├── Documentation.docc │ │ └── Documentation.md │ ├── Resources │ │ └── draft2020-12 │ │ │ ├── meta │ │ │ ├── format-annotation.json │ │ │ ├── unevaluated.json │ │ │ ├── content.json │ │ │ ├── meta-data.json │ │ │ ├── core.json │ │ │ ├── applicator.json │ │ │ └── validation.json │ │ │ └── schema.json │ ├── JSONValue │ │ ├── Array+JSONValue.swift │ │ ├── JSONValue+merge.swift │ │ ├── JSONValue+ExpressibleByLiteral.swift │ │ └── JSONValue+Codable.swift │ ├── Schema+Equatable.swift │ ├── FormatValidators │ │ ├── FormatValidator.swift │ │ └── BuiltinValidators.swift │ ├── Keywords │ │ ├── Keyword.swift │ │ ├── Keywords+Annotation.swift │ │ ├── Keywords+Reserved.swift │ │ └── Keywords.swift │ ├── Annotations │ │ ├── JSONType.swift │ │ ├── AnnotationContainer.swift │ │ └── Annotation.swift │ └── Schema+Codable.swift ├── JSONSchemaConversion │ ├── UUIDConversion.swift │ ├── Conversions.swift │ ├── URLConversion.swift │ ├── Documentation.docc │ │ └── JSONSchemaConversionOverview.md │ └── DateConversion.swift └── JSONSchemaMacro │ ├── SchemaOptions.swift │ ├── ExcludeFromSchemaMacro.swift │ ├── JSONSchemaMacroPlugin.swift │ ├── Schemable │ ├── SupportedPrimitive.swift │ ├── CompositionKeyword.swift │ └── SchemaOptionsGenerator.swift │ └── TypeSpecificOptionMacros.swift ├── .gitmodules ├── Tests ├── JSONSchemaIntegrationTests │ ├── __Snapshots__ │ │ └── PollExampleTests │ │ │ ├── parse-instance.4.txt │ │ │ ├── parse-instance.6.txt │ │ │ ├── parse-instance.7.txt │ │ │ ├── parse-instance.3.txt │ │ │ ├── parse-instance.2.txt │ │ │ ├── parse-instance.1.txt │ │ │ └── parse-instance.5.txt │ ├── NestedTypeIntegrationTests.swift │ ├── OptionalNullsIntegrationTests.swift │ ├── RecursiveTreeIntegrationTests.swift │ ├── KeyEncodingTests.swift │ ├── ConditionalTests.swift │ ├── SchemaCodableIntegrationTests.swift │ ├── BacktickEnumIntegrationTests.swift │ └── CodingKeysIntegrationTests.swift ├── JSONSchemaMacroTests │ ├── SimpleDiagnosticsTests.swift │ ├── Helpers │ │ └── MacroExpansion+SwiftTesting.swift │ └── BacktickEnumTests.swift ├── JSONSchemaBuilderTests │ ├── ContentMediaTypeTests.swift │ ├── SchemaAnchorNameTests.swift │ ├── JSONIdentifierTests.swift │ ├── SchemaReferenceURITests.swift │ ├── CompileTimeMacroTests.swift │ ├── KeyEncodingStrategyTests.swift │ ├── JSONConditionalTests.swift │ ├── JSONReferenceComponentTests.swift │ └── WrapperTests.swift ├── JSONSchemaConversionTests │ └── ConversionTests.swift └── JSONSchemaTests │ ├── JSONValueTests.swift │ └── FormatValidatorTests.swift ├── .github ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── format.yml ├── Makefile ├── .vscode └── launch.json ├── Contributing.md ├── LICENSE ├── Package.resolved ├── .swift-format └── ReferenceResolver.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/ 7 | .netrc 8 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: 5 | - JSONSchema 6 | - JSONSchemaBuilder 7 | - JSONSchemaConversion 8 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/JSONComponents.swift: -------------------------------------------------------------------------------- 1 | /// A namespace for components that modify the behavior of other components. 2 | public enum JSONComponents {} 3 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/JSONPropertyComponents.swift: -------------------------------------------------------------------------------- 1 | /// A namespace for all the JSON property components. 2 | public enum JSONPropertyComponents {} 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Tests/JSONValidationTests/JSON-Schema-Test-Suite"] 2 | path = Tests/JSONSchemaTests/JSON-Schema-Test-Suite 3 | url = https://github.com/json-schema-org/JSON-Schema-Test-Suite.git 4 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/ExcludeFromSchema.swift: -------------------------------------------------------------------------------- 1 | @attached(peer) 2 | public macro ExcludeFromSchema() = 3 | #externalMacro(module: "JSONSchemaMacro", type: "ExcludeFromSchemaMacro") 4 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Validation/Errors/SchemaIssue.swift: -------------------------------------------------------------------------------- 1 | public enum SchemaIssue: Error, Equatable { 2 | case schemaShouldBeBooleanOrObject 3 | case unsupportedRequiredVocabulary(String) 4 | case invalidVocabularyFormat 5 | case missingRootRawSchema 6 | } 7 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.4.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ invalid: 1 element 3 | ▿ Missing required property `title`. 4 | ▿ missingRequiredProperty: (1 element) 5 | - property: "title" 6 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/JSONValueRepresentable.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A component for use in ``JSONValueBuilder```. 4 | public protocol JSONValueRepresentable { 5 | /// The value that this component represents. 6 | var value: JSONValue { get } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Validation/SchemaDocument.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SchemaDocument: Sendable { 4 | let url: URL 5 | let rawSchema: JSONValue 6 | 7 | func value(at pointer: JSONPointer) -> JSONValue? { 8 | rawSchema.value(at: pointer) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONNullValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON null value component for use in ``JSONValueBuilder``. 4 | public struct JSONNullValue: JSONValueRepresentable { 5 | public var value: JSONValue { .null } 6 | 7 | public init() {} 8 | } 9 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/Constant.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | public func constant(_ value: JSONValue) -> Self { 5 | var copy = self 6 | copy.schemaValue[Keywords.Constant.name] = value 7 | return copy 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/JSONSchemaConversion/UUIDConversion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchemaBuilder 3 | 4 | public struct UUIDConversion: Schemable { 5 | public static var schema: some JSONSchemaComponent { 6 | JSONString() 7 | .format("uuid") 8 | .compactMap { UUID(uuidString: $0) } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONValue+Schema.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 4 | extension JSONValue { 5 | /// A schema component that accepts any JSON value and returns it unchanged. 6 | public static var schema: some JSONSchemaComponent { JSONAnyValue() } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Utilities/Bundle+JSONSchemaResources.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private final class JSONSchemaBundleFinder {} 4 | 5 | extension Bundle { 6 | public static var jsonSchemaResources: Bundle { 7 | #if SWIFT_PACKAGE 8 | return .module 9 | #else 10 | return .init(for: JSONSchemaBundleFinder.self) 11 | #endif 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/SchemaOptions.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacros 3 | 4 | public struct SchemaOptionsMacro: PeerMacro { 5 | public static func expansion( 6 | of node: AttributeSyntax, 7 | providingPeersOf declaration: some DeclSyntaxProtocol, 8 | in context: some MacroExpansionContext 9 | ) throws -> [DeclSyntax] { [] } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``JSONSchema`` 2 | 3 | Summary 4 | 5 | ## Overview 6 | 7 | Text 8 | 9 | ## Topics 10 | 11 | ### Group 12 | 13 | - ``Symbol`` 14 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONIntegerValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON integer value component for use in ``JSONValueBuilder``. 4 | public struct JSONIntegerValue: JSONValueRepresentable { 5 | public var value: JSONValue { .integer(integer) } 6 | 7 | let integer: Int 8 | 9 | public init(integer: Int) { self.integer = integer } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONNumberValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON number value component for use in ``JSONValueBuilder``. 4 | public struct JSONNumberValue: JSONValueRepresentable { 5 | public var value: JSONValue { .number(number) } 6 | 7 | let number: Double 8 | 9 | public init(number: Double) { self.number = number } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONStringValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON string value component for use in ``JSONValueBuilder``. 4 | public struct JSONStringValue: JSONValueRepresentable { 5 | public var value: JSONValue { .string(string) } 6 | 7 | let string: String 8 | 9 | public init(string: String) { self.string = string } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONBooleanValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON boolean value component for use in ``JSONValueBuilder``. 4 | public struct JSONBooleanValue: JSONValueRepresentable { 5 | public var value: JSONValue { .boolean(boolean) } 6 | 7 | let boolean: Bool 8 | 9 | public init(boolean: Bool) { self.boolean = boolean } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/ExcludeFromSchemaMacro.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacros 3 | 4 | public struct ExcludeFromSchemaMacro: PeerMacro { 5 | public static func expansion( 6 | of node: AttributeSyntax, 7 | providingPeersOf declaration: some DeclSyntaxProtocol, 8 | in context: some MacroExpansionContext 9 | ) throws -> [DeclSyntax] { [] } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchemaConversion/Conversions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchemaBuilder 3 | 4 | public enum Conversions { 5 | public static let uuid = UUIDConversion.self 6 | 7 | public static let dateTime = DateTimeConversion.self 8 | public static let date = DateConversion.self 9 | public static let time = TimeConversion.self 10 | 11 | public static let url = URLConversion.self 12 | } 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. 4 | 5 | ## Type of Change 6 | 7 | - [ ] Bug fix 8 | - [ ] New feature 9 | - [ ] Breaking change 10 | - [ ] Documentation update 11 | 12 | ## Additional Notes 13 | 14 | Add any other context or screenshots about the pull request here. 15 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/JSONSchemaMacroPlugin.swift: -------------------------------------------------------------------------------- 1 | import SwiftCompilerPlugin 2 | import SwiftSyntaxMacros 3 | 4 | @main struct JSONSchemaMacroPlugin: CompilerPlugin { 5 | let providingMacros: [Macro.Type] = [ 6 | SchemableMacro.self, SchemaOptionsMacro.self, NumberOptionsMacro.self, ArrayOptionsMacro.self, 7 | ObjectOptionsMacro.self, StringOptionsMacro.self, ExcludeFromSchemaMacro.self, 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /Tests/JSONSchemaMacroTests/SimpleDiagnosticsTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchemaMacro 2 | import SwiftSyntaxMacros 3 | import Testing 4 | 5 | struct SimpleDiagnosticsTests { 6 | let testMacros: [String: Macro.Type] = [ 7 | "Schemable": SchemableMacro.self 8 | ] 9 | 10 | @Test func simpleTest() { 11 | // Just ensure the macro can be created and runs 12 | #expect(testMacros["Schemable"] != nil) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONArrayValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON array value component for use in ``JSONValueBuilder``. 4 | public struct JSONArrayValue: JSONValueRepresentable { 5 | public var value: JSONValue { .array(elements.map(\.value)) } 6 | 7 | let elements: [JSONValueRepresentable] 8 | 9 | public init(elements: [JSONValueRepresentable] = []) { self.elements = elements } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/format-annotation.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Format vocabulary meta-schema for annotation results", 7 | "type": ["object", "boolean"], 8 | "properties": { 9 | "format": { "type": "string" } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/unevaluated.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Unevaluated applicator vocabulary meta-schema", 7 | "type": ["object", "boolean"], 8 | "properties": { 9 | "unevaluatedItems": { "$dynamicRef": "#meta" }, 10 | "unevaluatedProperties": { "$dynamicRef": "#meta" } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/content.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/content", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Content vocabulary meta-schema", 7 | 8 | "type": ["object", "boolean"], 9 | "properties": { 10 | "contentEncoding": { "type": "string" }, 11 | "contentMediaType": { "type": "string" }, 12 | "contentSchema": { "$dynamicRef": "#meta" } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/JSONSchemaConversion/URLConversion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchemaBuilder 3 | 4 | /// A conversion for JSON Schema `uri` format to `Foundation.URL`. 5 | /// 6 | /// Accepts strings that are valid URIs (e.g. "https://example.com"). 7 | /// Returns a `URL` if parsing succeeds, otherwise fails validation. 8 | public struct URLConversion: Schemable { 9 | public static var schema: some JSONSchemaComponent { 10 | JSONString() 11 | .format("uri") 12 | .compactMap { URL(string: $0) } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.6.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ valid: Poll 3 | - category: Category.other 4 | - createdAt: "2024-10-25T12:00:00Z" 5 | - description: Optional.none 6 | - expiresAt: Optional.none 7 | - id: 6 8 | - isActive: true 9 | ▿ options: 1 element 10 | ▿ Option 11 | - id: -1 12 | - text: "Option A" 13 | - voteCount: 5 14 | - settings: Optional.none 15 | - title: "Invalid Poll" 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | SUBMODULE_DIR = Tests/JSONSchemaTests/JSON-Schema-Test-Suite 3 | 4 | # Target to initialize and update the submodule 5 | update-submodule: 6 | @git submodule init 7 | @git submodule update --remote $(SUBMODULE_DIR) 8 | @echo "Submodule $(SUBMODULE_DIR) updated successfully." 9 | 10 | format: 11 | @swift-format format --in-place --parallel --recursive Sources/ Tests/ 12 | @swift-format lint --strict --parallel --recursive Sources/ Tests/ 13 | @echo "Swift code formatted successfully." 14 | 15 | .PHONY: clean-submodule format 16 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONPropertyComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A component that represents a JSON property. 4 | /// Used in the ``JSONObject/init(with:)`` initializer to define the properties of an object schema. 5 | public protocol JSONPropertyComponent { 6 | associatedtype Value: JSONSchemaComponent 7 | associatedtype Output 8 | 9 | var key: String { get } 10 | var isRequired: Bool { get } 11 | var value: Value { get } 12 | 13 | func parse(_ input: [String: JSONValue]) -> Parsed 14 | } 15 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Validation/ValidationLocation.swift: -------------------------------------------------------------------------------- 1 | public struct ValidationLocation: Sendable, Codable, Equatable { 2 | var keywordLocation: JSONPointer 3 | var instanceLocation: JSONPointer 4 | /// Required if keyworkLocation is a ref or dynamic ref 5 | var absoluteKeywordLocation: JSONPointer? 6 | 7 | var isRoot: Bool { keywordLocation.isRoot } 8 | 9 | public init(keywordLocation: JSONPointer = .init(), instanceLocation: JSONPointer = .init()) { 10 | self.keywordLocation = keywordLocation 11 | self.instanceLocation = instanceLocation 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/TypeSpecific/JSONNull.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON null schema component for use in ``JSONSchemaBuilder``. 4 | public struct JSONNull: JSONSchemaComponent { 5 | public var schemaValue: SchemaValue = .object([ 6 | Keywords.TypeKeyword.name: .string(JSONType.null.rawValue) 7 | ]) 8 | 9 | public init() {} 10 | 11 | public func parse(_ value: JSONValue) -> Parsed { 12 | if case .null = value { return .valid(()) } 13 | return .error(.typeMismatch(expected: .null, actual: value)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/TypeSpecific/JSONBoolean.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON boolean schema component for use in ``JSONSchemaBuilder``. 4 | public struct JSONBoolean: JSONSchemaComponent { 5 | public var schemaValue: SchemaValue = .object([ 6 | Keywords.TypeKeyword.name: .string(JSONType.boolean.rawValue) 7 | ]) 8 | 9 | public init() {} 10 | 11 | public func parse(_ value: JSONValue) -> Parsed { 12 | if case .boolean(let bool) = value { return .valid(bool) } 13 | return .error(.typeMismatch(expected: .boolean, actual: value)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/JSONSchema/JSONValue/Array+JSONValue.swift: -------------------------------------------------------------------------------- 1 | extension Array where Element == JSONValue { 2 | /// Creates an array from a JSON value. 3 | /// If the given value is JSON array type, the elements become the elements of the array. 4 | /// If there anything other JSON value is given, it becomes the one and only element of the array. 5 | /// - Parameter value: The JSON value to convert to an array. 6 | public init(_ value: JSONValue) { 7 | switch value { 8 | case .string, .number, .integer, .object, .boolean, .null: self = [value] 9 | case .array(let array): self = array 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.7.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ valid: Poll 3 | - category: Category.other 4 | - createdAt: "invalid-date" 5 | - description: Optional.none 6 | - expiresAt: Optional.none 7 | - id: 7 8 | - isActive: true 9 | ▿ options: 2 elements 10 | ▿ Option 11 | - id: 1 12 | - text: "" 13 | - voteCount: -5 14 | ▿ Option 15 | - id: 2 16 | - text: "Valid Option" 17 | - voteCount: 0 18 | - settings: Optional.none 19 | - title: "" 20 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Documentation.docc/Articles/ValueBuilder.md: -------------------------------------------------------------------------------- 1 | # Value Builder 2 | 3 | ## Overview 4 | 5 | You can also use the ``JSONValueBuilder`` result builder to create JSON values (a.k.a. instances or documents). 6 | 7 | ```swift 8 | @JSONValueBuilder var jsonValue: JSONValueRepresentable { 9 | JSONArrayValue { 10 | JSONStringValue("Hello, world!") 11 | JSONNumberValue(42) 12 | } 13 | } 14 | ``` 15 | 16 | or use the literal extensions for JSON values. 17 | 18 | ```swift 19 | @JSONValueBuilder var jsonValue: JSONValueRepresentable { 20 | [ 21 | "Hello, world!", 22 | 42 23 | ] 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.3.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ valid: Poll 3 | - category: Category.sports 4 | - createdAt: "2024-10-25T15:00:00Z" 5 | - description: Optional.none 6 | - expiresAt: Optional.none 7 | - id: 3 8 | - isActive: false 9 | ▿ options: 2 elements 10 | ▿ Option 11 | - id: 1 12 | - text: "Soccer" 13 | - voteCount: 25 14 | ▿ Option 15 | - id: 2 16 | - text: "Basketball" 17 | - voteCount: 30 18 | - settings: Optional.none 19 | - title: "Favorite Sport?" 20 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/ContentMediaTypeTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct ContentMediaTypeBuilderTests { 7 | @Test func encodingAndMediaType() { 8 | @JSONSchemaBuilder var sample: some JSONSchemaComponent { 9 | JSONString() 10 | .contentEncoding("base64") 11 | .contentMediaType("image/png") 12 | } 13 | 14 | let expected: [String: JSONValue] = [ 15 | "type": "string", 16 | "contentEncoding": "base64", 17 | "contentMediaType": "image/png", 18 | ] 19 | 20 | #expect(sample.schemaValue == .object(expected)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONAnyValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A compoment that accepts any JSON value. 4 | public struct JSONAnyValue: JSONSchemaComponent { 5 | public var schemaValue: SchemaValue = .boolean(true) 6 | 7 | public init() {} 8 | 9 | /// Creates a `JSONAnyValue` from any other component. Validation is skipped 10 | /// but the wrapped component's schema metadata is preserved. 11 | public init(_ component: Component) { 12 | self.init() 13 | self.schemaValue = component.schemaValue 14 | } 15 | 16 | public func parse(_ value: JSONValue) -> Parsed { .valid(value) } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Schema+Equatable.swift: -------------------------------------------------------------------------------- 1 | extension Schema { 2 | public static func == (lhs: Schema, rhs: Schema) -> Bool { 3 | switch (lhs.schema, rhs.schema) { 4 | case (let lhsBool as BooleanSchema, let rhsBool as BooleanSchema): return lhsBool == rhsBool 5 | case (let lhsObject as ObjectSchema, let rhsObject as ObjectSchema): 6 | return lhsObject == rhsObject 7 | default: return false 8 | } 9 | } 10 | } 11 | 12 | extension BooleanSchema { 13 | public static func == (lhs: Self, rhs: Self) -> Bool { lhs.schemaValue == rhs.schemaValue } 14 | } 15 | 16 | extension ObjectSchema { 17 | public static func == (lhs: Self, rhs: Self) -> Bool { lhs.schemaValue == rhs.schemaValue } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/JSONPropertyValue.swift: -------------------------------------------------------------------------------- 1 | /// A JSON property value component for use in ``JSONPropertyBuilder``. 2 | /// 3 | /// This component is used to represent a key-value pair in a JSON object instance. 4 | /// Not to be confused with ``JSONProperty`` which is used to represent a key-value pair in a JSON schema. 5 | public struct JSONPropertyValue { 6 | let key: String 7 | let value: JSONValueRepresentable 8 | 9 | public init(key: String, @JSONValueBuilder builder: () -> JSONValueRepresentable) { 10 | self.key = key 11 | self.value = builder() 12 | } 13 | 14 | public init(key: String, value: JSONValueRepresentable) { 15 | self.key = key 16 | self.value = value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.2.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ valid: Poll 3 | ▿ category: Category 4 | ▿ entertainment: Entertainment 5 | - ageRating: AgeRating.pg13 6 | - genre: Genre.movies 7 | - createdAt: "2024-10-26T08:30:00Z" 8 | - description: Optional.none 9 | - expiresAt: Optional.none 10 | - id: 2 11 | - isActive: false 12 | ▿ options: 2 elements 13 | ▿ Option 14 | - id: 1 15 | - text: "Action" 16 | - voteCount: 5 17 | ▿ Option 18 | - id: 2 19 | - text: "Drama" 20 | - voteCount: 8 21 | - settings: Optional.none 22 | - title: "Favorite Movie Genre?" 23 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyOptionalComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONPropertyComponents { 4 | /// A property collection that wraps another property collection and makes it's validation result optional. 5 | public struct OptionalComponent: PropertyCollection { 6 | public var schemaValue: SchemaValue { wrapped?.schemaValue ?? .object([:]) } 7 | 8 | public var requiredKeys: [String] { wrapped?.requiredKeys ?? [] } 9 | 10 | var wrapped: Wrapped? 11 | 12 | public init(wrapped: Wrapped?) { self.wrapped = wrapped } 13 | 14 | public func validate(_ input: [String: JSONValue]) -> Parsed { 15 | wrapped?.validate(input).map(Optional.some) ?? .valid(nil) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONBooleanSchema.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | public struct JSONBooleanSchema: JSONSchemaComponent { 4 | public var schemaValue: SchemaValue { 5 | get { .boolean(value) } 6 | set { fatalError("Cannot set schemaValue on JSONBooleanSchema") } 7 | } 8 | 9 | let value: Bool 10 | 11 | public func schema() -> Schema { 12 | BooleanSchema(schemaValue: value, location: .init(), context: .init(dialect: .draft2020_12)) 13 | .asSchema() 14 | } 15 | 16 | public func parse(_ value: JSONValue) -> Parsed { 17 | self.value ? .valid(true) : .error(.typeMismatch(expected: .boolean, actual: value)) 18 | } 19 | } 20 | 21 | extension JSONBooleanSchema: ExpressibleByBooleanLiteral { 22 | public init(booleanLiteral value: BooleanLiteralType) { 23 | self.init(value: value) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/AnySchemaComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | public func eraseToAnySchemaComponent() -> JSONComponents.AnySchemaComponent { 5 | .init(self) 6 | } 7 | } 8 | 9 | extension JSONComponents { 10 | /// Component for type erasure. 11 | public struct AnySchemaComponent: JSONSchemaComponent { 12 | private let validate: (JSONValue) -> Parsed 13 | public var schemaValue: SchemaValue 14 | 15 | public init(_ component: Component) 16 | where Component.Output == Output { 17 | self.schemaValue = component.schemaValue 18 | self.validate = component.parse 19 | } 20 | 21 | public func parse(_ value: JSONValue) -> Parsed { validate(value) } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "swift", 5 | "request": "launch", 6 | "args": [], 7 | "cwd": "${workspaceFolder:swift-json-schema}", 8 | "name": "Debug JSONSchemaClient", 9 | "program": "${workspaceFolder:swift-json-schema}/.build/debug/JSONSchemaClient", 10 | "preLaunchTask": "swift: Build Debug JSONSchemaClient" 11 | }, 12 | { 13 | "type": "swift", 14 | "request": "launch", 15 | "args": [], 16 | "cwd": "${workspaceFolder:swift-json-schema}", 17 | "name": "Release JSONSchemaClient", 18 | "program": "${workspaceFolder:swift-json-schema}/.build/release/JSONSchemaClient", 19 | "preLaunchTask": "swift: Build Release JSONSchemaClient" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /Sources/JSONSchema/Validation/ValidatableSchema.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ValidatableSchema: Equatable, Sendable { 4 | func validate(_ instance: JSONValue, at location: JSONPointer) -> ValidationResult 5 | } 6 | 7 | extension ValidatableSchema { 8 | public func validate(_ instance: JSONValue) -> ValidationResult { 9 | self.validate(instance, at: .init()) 10 | } 11 | 12 | /// Convenience for validating instances from `String` form. The decoder will first convert to ``JSONValue`` and then pass to standard ``validate(_:at:)``. 13 | public func validate( 14 | instance: String, 15 | using decoder: JSONDecoder = .init(), 16 | at location: JSONPointer = .init() 17 | ) throws -> ValidationResult { 18 | let data = try decoder.decode(JSONValue.self, from: Data(instance.utf8)) 19 | return validate(data, at: location) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift: -------------------------------------------------------------------------------- 1 | enum SupportedPrimitive: String, CaseIterable { 2 | case double = "Double" 3 | case float = "Float" 4 | case string = "String" 5 | case int = "Int" 6 | case bool = "Bool" 7 | case array = "Array" 8 | case dictionary = "Dictionary" 9 | 10 | var schema: String { 11 | switch self { 12 | case .double, .float: "JSONNumber" 13 | case .string: "JSONString" 14 | case .int: "JSONInteger" 15 | case .bool: "JSONBoolean" 16 | case .array: "JSONArray" 17 | case .dictionary: "JSONObject" 18 | } 19 | } 20 | 21 | /// Returns true if this is a scalar primitive (not array or dictionary) 22 | var isScalar: Bool { 23 | switch self { 24 | case .double, .float, .string, .int, .bool: 25 | return true 26 | case .array, .dictionary: 27 | return false 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Utilities/LockIsolated.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A thread-safe wrapper that uses a lock to protect access to a mutable value. 4 | /// 5 | /// This type provides a simple way to make a value safe to access from multiple threads 6 | /// by wrapping it in a lock. Access to the value is controlled through the `withLock` method. 7 | final class LockIsolated: @unchecked Sendable { 8 | private var value: Value 9 | private let lock = NSRecursiveLock() 10 | 11 | init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { 12 | self.value = try value() 13 | } 14 | 15 | func withLock( 16 | _ operation: @Sendable (inout Value) throws -> T 17 | ) rethrows -> T { 18 | lock.lock() 19 | defer { lock.unlock() } 20 | var value = self.value 21 | defer { self.value = value } 22 | return try operation(&value) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/OptionalComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONComponents { 4 | /// A component that makes the output of the upstream component optional. 5 | /// When the wrapped component is nil, the output of validation is `.valid(nil)` and the schema accepts any input. 6 | public struct OptionalComponent: JSONSchemaComponent { 7 | public var schemaValue: SchemaValue { 8 | get { wrapped?.schemaValue ?? .object([:]) } 9 | set { wrapped?.schemaValue = newValue } 10 | } 11 | 12 | var wrapped: Wrapped? 13 | 14 | public init(wrapped: Wrapped?) { self.wrapped = wrapped } 15 | 16 | public func parse(_ value: JSONValue) -> Parsed { 17 | guard let wrapped else { return .valid(nil) } 18 | return wrapped.parse(value).map(Optional.some) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/JSONSchema/FormatValidators/FormatValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol FormatValidator: Sendable { 4 | /// The name of the format that this validator is responsible for. 5 | var formatName: String { get } 6 | /// Returns `true` if the provided string is valid for this format. 7 | func validate(_ value: String) -> Bool 8 | } 9 | 10 | public enum DefaultFormatValidators { 11 | /// Collection of the default validators provided by ``JSONSchema``. 12 | public static var all: [any FormatValidator] { 13 | [ 14 | DateTimeFormatValidator(), 15 | DateFormatValidator(), 16 | TimeFormatValidator(), 17 | EmailFormatValidator(), 18 | HostnameFormatValidator(), 19 | IPv4FormatValidator(), 20 | IPv6FormatValidator(), 21 | UUIDFormatValidator(), 22 | URIFormatValidator(), 23 | URIReferenceFormatValidator(), 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent+Content.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Specifies the encoding used to represent non-JSON data. 5 | /// - Parameter value: The content encoding (e.g. "base64"). 6 | /// - Returns: A new instance with the `contentEncoding` keyword set. 7 | public func contentEncoding(_ value: String) -> Self { 8 | var copy = self 9 | copy.schemaValue[Keywords.ContentEncoding.name] = .string(value) 10 | return copy 11 | } 12 | 13 | /// Specifies the media type of the decoded content. 14 | /// - Parameter value: The media type (e.g. "image/png"). 15 | /// - Returns: A new instance with the `contentMediaType` keyword set. 16 | public func contentMediaType(_ value: String) -> Self { 17 | var copy = self 18 | copy.schemaValue[Keywords.ContentMediaType.name] = .string(value) 19 | return copy 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Keywords/Keyword.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias KeywordIdentifier = String 4 | 5 | package protocol Keyword: Sendable { 6 | /// The name of the keyword, such as `type` or `minLength`. 7 | static var name: KeywordIdentifier { get } 8 | 9 | /// The vocabulary URI that defines this keyword. 10 | static var vocabulary: String { get } 11 | 12 | var value: JSONValue { get } 13 | var context: KeywordContext { get } 14 | 15 | init(value: JSONValue, context: KeywordContext) 16 | } 17 | 18 | package struct KeywordContext { 19 | let location: JSONPointer 20 | let context: Context 21 | let uri: URL 22 | 23 | package init( 24 | location: JSONPointer = .init(), 25 | context: Context = .init(dialect: .draft2020_12), 26 | uri: URL = .init(fileURLWithPath: #file) 27 | ) { 28 | self.location = location 29 | self.context = context 30 | self.uri = uri 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Keywords/Keywords+Annotation.swift: -------------------------------------------------------------------------------- 1 | package protocol AnnotationProducingKeyword: Keyword { 2 | /// The type of the annoation value that can be produced as a result of this applying this keyword during validaiton. 3 | associatedtype AnnotationValue: AnnotationValueConvertible 4 | } 5 | 6 | package protocol AnnotationValueConvertible: Sendable, Equatable { 7 | var value: JSONValue { get } 8 | 9 | func merged(with other: Self) -> Self 10 | } 11 | 12 | extension JSONValue: AnnotationValueConvertible { 13 | package var value: JSONValue { 14 | self 15 | } 16 | 17 | package func merged(with other: JSONValue) -> JSONValue { 18 | switch (self, other) { 19 | case (.array(let lhs), .array(let rhs)): 20 | .array(lhs + rhs) 21 | case (.object(let lhs), .object(let rhs)): 22 | .object(lhs.merging(rhs, uniquingKeysWith: { $1 })) 23 | default: 24 | other 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.1.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ valid: Poll 3 | ▿ category: Category 4 | ▿ technology: Technology 5 | - hasDemo: true 6 | - subTopic: "software" 7 | - createdAt: "2024-10-25T12:00:00Z" 8 | ▿ description: Optional 9 | - some: "Vote for your favorite language." 10 | ▿ expiresAt: Optional 11 | - some: "2024-12-31T23:59:59Z" 12 | - id: 1 13 | - isActive: true 14 | ▿ options: 2 elements 15 | ▿ Option 16 | - id: 1 17 | - text: "Swift" 18 | - voteCount: 10 19 | ▿ Option 20 | - id: 2 21 | - text: "Python" 22 | - voteCount: 20 23 | ▿ settings: Optional 24 | ▿ some: Settings 25 | - allowMultipleVotes: false 26 | - requireAuthentication: true 27 | - title: "Favorite Programming Language?" 28 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/PassthroughComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONComponents { 4 | /// A component that performs validation on wrapped component but ignores wrapped `Output`and uses original input instead. 5 | /// Useful schema collections where `Output` type needs to match across schemas. 6 | public struct PassthroughComponent: JSONSchemaComponent { 7 | public var schemaValue: SchemaValue { 8 | get { wrapped.schemaValue } 9 | set { wrapped.schemaValue = newValue } 10 | } 11 | 12 | var wrapped: Component 13 | 14 | public init(wrapped: Component) { self.wrapped = wrapped } 15 | 16 | public func parse(_ value: JSONValue) -> Parsed { 17 | wrapped.parse(value) 18 | .flatMap { _ in .valid(value) // Ignore valid associated type and pass original string 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Documentation.docc/Articles/ConditionalValidation.md: -------------------------------------------------------------------------------- 1 | # Conditional Validation 2 | 3 | Learn how to use conditional keywords like ``dependentRequired`` and the ``If`` builder to model relationships between schema properties. 4 | 5 | Use ``dependentRequired`` when the presence of one property requires another: 6 | 7 | ```swift 8 | @JSONSchemaBuilder var creditInfo: some JSONSchemaComponent { 9 | JSONObject { 10 | JSONProperty(key: "credit_card") { JSONInteger() } 11 | JSONProperty(key: "billing_address") { JSONString() } 12 | } 13 | .dependentRequired(["credit_card": ["billing_address"]]) 14 | } 15 | ``` 16 | 17 | You can also build conditional schemas using the ``If`` helper: 18 | 19 | ```swift 20 | @JSONSchemaBuilder var conditional: some JSONSchemaComponent { 21 | If({ JSONString().minLength(1) }) { 22 | JSONString().pattern("^foo") 23 | } else: { 24 | JSONString().pattern("^bar") 25 | } 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/NestedTypeIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchemaBuilder 2 | import Testing 3 | 4 | // Test that @Schemable works with qualified type names (MemberType syntax) 5 | enum Weather { 6 | @Schemable 7 | enum Condition: String, Sendable { 8 | case sunny 9 | case rainy 10 | } 11 | } 12 | 13 | @Schemable 14 | struct Forecast: Sendable { 15 | let date: String 16 | let condition: Weather.Condition 17 | } 18 | 19 | struct MemberTypeTests { 20 | @Test func qualifiedTypeNameSupport() throws { 21 | // Verify that properties with qualified type names like Weather.Condition 22 | // are properly included in schema generation 23 | let json = """ 24 | {"date":"2025-10-30","condition":"rainy"} 25 | """ 26 | let result = try Forecast.schema.parse(instance: json) 27 | #expect(result.value != nil) 28 | #expect(result.value?.date == "2025-10-30") 29 | #expect(result.value?.condition == .rainy) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/meta-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Meta-data vocabulary meta-schema", 7 | 8 | "type": ["object", "boolean"], 9 | "properties": { 10 | "title": { 11 | "type": "string" 12 | }, 13 | "description": { 14 | "type": "string" 15 | }, 16 | "default": true, 17 | "deprecated": { 18 | "type": "boolean", 19 | "default": false 20 | }, 21 | "readOnly": { 22 | "type": "boolean", 23 | "default": false 24 | }, 25 | "writeOnly": { 26 | "type": "boolean", 27 | "default": false 28 | }, 29 | "examples": { 30 | "type": "array", 31 | "items": true 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/SchemaAnchorNameTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | @testable import JSONSchemaBuilder 4 | 5 | struct SchemaAnchorNameTests { 6 | @Test func preservesAllowedCharacters() { 7 | let raw = "Module.Type_Name-01.nested" 8 | #expect(SchemaAnchorName.sanitized(raw) == raw) 9 | } 10 | 11 | @Test func replacesDisallowedCharacters() { 12 | let raw = "My Type" 13 | #expect(SchemaAnchorName.sanitized(raw) == "My_Type_Name_") 14 | } 15 | 16 | @Test func replacesColonAndSlash() { 17 | let raw = "Module.Type_Name-01:/nested" 18 | #expect(SchemaAnchorName.sanitized(raw) == "Module.Type_Name-01__nested") 19 | } 20 | 21 | @Test func prefixesInvalidLeadingCharacters() { 22 | #expect(SchemaAnchorName.sanitized("9Type") == "_9Type") 23 | #expect(SchemaAnchorName.sanitized("-Type") == "_-Type") 24 | #expect(SchemaAnchorName.sanitized(".Type") == "_.Type") 25 | } 26 | 27 | @Test func fallsBackForEmptyInput() { 28 | #expect(SchemaAnchorName.sanitized("") == "_Anchor") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Builders/JSONPropertyBuilder.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A result builder type that collects multiple ``JSONPropertyValue`` instances into a single array. 4 | @resultBuilder public struct JSONPropertyBuilder { 5 | public static func buildBlock(_ components: [JSONPropertyValue]...) -> [JSONPropertyValue] { 6 | components.flatMap { $0 } 7 | } 8 | 9 | public static func buildBlock(_ components: JSONPropertyValue...) -> [JSONPropertyValue] { 10 | components 11 | } 12 | 13 | public static func buildEither(first component: [JSONPropertyValue]) -> [JSONPropertyValue] { 14 | component 15 | } 16 | 17 | public static func buildEither(second component: [JSONPropertyValue]) -> [JSONPropertyValue] { 18 | component 19 | } 20 | 21 | public static func buildOptional(_ component: [JSONPropertyValue]?) -> [JSONPropertyValue] { 22 | component ?? [] 23 | } 24 | 25 | public static func buildArray(_ components: [[JSONPropertyValue]]) -> [JSONPropertyValue] { 26 | components.flatMap { $0 } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/JSONSchema/JSONValue/JSONValue+merge.swift: -------------------------------------------------------------------------------- 1 | extension JSONValue { 2 | /// Mutates `self` by merging in values from `other`. 3 | /// - Arrays are concatenated. 4 | /// - Objects are merged recursively. 5 | /// - Scalars are preserved unless `self` is `.null`. 6 | public mutating func merge(_ other: JSONValue) { 7 | switch (self, other) { 8 | case (.object(var lhsDict), .object(let rhsDict)): 9 | for (key, rhsValue) in rhsDict { 10 | if let lhsValue = lhsDict[key] { 11 | var mergedValue = lhsValue 12 | mergedValue.merge(rhsValue) 13 | lhsDict[key] = mergedValue 14 | } else { 15 | lhsDict[key] = rhsValue 16 | } 17 | } 18 | self = .object(lhsDict) 19 | 20 | case (.array(let lhsArray), .array(let rhsArray)): 21 | self = .array(lhsArray + rhsArray) 22 | 23 | case (.null, let rhs): // If self is null, adopt other 24 | self = rhs 25 | 26 | default: 27 | // Keep existing self by default (no overwrite) 28 | break 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyConditional.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONPropertyComponents { 4 | /// A component that conditionally applies one of two property collections. 5 | public enum Conditional: PropertyCollection 6 | where First.Output == Second.Output { 7 | public var schemaValue: SchemaValue { 8 | switch self { 9 | case .first(let first): first.schemaValue 10 | case .second(let second): second.schemaValue 11 | } 12 | } 13 | 14 | public var requiredKeys: [String] { 15 | switch self { 16 | case .first(let first): first.requiredKeys 17 | case .second(let second): second.requiredKeys 18 | } 19 | } 20 | 21 | case first(First) 22 | case second(Second) 23 | 24 | public func validate(_ input: [String: JSONValue]) -> Parsed { 25 | switch self { 26 | case .first(let first): first.validate(input) 27 | case .second(let second): second.validate(input) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/TypeSpecific/StringOptions.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @attached(peer) 4 | public macro StringOptions( 5 | _ traits: StringTrait... 6 | ) = #externalMacro(module: "JSONSchemaMacro", type: "StringOptionsMacro") 7 | 8 | public protocol StringTrait {} 9 | 10 | public struct StringSchemaTrait: StringTrait { 11 | fileprivate init() {} 12 | 13 | fileprivate static let errorMessage = 14 | "This method should only be used within @StringOptions macro" 15 | } 16 | 17 | extension StringTrait where Self == StringSchemaTrait { 18 | public static func minLength(_ value: Int) -> StringSchemaTrait { 19 | fatalError(StringSchemaTrait.errorMessage) 20 | } 21 | 22 | public static func maxLength(_ value: Int) -> StringSchemaTrait { 23 | fatalError(StringSchemaTrait.errorMessage) 24 | } 25 | 26 | public static func pattern(_ value: String) -> StringSchemaTrait { 27 | fatalError(StringSchemaTrait.errorMessage) 28 | } 29 | 30 | public static func format(_ value: String) -> StringSchemaTrait { 31 | fatalError(StringSchemaTrait.errorMessage) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/OptionalNullsIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import JSONSchemaBuilder 3 | import Testing 4 | 5 | @Schemable 6 | struct TestStructWithOptionalInt: Codable { 7 | let required: String 8 | let optionalInt: Int? 9 | let optionalString: String? 10 | } 11 | 12 | struct OptionalNullsIntegrationTests { 13 | struct TestError: Error { 14 | let message: String 15 | init(_ message: String) { self.message = message } 16 | } 17 | 18 | @Test func optionalIntWithOrNull() throws { 19 | // Test with null for optional Int 20 | let jsonWithNull = """ 21 | { 22 | "required": "test", 23 | "optionalInt": null, 24 | "optionalString": null 25 | } 26 | """ 27 | 28 | let result = try TestStructWithOptionalInt.schema.parse(instance: jsonWithNull) 29 | #expect(result.value != nil, "Expected successful parsing") 30 | guard let parsed = result.value else { throw TestError("Failed to parse") } 31 | #expect(parsed.required == "test") 32 | #expect(parsed.optionalInt == nil) 33 | #expect(parsed.optionalString == nil) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | Welcome to Swift JSON Schema! Contributions are welcome and greatly appreciated. By contributing, you are helping to make this project better for everyone. 4 | 5 | ## Style Guidelines 6 | 7 | - Follow the [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/). 8 | - Write clear and concise comments where necessary. 9 | - Ensure your code is well-documented and includes meaningful test cases. 10 | 11 | ## Code Formatting 12 | 13 | All code must be formatted using Swift format to ensure consistency across the codebase. The CI pipeline will check for formatting issues automatically. You can add the `auto-format` label to your pull requests to enable automatic Swift formatting before merging. 14 | 15 | ### How to Use 16 | 17 | 1. Create a pull request as usual. 18 | 2. Add the `auto-format` label to your pull request. 19 | 20 | When the label is added, the CI pipeline will automatically format the code and commit the changes to your pull request. 21 | 22 | For more information about Swift format, visit the [Swift format repository](https://github.com/apple/swift-format). 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Austin Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/Conditional.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONComponents { 4 | /// A component that conditionally applies one of two components based on the input value. 5 | public enum Conditional: 6 | JSONSchemaComponent 7 | where First.Output == Second.Output { 8 | public var schemaValue: SchemaValue { 9 | get { 10 | switch self { 11 | case .first(let first): first.schemaValue 12 | case .second(let second): second.schemaValue 13 | } 14 | } 15 | set { 16 | switch self { 17 | case .first(var first): first.schemaValue = newValue 18 | case .second(var second): second.schemaValue = newValue 19 | } 20 | } 21 | } 22 | 23 | case first(First) 24 | case second(Second) 25 | 26 | public func parse(_ value: JSONValue) -> Parsed { 27 | switch self { 28 | case .first(let first): return first.parse(value) 29 | case .second(let second): return second.parse(value) 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/JSONSchema/JSONValue/JSONValue+ExpressibleByLiteral.swift: -------------------------------------------------------------------------------- 1 | extension JSONValue: ExpressibleByStringLiteral { 2 | public init(stringLiteral value: StringLiteralType) { self = .string(value) } 3 | } 4 | 5 | extension JSONValue: ExpressibleByIntegerLiteral { 6 | public init(integerLiteral value: IntegerLiteralType) { self = .integer(value) } 7 | } 8 | 9 | extension JSONValue: ExpressibleByBooleanLiteral { 10 | public init(booleanLiteral value: BooleanLiteralType) { self = .boolean(value) } 11 | } 12 | 13 | extension JSONValue: ExpressibleByArrayLiteral { 14 | public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } 15 | } 16 | 17 | extension JSONValue: ExpressibleByDictionaryLiteral { 18 | public init(dictionaryLiteral elements: (String, JSONValue)...) { 19 | let dictionary = Dictionary(uniqueKeysWithValues: elements) 20 | self = .object(dictionary) 21 | } 22 | } 23 | 24 | extension JSONValue: ExpressibleByFloatLiteral { 25 | public init(floatLiteral value: FloatLiteralType) { self = .number(value) } 26 | } 27 | 28 | extension JSONValue: ExpressibleByNilLiteral { public init(nilLiteral: ()) { self = .null } } 29 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Values/TypeSpecific/JSONObjectValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON object value component for use in ``JSONValueBuilder``. 4 | public struct JSONObjectValue: JSONValueRepresentable { 5 | public var value: JSONValue { .object(properties.mapValues(\.value)) } 6 | 7 | let properties: [String: JSONValueRepresentable] 8 | 9 | public init(properties: [String: JSONValueRepresentable] = [:]) { self.properties = properties } 10 | } 11 | 12 | extension JSONObjectValue { 13 | /// Constructs a new `JSONObjectValue` with the provided properties. 14 | /// 15 | /// Example: 16 | /// ```swift 17 | /// let value = JSONObjectValue { 18 | /// JSONPropertyValue(key: "name", value: "value") 19 | /// } 20 | /// ``` 21 | /// which is equivalent to: 22 | /// ```swift 23 | /// let value = JSONObjectValue(properties: [JSONPropertyValue(key: "name", value: "value")]) 24 | /// ``` 25 | public init(@JSONPropertyBuilder _ content: () -> [JSONPropertyValue]) { 26 | self.properties = content() 27 | .reduce(into: [:]) { partialResult, property in partialResult[property.key] = property.value } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/TypeSpecificOptionMacros.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | import SwiftSyntaxMacros 3 | 4 | public struct NumberOptionsMacro: PeerMacro { 5 | public static func expansion( 6 | of node: AttributeSyntax, 7 | providingPeersOf declaration: some DeclSyntaxProtocol, 8 | in context: some MacroExpansionContext 9 | ) throws -> [DeclSyntax] { [] } 10 | } 11 | 12 | public struct ArrayOptionsMacro: PeerMacro { 13 | public static func expansion( 14 | of node: AttributeSyntax, 15 | providingPeersOf declaration: some DeclSyntaxProtocol, 16 | in context: some MacroExpansionContext 17 | ) throws -> [DeclSyntax] { [] } 18 | } 19 | 20 | public struct ObjectOptionsMacro: PeerMacro { 21 | public static func expansion( 22 | of node: AttributeSyntax, 23 | providingPeersOf declaration: some DeclSyntaxProtocol, 24 | in context: some MacroExpansionContext 25 | ) throws -> [DeclSyntax] { [] } 26 | } 27 | 28 | public struct StringOptionsMacro: PeerMacro { 29 | public static func expansion( 30 | of node: AttributeSyntax, 31 | providingPeersOf declaration: some DeclSyntaxProtocol, 32 | in context: some MacroExpansionContext 33 | ) throws -> [DeclSyntax] { [] } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/JSONSchemaConversion/Documentation.docc/JSONSchemaConversionOverview.md: -------------------------------------------------------------------------------- 1 | # ``JSONSchemaConversion`` 2 | 3 | ## Overview 4 | 5 | `JSONSchemaConversion` includes some type-safe, schema-driven conversion from JSON to Foundation types like `UUID`, `Date`, and `URL`. 6 | 7 | ## Example 8 | 9 | Use a custom conversion in a macro-based schema: 10 | 11 | ```swift 12 | import JSONSchemaBuilder 13 | import JSONSchemaConversion 14 | 15 | struct Custom: Schemable { 16 | static var schema: some JSONSchemaComponent { 17 | JSONString() 18 | .format("my-custom-format") 19 | } 20 | } 21 | 22 | @Schemable 23 | struct MyModel { 24 | @SchemaOptions(.customSchema(Conversions.uuid)) 25 | let id: UUID 26 | 27 | @SchemaOptions(.customSchema(Custom.self)) 28 | let myVar: String 29 | 30 | // Auto-generated schema ↴ 31 | static var schema: some JSONSchemaComponent { 32 | JSONProperty(key: "id") { 33 | Conversions.uuid.schema 34 | } 35 | JSONProperty(key: "myVar") { 36 | Custom.self.schema 37 | } 38 | } 39 | } 40 | ``` 41 | 42 | This ensures only valid UUID strings are accepted for `id` during parsing and validation and allows schema overrides for individual properties. 43 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/Map.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Maps the validated output of the upstream component to a new output type. 5 | /// - Parameter transform: The transform to apply to the output. 6 | /// - Returns: A new component that applies the transform. 7 | public func map( 8 | _ transform: @Sendable @escaping (Output) -> NewOutput 9 | ) -> JSONComponents.Map { .init(upstream: self, transform: transform) } 10 | } 11 | 12 | extension JSONComponents { 13 | public struct Map: JSONSchemaComponent { 14 | public var schemaValue: SchemaValue { 15 | get { upstream.schemaValue } 16 | set { upstream.schemaValue = newValue } 17 | } 18 | 19 | var upstream: Upstream 20 | let transform: @Sendable (Upstream.Output) -> NewOutput 21 | 22 | public init(upstream: Upstream, transform: @Sendable @escaping (Upstream.Output) -> NewOutput) { 23 | self.upstream = upstream 24 | self.transform = transform 25 | } 26 | 27 | public func parse(_ value: JSONValue) -> Parsed { 28 | upstream.parse(value).map(transform) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/RuntimeComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A component that wraps a runtime ``Schema`` instance. 4 | /// Validation is performed using the provided schema and any valid input is 5 | /// returned unchanged. 6 | public struct RuntimeComponent: JSONSchemaComponent { 7 | public enum UnsupportedSchemaTypeError: Error { 8 | case unsupportedType(String) 9 | } 10 | public var schemaValue: SchemaValue 11 | let schema: Schema 12 | 13 | public init(rawSchema: JSONValue, dialect: Dialect = .draft2020_12) throws { 14 | switch rawSchema { 15 | case .boolean(let bool): 16 | self.schemaValue = .boolean(bool) 17 | case .object(let dict): 18 | self.schemaValue = .object(dict) 19 | default: 20 | throw UnsupportedSchemaTypeError.unsupportedType( 21 | "Unsupported rawSchema type encountered: \(rawSchema)" 22 | ) 23 | } 24 | self.schema = try Schema(rawSchema: rawSchema, context: .init(dialect: dialect)) 25 | } 26 | 27 | public func parse(_ value: JSONValue) -> Parsed { 28 | let result = schema.validate(value) 29 | return result.isValid 30 | ? .valid(value) 31 | : .error(.runtimeValidationIssue(result)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyArray.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONPropertyComponents { 4 | public struct PropertyArray: PropertyCollection { 5 | let components: [Component] 6 | 7 | public var requiredKeys: [String] { 8 | components.flatMap(\.requiredKeys) 9 | } 10 | 11 | public var schemaValue: SchemaValue { 12 | components.reduce(into: SchemaValue.object([:])) { result, component in 13 | result.merge(component.schemaValue) 14 | } 15 | } 16 | 17 | public func validate( 18 | _ dictionary: [String: JSONValue] 19 | ) -> Parsed<[Component.Output], ParseIssue> { 20 | var outputs: [Component.Output] = [] 21 | var issues: [ParseIssue] = [] 22 | 23 | for component in components { 24 | let result = component.validate(dictionary) 25 | switch result { 26 | case .valid(let output): 27 | outputs.append(output) 28 | case .invalid(let issue): 29 | issues.append(contentsOf: issue) 30 | } 31 | } 32 | 33 | guard issues.isEmpty else { 34 | return .invalid(issues) 35 | } 36 | return .valid(outputs) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/JSONSchemaMacroTests/Helpers/MacroExpansion+SwiftTesting.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntaxMacroExpansion 2 | import SwiftSyntaxMacros 3 | import SwiftSyntaxMacrosGenericTestSupport 4 | import Testing 5 | 6 | func assertMacroExpansion( 7 | _ originalSource: String, 8 | expandedSource expectedExpandedSource: String, 9 | diagnostics expectedDiagnostics: [DiagnosticSpec] = [], 10 | macros: [String: Macro.Type], 11 | fileID: StaticString = #fileID, 12 | filePath: StaticString = #filePath, 13 | line: UInt = #line, 14 | column: UInt = #column 15 | ) { 16 | assertMacroExpansion( 17 | originalSource, 18 | expandedSource: expectedExpandedSource, 19 | diagnostics: expectedDiagnostics, 20 | macroSpecs: macros.mapValues { MacroSpec(type: $0) }, 21 | indentationWidth: .spaces(2), 22 | failureHandler: { spec in 23 | Issue.record( 24 | "\(spec.message)", 25 | sourceLocation: SourceLocation( 26 | fileID: spec.location.fileID, 27 | filePath: spec.location.filePath, 28 | line: spec.location.line, 29 | column: spec.location.column 30 | ) 31 | ) 32 | }, 33 | fileID: fileID, 34 | filePath: filePath, 35 | line: line, 36 | column: column 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/RecursiveTreeIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import JSONSchemaBuilder 3 | import Testing 4 | 5 | @Schemable 6 | struct TreeNode: Sendable, Equatable { 7 | let name: String 8 | let children: [TreeNode] 9 | } 10 | 11 | struct RecursiveTreeIntegrationTests { 12 | @Test func treeSchemaValidatesRecursiveDocuments() throws { 13 | let schema = TreeNode.schema.definition() 14 | 15 | let valid: JSONValue = [ 16 | "name": "root", 17 | "children": [ 18 | ["name": "child", "children": []], 19 | [ 20 | "name": "branch", 21 | "children": [ 22 | ["name": "leaf", "children": []] 23 | ], 24 | ], 25 | ], 26 | ] 27 | 28 | #expect(schema.validate(valid).isValid) 29 | } 30 | 31 | @Test func treeSchemaInvalidRecursiveDocuments() throws { 32 | let schema = TreeNode.schema.definition() 33 | 34 | let invalid: JSONValue = [ 35 | "name": "root", 36 | "children": [ 37 | [ 38 | "name": "branch", 39 | "children": [ 40 | ["name": 42, "children": []] 41 | ], 42 | ] 43 | ], 44 | ] 45 | 46 | #expect(schema.validate(invalid).isValid == false) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Annotations/JSONType.swift: -------------------------------------------------------------------------------- 1 | /// The type of a JSON value. 2 | /// 3 | /// - SeeAlso: ``JSONValue`` 4 | public enum JSONType: String, Codable, Hashable, Sendable { 5 | case string 6 | case integer 7 | case number 8 | case object 9 | case array 10 | case boolean 11 | case null 12 | } 13 | 14 | extension JSONType { 15 | /// Determines whether the schema's allowed type matches the instance's actual type. 16 | /// 17 | /// This method accounts for the subtype relationship in JSON Schema where "integer" is a subset of "number". 18 | /// It returns `true` if the allowed type matches the instance type directly, or if the allowed type is "number" 19 | /// and the instance type is "integer", reflecting that integers are valid numbers. 20 | /// 21 | /// - Parameter instanceType: The actual type of the JSON instance being validated. 22 | /// - Returns: `true` if the instance type matches the allowed type specified in the schema; otherwise, `false`. 23 | public func matches(instanceType: JSONType) -> Bool { 24 | if self == instanceType { 25 | return true 26 | } 27 | // 'integer' is a subtype of 'number' 28 | if self == .number && instanceType == .integer { 29 | return true 30 | } 31 | return false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/TypeSpecific/NumberOptions.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @attached(peer) 4 | public macro NumberOptions( 5 | _ traits: NumberTrait... 6 | ) = #externalMacro(module: "JSONSchemaMacro", type: "NumberOptionsMacro") 7 | 8 | public protocol NumberTrait {} 9 | 10 | public struct NumberSchemaTrait: NumberTrait { 11 | fileprivate init() {} 12 | 13 | fileprivate static let errorMessage = 14 | "This method should only be used within @NumberOptions macro" 15 | } 16 | 17 | extension NumberTrait where Self == NumberSchemaTrait { 18 | public static func multipleOf(_ value: Double) -> NumberSchemaTrait { 19 | fatalError(NumberSchemaTrait.errorMessage) 20 | } 21 | 22 | public static func minimum(_ value: Double) -> NumberSchemaTrait { 23 | fatalError(NumberSchemaTrait.errorMessage) 24 | } 25 | 26 | public static func exclusiveMinimum(_ value: Double) -> NumberSchemaTrait { 27 | fatalError(NumberSchemaTrait.errorMessage) 28 | } 29 | 30 | public static func maximum(_ value: Double) -> NumberSchemaTrait { 31 | fatalError(NumberSchemaTrait.errorMessage) 32 | } 33 | 34 | public static func exclusiveMaximum(_ value: Double) -> NumberSchemaTrait { 35 | fatalError(NumberSchemaTrait.errorMessage) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Utils/SchemaAnchorName.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A small utility for converting arbitrary Swift type names into valid JSON Schema anchor strings. 4 | /// 5 | /// JSON Schema anchor names must match the regular expression `[A-Za-z_][A-Za-z0-9_\-\.]*`. 6 | public enum SchemaAnchorName { 7 | private static let allowedCharacters: CharacterSet = { 8 | var set = CharacterSet() 9 | set.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._") 10 | return set 11 | }() 12 | 13 | private static let fallback = "_Anchor" 14 | 15 | public static func sanitized(_ rawValue: String) -> String { 16 | Self.sanitize(rawValue) 17 | } 18 | 19 | private static func sanitize(_ rawValue: String) -> String { 20 | var transformed = rawValue.unicodeScalars.map { scalar -> Character in 21 | guard allowedCharacters.contains(scalar) else { 22 | return "_" 23 | } 24 | return Character(scalar) 25 | } 26 | 27 | if transformed.first?.isNumber == true || transformed.first == "-" || transformed.first == "." { 28 | transformed.insert("_", at: 0) 29 | } 30 | 31 | if transformed.isEmpty { 32 | transformed = Array(fallback) 33 | } 34 | 35 | return String(transformed) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/Enum.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Adds an `enum` constraint to the schema. 5 | /// - Parameter builder: A closure that returns one or more of enum values. 6 | /// - Returns: A new component with the `enum` constraint applied. 7 | public func enumValues( 8 | @JSONValueBuilder with builder: () -> JSONValueRepresentable 9 | ) -> JSONComponents.Enum { .init(upstream: self, cases: Array(builder().value)) } 10 | } 11 | 12 | extension JSONComponents { 13 | public struct Enum: JSONSchemaComponent { 14 | public var schemaValue: SchemaValue { 15 | get { 16 | var schema = upstream.schemaValue 17 | schema[Keywords.Enum.name] = .array(cases) 18 | return schema 19 | } 20 | set { upstream.schemaValue = newValue } 21 | } 22 | 23 | var upstream: Upstream 24 | var cases: [JSONValue] 25 | 26 | public init(upstream: Upstream, cases: [JSONValue]) { 27 | self.upstream = upstream 28 | self.cases = cases 29 | 30 | } 31 | 32 | public func parse(_ value: JSONValue) -> Parsed { 33 | for `case` in cases where `case` == value { return upstream.parse(value) } 34 | return .error(.noEnumCaseMatch(value: value)) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Parsing/ParseIssue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | public enum ParseIssue: Error, Equatable, Sendable { 4 | case typeMismatch(expected: JSONType, actual: JSONValue) 5 | case noEnumCaseMatch(value: JSONValue) 6 | case missingRequiredProperty(property: String) 7 | case compactMapValueNil(value: JSONValue) 8 | case compositionFailure(type: JSONComposition, reason: String, nestedErrors: [ParseIssue]) 9 | case runtimeValidationIssue(ValidationResult) 10 | } 11 | 12 | extension ParseIssue: CustomStringConvertible { 13 | public var description: String { 14 | switch self { 15 | case .typeMismatch(let expected, let actual): 16 | "Type mismatch: the instance of type `\(actual.primitive)` does not match the expected type `\(expected)`." 17 | case .noEnumCaseMatch(let value): 18 | "The instance `\(value)` does not match any enum case." 19 | case .missingRequiredProperty(let property): 20 | "Missing required property `\(property)`." 21 | case .compactMapValueNil(let value): 22 | "The instance `\(value)` returned nil when evaluated against compact map." 23 | case .compositionFailure(let type, let reason, _): 24 | "Composition (`\(type)`) failure: the instance \(reason)." 25 | case .runtimeValidationIssue(let error): 26 | "Runtime validation issue: \(error)" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/Schemable/CompositionKeyword.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | /// Represents the composition keyword that should be emitted in generated schemas. 4 | enum CompositionKeyword { 5 | case oneOf 6 | case anyOf 7 | 8 | init(argument: ExprSyntax?) { 9 | guard let argument else { 10 | self = .oneOf 11 | return 12 | } 13 | 14 | if let memberAccess = argument.as(MemberAccessExprSyntax.self) { 15 | self = CompositionKeyword(baseName: memberAccess.declName.baseName.text) 16 | } else if let declReference = argument.as(DeclReferenceExprSyntax.self) { 17 | self = CompositionKeyword(baseName: declReference.baseName.text) 18 | } else { 19 | self = .oneOf 20 | } 21 | } 22 | 23 | private init(baseName: String) { 24 | switch baseName.lowercased() { 25 | case "anyof": 26 | self = .anyOf 27 | default: 28 | self = .oneOf 29 | } 30 | } 31 | 32 | /// Returns the suffix for `JSONComposition` builders (e.g., `OneOf`). 33 | var jsonCompositionBuilderName: String { 34 | switch self { 35 | case .oneOf: 36 | return "OneOf" 37 | case .anyOf: 38 | return "AnyOf" 39 | } 40 | } 41 | 42 | /// Returns the member access used when emitting `.orNull(style: ...)`. 43 | var orNullStyleAccessor: String { 44 | switch self { 45 | case .oneOf: 46 | return ".union" 47 | case .anyOf: 48 | return ".unionAnyOf" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/JSONIdentifierTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct JSONIdentifierBuilderTests { 7 | @Test func identifierKeywords() throws { 8 | @JSONSchemaBuilder var sample: some JSONSchemaComponent { 9 | JSONString() 10 | .id("https://example.com/schema") 11 | .schema("https://json-schema.org/draft/2020-12/schema") 12 | .vocabulary([ 13 | "https://example.com/vocab/core": true, 14 | "https://example.com/vocab/other": false, 15 | ]) 16 | .anchor("nameAnchor") 17 | .dynamicAnchor("dynAnchor") 18 | .dynamicRef("#dynAnchor") 19 | .ref("#someRef") 20 | .recursiveAnchor("recAnchor") 21 | .recursiveRef("#recAnchor") 22 | } 23 | 24 | let expected: [String: JSONValue] = [ 25 | "$id": "https://example.com/schema", 26 | "$schema": "https://json-schema.org/draft/2020-12/schema", 27 | "$vocabulary": [ 28 | "https://example.com/vocab/core": true, 29 | "https://example.com/vocab/other": false, 30 | ], 31 | "$anchor": "nameAnchor", 32 | "$dynamicAnchor": "dynAnchor", 33 | "$dynamicRef": "#dynAnchor", 34 | "$ref": "#someRef", 35 | "$recursiveAnchor": "recAnchor", 36 | "$recursiveRef": "#recAnchor", 37 | "type": "string", 38 | ] 39 | 40 | #expect(sample.schemaValue == .object(expected)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyFlatMap.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONPropertyComponent { 4 | /// Flattens a double-optional output to a single optional. 5 | /// This is specifically useful for optional properties that use `.orNull()`, 6 | /// which creates a double-optional (T??) that needs to be flattened to T?. 7 | /// - Returns: A new component that flattens the double-optional output. 8 | public func flatMapOptional() 9 | -> JSONPropertyComponents.FlatMapOptional 10 | where Output == Wrapped?? { 11 | .init(upstream: self) 12 | } 13 | } 14 | 15 | extension JSONPropertyComponents { 16 | public struct FlatMapOptional: JSONPropertyComponent 17 | where Upstream.Output == Wrapped?? { 18 | let upstream: Upstream 19 | 20 | public var key: String { upstream.key } 21 | 22 | public var isRequired: Bool { upstream.isRequired } 23 | 24 | public var value: Upstream.Value { upstream.value } 25 | 26 | public func parse(_ input: [String: JSONValue]) -> Parsed { 27 | switch upstream.parse(input) { 28 | case .valid(let output): 29 | // Flatten T?? to T? 30 | // If output is nil (property missing), return nil 31 | // If output is .some(nil) (property present but null), return nil 32 | // If output is .some(.some(value)), return value 33 | return .valid(output.flatMap { $0 }) 34 | case .invalid(let error): return .invalid(error) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/CompactMap.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Modifies the validated output of a component by applying a transform. If the transform returns `nil`, the output sends a validation error. 5 | /// - Parameter transform: The transform to apply to the output. 6 | /// - Returns: A new component that applies the transform. 7 | public func compactMap( 8 | _ transform: @Sendable @escaping (Output) -> NewOutput? 9 | ) -> JSONComponents.CompactMap { .init(upstream: self, transform: transform) } 10 | } 11 | 12 | extension JSONComponents { 13 | public struct CompactMap: JSONSchemaComponent { 14 | public var schemaValue: SchemaValue { 15 | get { upstream.schemaValue } 16 | set { upstream.schemaValue = newValue } 17 | } 18 | 19 | var upstream: Upstream 20 | let transform: @Sendable (Upstream.Output) -> Output? 21 | 22 | public init(upstream: Upstream, transform: @Sendable @escaping (Upstream.Output) -> Output?) { 23 | self.upstream = upstream 24 | self.transform = transform 25 | } 26 | 27 | public func parse(_ value: JSONValue) -> Parsed { 28 | let output = upstream.parse(value) 29 | switch output { 30 | case .valid(let a): 31 | guard let newOutput = transform(a) else { return .error(.compactMapValueNil(value: value)) } 32 | return .valid(newOutput) 33 | case .invalid(let errors): return .invalid(errors) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/FlatMap.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Modifies valid components by applying a transform with additional validation. 5 | /// - Parameter transform: The transform to apply to the output. 6 | /// - Returns: A new component that applies the transform. 7 | public func flatMap( 8 | _ transform: @Sendable @escaping (Output) -> NewComponent 9 | ) -> JSONComponents.FlatMap { .init(upstream: self, transform: transform) } 10 | } 11 | 12 | extension JSONComponents { 13 | public struct FlatMap: 14 | JSONSchemaComponent 15 | { 16 | public var schemaValue: SchemaValue { 17 | get { upstream.schemaValue } 18 | set { upstream.schemaValue = newValue } 19 | } 20 | 21 | var upstream: Upstream 22 | let transform: @Sendable (Upstream.Output) -> NewSchemaComponent 23 | 24 | init(upstream: Upstream, transform: @Sendable @escaping (Upstream.Output) -> NewSchemaComponent) 25 | { 26 | self.upstream = upstream 27 | self.transform = transform 28 | } 29 | 30 | public func parse(_ value: JSONValue) -> Parsed { 31 | switch upstream.parse(value) { 32 | case .valid(let upstreamOutput): 33 | let newSchemaComponent = transform(upstreamOutput) 34 | return newSchemaComponent.parse(value) 35 | case .invalid(let error): return .invalid(error) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/KeyEncodingTests.swift: -------------------------------------------------------------------------------- 1 | import InlineSnapshotTesting 2 | import JSONSchemaBuilder 3 | import Testing 4 | 5 | @Schemable(keyStrategy: .snakeCase) 6 | struct SnakePerson { 7 | let firstName: String 8 | let lastName: String 9 | } 10 | 11 | @Schemable 12 | struct CustomKeyPerson { 13 | @SchemaOptions(.key("first_name")) 14 | let firstName: String 15 | let lastName: String 16 | } 17 | 18 | struct KeyEncodingTests { 19 | @Test(.snapshots(record: false)) func snakeCase() { 20 | let schema = SnakePerson.schema.schemaValue 21 | assertInlineSnapshot(of: schema, as: .json) { 22 | #""" 23 | { 24 | "properties" : { 25 | "first_name" : { 26 | "type" : "string" 27 | }, 28 | "last_name" : { 29 | "type" : "string" 30 | } 31 | }, 32 | "required" : [ 33 | "first_name", 34 | "last_name" 35 | ], 36 | "type" : "object" 37 | } 38 | """# 39 | } 40 | } 41 | 42 | @Test(.snapshots(record: false)) func override() { 43 | let schema = CustomKeyPerson.schema.schemaValue 44 | assertInlineSnapshot(of: schema, as: .json) { 45 | #""" 46 | { 47 | "properties" : { 48 | "first_name" : { 49 | "type" : "string" 50 | }, 51 | "lastName" : { 52 | "type" : "string" 53 | } 54 | }, 55 | "required" : [ 56 | "first_name", 57 | "lastName" 58 | ], 59 | "type" : "object" 60 | } 61 | """# 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/SchemaReferenceURITests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct SchemaReferenceURITests { 7 | @Test func definitionsInDefsContainer() { 8 | let uri = SchemaReferenceURI.definition(named: "Tree") 9 | #expect(uri.rawValue == "#/$defs/Tree") 10 | } 11 | 12 | @Test func definitionsInLegacyContainer() { 13 | let uri = SchemaReferenceURI.definition(named: "Tree", location: .definitions) 14 | #expect(uri.rawValue == "#/definitions/Tree") 15 | } 16 | 17 | @Test func documentPointerEscapesTokens() { 18 | let pointer = JSONPointer(tokens: ["properties", "foo/bar", "tilde~value"]) 19 | let uri = SchemaReferenceURI.documentPointer(pointer) 20 | #expect(uri.rawValue == "#/properties/foo~1bar/tilde~0value") 21 | } 22 | 23 | @Test func remoteWithoutPointerKeepsURL() { 24 | let url = "https://example.com/schemas/tree.json" 25 | let uri = SchemaReferenceURI.remote(url) 26 | #expect(uri.rawValue == url) 27 | } 28 | 29 | @Test func remoteWithPointerAppendsFragment() { 30 | let url = "https://example.com/schemas/tree.json" 31 | let pointer = JSONPointer(tokens: ["$defs", "Tree"]) 32 | let uri = SchemaReferenceURI.remote(url, pointer: pointer) 33 | #expect(uri.rawValue == "https://example.com/schemas/tree.json#/$defs/Tree") 34 | } 35 | 36 | @Test func remoteWithEmptyPointerPointsToRoot() { 37 | let url = "https://example.com/schemas/tree.json" 38 | let pointer = JSONPointer(tokens: []) 39 | let uri = SchemaReferenceURI.remote(url, pointer: pointer) 40 | #expect(uri.rawValue == "https://example.com/schemas/tree.json#") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/Schemable.swift: -------------------------------------------------------------------------------- 1 | /// Defines the composition keyword that should be used when combining schemas. 2 | public enum SchemaComposition { 3 | /// Uses the `oneOf` keyword when composing schemas. 4 | case oneOf 5 | /// Uses the `anyOf` keyword when composing schemas. 6 | case anyOf 7 | } 8 | 9 | @attached(extension, conformances: Schemable) 10 | @attached(member, names: named(schema), named(keyEncodingStrategy)) 11 | public macro Schemable( 12 | keyStrategy: KeyEncodingStrategies? = nil, 13 | optionalNulls: Bool = true, 14 | enumComposition: SchemaComposition = .oneOf, 15 | optionalNullUnion: SchemaComposition = .oneOf 16 | ) = #externalMacro(module: "JSONSchemaMacro", type: "SchemableMacro") 17 | 18 | public protocol Schemable { 19 | #if compiler(>=5.9) 20 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 21 | associatedtype Schema: JSONSchemaComponent 22 | 23 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 24 | @JSONSchemaBuilder static var schema: Schema { get } 25 | 26 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 27 | static var keyEncodingStrategy: KeyEncodingStrategies { get } 28 | 29 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 30 | static var defaultAnchor: String { get } 31 | #endif 32 | } 33 | 34 | #if compiler(>=5.9) 35 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 36 | extension Schemable { 37 | public static var keyEncodingStrategy: KeyEncodingStrategies { .identity } 38 | 39 | public static var defaultAnchor: String { 40 | SchemaAnchorName.sanitized(String(reflecting: Self.self)) 41 | } 42 | } 43 | #endif 44 | -------------------------------------------------------------------------------- /Sources/JSONSchema/JSONValue/JSONValue+Codable.swift: -------------------------------------------------------------------------------- 1 | extension JSONValue: Codable { 2 | public func encode(to encoder: any Encoder) throws { 3 | var container = encoder.singleValueContainer() 4 | switch self { 5 | case .string(let string): try container.encode(string) 6 | case .number(let double): try container.encode(double) 7 | case .integer(let int): try container.encode(int) 8 | case .object(let dictionary): try container.encode(dictionary) 9 | case .array(let array): try container.encode(array) 10 | case .boolean(let bool): try container.encode(bool) 11 | case .null: try container.encodeNil() 12 | } 13 | } 14 | 15 | public init(from decoder: Decoder) throws { 16 | let container = try decoder.singleValueContainer() 17 | if let string = try? container.decode(String.self) { 18 | self = .string(string) 19 | } else if let int = try? container.decode(Int.self) { 20 | // It is important to check for integer before double, as all integers are also doubles. 21 | self = .integer(int) 22 | } else if let double = try? container.decode(Double.self) { 23 | self = .number(double) 24 | } else if let dictionary = try? container.decode([String: Self].self) { 25 | self = .object(dictionary) 26 | } else if let array = try? container.decode([Self].self) { 27 | self = .array(array) 28 | } else if let bool = try? container.decode(Bool.self) { 29 | self = .boolean(bool) 30 | } else if container.decodeNil() { 31 | self = .null 32 | } else { 33 | throw DecodingError.dataCorruptedError( 34 | in: container, 35 | debugDescription: "Unrecognized JSON value" 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/core.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/core", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Core vocabulary meta-schema", 7 | "type": ["object", "boolean"], 8 | "properties": { 9 | "$id": { 10 | "$ref": "#/$defs/uriReferenceString", 11 | "$comment": "Non-empty fragments not allowed.", 12 | "pattern": "^[^#]*#?$" 13 | }, 14 | "$schema": { "$ref": "#/$defs/uriString" }, 15 | "$ref": { "$ref": "#/$defs/uriReferenceString" }, 16 | "$anchor": { "$ref": "#/$defs/anchorString" }, 17 | "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, 18 | "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, 19 | "$vocabulary": { 20 | "type": "object", 21 | "propertyNames": { "$ref": "#/$defs/uriString" }, 22 | "additionalProperties": { 23 | "type": "boolean" 24 | } 25 | }, 26 | "$comment": { 27 | "type": "string" 28 | }, 29 | "$defs": { 30 | "type": "object", 31 | "additionalProperties": { "$dynamicRef": "#meta" } 32 | } 33 | }, 34 | "$defs": { 35 | "anchorString": { 36 | "type": "string", 37 | "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" 38 | }, 39 | "uriString": { 40 | "type": "string", 41 | "format": "uri" 42 | }, 43 | "uriReferenceString": { 44 | "type": "string", 45 | "format": "uri-reference" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchema.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// Analogous to `Group` in SwiftUI, this component can be used to group other components together. 4 | /// It can also be used to transform the output of the grouped components. 5 | public struct JSONSchema: JSONSchemaComponent { 6 | public var schemaValue: SchemaValue { 7 | get { components.schemaValue } 8 | set { components.schemaValue = newValue } 9 | } 10 | 11 | let transform: (Components.Output) -> NewOutput 12 | 13 | var components: Components 14 | 15 | /// Creates a new schema component and transforms the validated result into a new type. 16 | /// - Parameters: 17 | /// - transform: The transform to apply to the output. 18 | /// - component: The components to group together. 19 | public init( 20 | _ transform: @escaping (Components.Output) -> NewOutput, 21 | @JSONSchemaBuilder component: () -> Components 22 | ) { 23 | self.transform = transform 24 | self.components = component() 25 | } 26 | 27 | public func parse(_ value: JSONValue) -> Parsed { 28 | components.parse(value).map(transform) 29 | } 30 | } 31 | 32 | extension JSONSchema where NewOutput == JSONValue, Components == JSONComponents.MergedComponent { 33 | /// Creates a new schema component. 34 | /// - Parameter components: The components to group together. 35 | public init( 36 | @JSONSchemaCollectionBuilder components: () -> [JSONComponents.AnySchemaComponent< 37 | JSONValue 38 | >] 39 | ) { 40 | self.transform = { $0 } 41 | self.components = JSONComponents.MergedComponent(components: components()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/MergedComponent.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | public func merging(with other: C) -> JSONComponents.MergedComponent 5 | where Self.Output == JSONValue, C.Output == JSONValue { 6 | JSONComponents.MergedComponent(components: [ 7 | JSONComponents.AnySchemaComponent(self), 8 | JSONComponents.AnySchemaComponent(other), 9 | ]) 10 | } 11 | } 12 | 13 | extension JSONComponents { 14 | public struct MergedComponent: JSONSchemaComponent { 15 | public var schemaValue: SchemaValue { 16 | get { 17 | var result = SchemaValue.object([:]) 18 | for component in components { 19 | result.merge(component.schemaValue) 20 | } 21 | result.merge(setSchemaValue) 22 | return result 23 | } 24 | set { 25 | setSchemaValue = newValue 26 | } 27 | } 28 | 29 | let components: [JSONComponents.AnySchemaComponent] 30 | private var setSchemaValue: SchemaValue 31 | 32 | public init(components: [JSONComponents.AnySchemaComponent]) { 33 | self.components = components 34 | self.setSchemaValue = .object([:]) 35 | } 36 | 37 | public func parse(_ value: JSONValue) -> Parsed { 38 | var errors: [ParseIssue] = [] 39 | for component in components { 40 | switch component.parse(value) { 41 | case .valid: 42 | continue 43 | case .invalid(let errs): 44 | errors.append(contentsOf: errs) 45 | } 46 | } 47 | guard errors.isEmpty else { 48 | return .invalid(errors) 49 | } 50 | return .valid(value) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Keywords/Keywords+Reserved.swift: -------------------------------------------------------------------------------- 1 | protocol ReservedKeyword: Keyword {} 2 | 3 | extension ReservedKeyword { 4 | package static var vocabulary: String { 5 | // These are not part of any specific vocabulary in 2020-12 6 | // They are legacy keywords that may not have vocabulary restrictions 7 | "" 8 | } 9 | } 10 | 11 | extension Keywords { 12 | package struct Definitions: ReservedKeyword { 13 | package static let name = "definitions" 14 | 15 | package let value: JSONValue 16 | package let context: KeywordContext 17 | 18 | package init(value: JSONValue, context: KeywordContext) { 19 | self.value = value 20 | self.context = context 21 | } 22 | } 23 | 24 | package struct Dependencies: ReservedKeyword { 25 | package static let name = "dependencies" 26 | 27 | package let value: JSONValue 28 | package let context: KeywordContext 29 | 30 | package init(value: JSONValue, context: KeywordContext) { 31 | self.value = value 32 | self.context = context 33 | } 34 | } 35 | 36 | package struct RecursiveAnchor: ReservedKeyword { 37 | package static let name = "$recursiveAnchor" 38 | 39 | package let value: JSONValue 40 | package let context: KeywordContext 41 | 42 | package init(value: JSONValue, context: KeywordContext) { 43 | self.value = value 44 | self.context = context 45 | } 46 | } 47 | 48 | package struct RecursiveReference: ReservedKeyword { 49 | package static let name = "$recursiveRef" 50 | 51 | package let value: JSONValue 52 | package let context: KeywordContext 53 | 54 | package init(value: JSONValue, context: KeywordContext) { 55 | self.value = value 56 | self.context = context 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent+Conditionals.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Sets the ``Keywords/DependentRequired`` mapping on the schema. 5 | /// 6 | /// When the specified key is present in the input, each of the associated 7 | /// property names must also be present. 8 | /// - Parameter mapping: A dictionary mapping property names to arrays of 9 | /// required property names. 10 | /// - Returns: A copy of this component with the ``dependentRequired`` mapping 11 | /// set. 12 | public func dependentRequired(_ mapping: [String: [String]]) -> Self { 13 | var copy = self 14 | copy.schemaValue[Keywords.DependentRequired.name] = .object( 15 | Dictionary( 16 | uniqueKeysWithValues: mapping.map { key, array in 17 | (key, .array(array.map { .string($0) })) 18 | } 19 | ) 20 | ) 21 | return copy 22 | } 23 | 24 | /// Sets the ``Keywords/DependentSchemas`` mapping on the schema. 25 | /// 26 | /// The schemas in this mapping are evaluated when the corresponding property 27 | /// is present in the input. 28 | /// - Parameter mapping: A dictionary whose keys are property names and values 29 | /// are the schemas to validate when that property exists. 30 | /// - Returns: A copy of this component with the ``dependentSchemas`` mapping 31 | /// set. 32 | public func dependentSchemas(_ mapping: [String: any JSONSchemaComponent]) -> Self { 33 | var copy = self 34 | copy.schemaValue[Keywords.DependentSchemas.name] = .object( 35 | Dictionary( 36 | uniqueKeysWithValues: mapping.map { key, component in 37 | (key, component.schemaValue.value) 38 | } 39 | ) 40 | ) 41 | return copy 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/CompileTimeMacroTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchemaBuilder 2 | 3 | // These are here to prevent compile time regressions in generated macro expansions. 4 | // Instead of asserting, they fail test target build. 5 | 6 | @Schemable 7 | enum Airline: String, CaseIterable { 8 | case delta 9 | case united 10 | case american 11 | case alaska 12 | } 13 | 14 | @Schemable 15 | struct Flight: Sendable { 16 | let origin: String 17 | let destination: String? 18 | let airline: Airline 19 | @NumberOptions(.multipleOf(0.5)) 20 | let duration: Double 21 | } 22 | 23 | @Schemable 24 | @ObjectOptions(.additionalProperties { false }) 25 | struct Weather1 { 26 | let cityName: String 27 | } 28 | 29 | @Schemable 30 | @ObjectOptions( 31 | .additionalProperties { 32 | JSONString() 33 | .pattern("^[a-zA-Z]+$") 34 | }, 35 | .patternProperties { 36 | JSONProperty(key: "^[A-Za-z_][A-Za-z0-9_]*$") { 37 | JSONBoolean() 38 | } 39 | } 40 | ) 41 | public struct Weather2 { 42 | let temperature: Double 43 | } 44 | 45 | @Schemable 46 | @ObjectOptions( 47 | .minProperties(2), 48 | .maxProperties(5), 49 | .propertyNames { 50 | JSONString() 51 | .pattern("^[A-Za-z_][A-Za-z0-9_]*$") 52 | }, 53 | .unevaluatedProperties { 54 | JSONString() 55 | } 56 | ) 57 | struct Weather3 { 58 | let cityName: String 59 | } 60 | 61 | @Schemable 62 | struct Weather4 { 63 | @SchemaOptions( 64 | .title("Temperature"), 65 | .description("The current temperature in fahrenheit, like 70°F"), 66 | .default(75.0), 67 | .examples([72.0, 75.0, 78.0]), 68 | .readOnly(true), 69 | .writeOnly(false), 70 | .deprecated(true), 71 | .comment("This is a comment about temperature") 72 | ) 73 | let temperature: Double 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build_and_test: 11 | runs-on: macos-latest 12 | 13 | name: Test on macOS 14 | steps: 15 | - uses: actions/checkout@v5 16 | - name: Xcode version 17 | run: xcodebuild -version 18 | if: ${{ always() }} 19 | - name: Log Xcode versions 20 | run: ls -l /Applications/Xcode* 21 | if: ${{ failure() }} 22 | - name: Get swift version 23 | run: swift --version 24 | - name: Run tests 25 | run: swift test --enable-code-coverage 26 | - name: Upload coverage reports to Codecov 27 | uses: vapor/swift-codecov-action@v0.3.2 28 | with: 29 | codecov_token: ${{ secrets.CODECOV_TOKEN }} 30 | 31 | build_linux: 32 | name: Build on Linux 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v5 36 | - name: Get swift version 37 | run: swift --version 38 | - name: Delete Package.resolved 39 | run: rm -f Package.resolved 40 | - name: Build 41 | run: swift build -v 42 | 43 | build_apple_platforms: 44 | name: Build on Apple Platforms 45 | runs-on: macos-26-xlarge 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | platform: [macOS, iOS, watchOS, tvOS, visionOS] 50 | steps: 51 | - uses: actions/checkout@v5 52 | - name: Get swift version 53 | run: swift --version 54 | - name: Get Xcode version 55 | run: xcodebuild -version 56 | - name: Build ${{ matrix.platform }} 57 | run: set -o pipefail && xcodebuild build -destination generic/platform=${{ matrix.platform }} -scheme swift-json-schema-Package | xcbeautify --renderer github-actions 58 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/applicator.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/applicator", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Applicator vocabulary meta-schema", 7 | "type": ["object", "boolean"], 8 | "properties": { 9 | "prefixItems": { "$ref": "#/$defs/schemaArray" }, 10 | "items": { "$dynamicRef": "#meta" }, 11 | "contains": { "$dynamicRef": "#meta" }, 12 | "additionalProperties": { "$dynamicRef": "#meta" }, 13 | "properties": { 14 | "type": "object", 15 | "additionalProperties": { "$dynamicRef": "#meta" }, 16 | "default": {} 17 | }, 18 | "patternProperties": { 19 | "type": "object", 20 | "additionalProperties": { "$dynamicRef": "#meta" }, 21 | "propertyNames": { "format": "regex" }, 22 | "default": {} 23 | }, 24 | "dependentSchemas": { 25 | "type": "object", 26 | "additionalProperties": { "$dynamicRef": "#meta" }, 27 | "default": {} 28 | }, 29 | "propertyNames": { "$dynamicRef": "#meta" }, 30 | "if": { "$dynamicRef": "#meta" }, 31 | "then": { "$dynamicRef": "#meta" }, 32 | "else": { "$dynamicRef": "#meta" }, 33 | "allOf": { "$ref": "#/$defs/schemaArray" }, 34 | "anyOf": { "$ref": "#/$defs/schemaArray" }, 35 | "oneOf": { "$ref": "#/$defs/schemaArray" }, 36 | "not": { "$dynamicRef": "#meta" } 37 | }, 38 | "$defs": { 39 | "schemaArray": { 40 | "type": "array", 41 | "minItems": 1, 42 | "items": { "$dynamicRef": "#meta" } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyCompactMap.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONPropertyComponent { 4 | /// Transforms the validated output of the property component's value by applying a transform. If the transform returns `nil`, the output sends a validation error. 5 | /// This effectively transforms the validation output to be non-optional and therefore marks the property as required. 6 | /// - Parameter transform: The transform to apply to the output. 7 | /// - Returns: A new component that applies the transform. 8 | public func compactMap( 9 | _ transform: @Sendable @escaping (Output) -> NewOutput? 10 | ) -> JSONPropertyComponents.CompactMap { 11 | .init(upstream: self, transform: transform) 12 | } 13 | } 14 | 15 | extension JSONPropertyComponents { 16 | public struct CompactMap: JSONPropertyComponent { 17 | let upstream: Upstream 18 | let transform: @Sendable (Upstream.Output) -> NewOutput? 19 | 20 | public var key: String { upstream.key } 21 | 22 | public let isRequired = true 23 | 24 | public var value: Upstream.Value { upstream.value } 25 | 26 | public func parse(_ input: [String: JSONValue]) -> Parsed { 27 | switch upstream.parse(input) { 28 | case .valid(let output): 29 | guard let newOutput = transform(output) else { 30 | // TODO: This isn't as generic as `compactMap` concept and leaks usage. 31 | // Would be better to use a closure for ParseIssue or change transform closure? 32 | return .error(.missingRequiredProperty(property: key)) 33 | } 34 | return .valid(newOutput) 35 | case .invalid(let error): return .invalid(error) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/JSONSchemaConversionTests/ConversionTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchemaBuilder 3 | import JSONSchemaConversion 4 | import Testing 5 | 6 | struct ConversionTests { 7 | @Test 8 | func uuid() throws { 9 | let result = try Conversions.uuid.schema.parseAndValidate( 10 | "123e4567-e89b-12d3-a456-426614174000" 11 | ) 12 | let expected = try #require(UUID(uuidString: "123e4567-e89b-12d3-a456-426614174000")) 13 | #expect(result == expected) 14 | } 15 | 16 | @Test 17 | func dateTime() throws { 18 | let result = try Conversions.dateTime.schema.parseAndValidate( 19 | "2023-05-01T12:34:56.789Z" 20 | ) 21 | #expect(result == Date(timeIntervalSince1970: 1682944496.789)) 22 | } 23 | 24 | @Test 25 | func date() throws { 26 | let result = try Conversions.date.schema.parseAndValidate( 27 | "2023-05-01" 28 | ) 29 | let formatter = DateFormatter() 30 | formatter.locale = Locale(identifier: "en_US_POSIX") 31 | formatter.dateFormat = "yyyy-MM-dd" 32 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 33 | let expected = try #require(formatter.date(from: "2023-05-01")) 34 | #expect(result == expected) 35 | } 36 | 37 | @Test 38 | func time() throws { 39 | let result = try Conversions.time.schema.parseAndValidate( 40 | "12:34:56.789Z" 41 | ) 42 | #expect(result.day == nil) 43 | #expect(result.hour == 12) 44 | #expect(result.minute == 34) 45 | #expect(result.second == 56) 46 | #expect(result.nanosecond != nil) 47 | #expect(result.timeZone?.secondsFromGMT() == 0) 48 | } 49 | 50 | @Test 51 | func url() throws { 52 | let result = try Conversions.url.schema.parseAndValidate( 53 | "https://example.com" 54 | ) 55 | #expect(result == URL(string: "https://example.com")) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/parse-instance.5.txt: -------------------------------------------------------------------------------- 1 | ▿ Parsed 2 | ▿ invalid: 1 element 3 | ▿ Composition (`oneOf`) failure: the instance no match found. 4 | ▿ compositionFailure: (3 elements) 5 | - type: JSONComposition.oneOf 6 | - reason: "no match found" 7 | ▿ nestedErrors: 4 elements 8 | ▿ Missing required property `technology`. 9 | ▿ missingRequiredProperty: (1 element) 10 | - property: "technology" 11 | ▿ Missing required property `entertainment`. 12 | ▿ missingRequiredProperty: (1 element) 13 | - property: "entertainment" 14 | ▿ Missing required property `education`. 15 | ▿ missingRequiredProperty: (1 element) 16 | - property: "education" 17 | ▿ The instance `{"food": {"_0": {"customDescription": "What's your favorite?"}}}` does not match any enum case. 18 | ▿ noEnumCaseMatch: (1 element) 19 | ▿ value: {"food": {"_0": {"customDescription": "What's your favorite?"}}} 20 | ▿ object: 1 key/value pair 21 | ▿ (2 elements) 22 | - key: "food" 23 | ▿ value: {"_0": {"customDescription": "What's your favorite?"}} 24 | ▿ object: 1 key/value pair 25 | ▿ (2 elements) 26 | - key: "_0" 27 | ▿ value: {"customDescription": "What's your favorite?"} 28 | ▿ object: 1 key/value pair 29 | ▿ (2 elements) 30 | - key: "customDescription" 31 | ▿ value: "What's your favorite?" 32 | - string: "What\'s your favorite?" 33 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Documentation.docc/Articles/WrapperTypes.md: -------------------------------------------------------------------------------- 1 | # Wrapper Types 2 | 3 | The builder provides several wrappers that can modify or combine components. 4 | 5 | | Wrapper | Description | 6 | | --- | --- | 7 | | ``JSONComponents/AnySchemaComponent`` | Type erasure for heterogeneous components. | 8 | | ``JSONComponents/Map`` | Transforms the output of an upstream component. | 9 | | ``JSONComponents/CompactMap`` | Like ``Map`` but emits an error when the transform returns `nil`. | 10 | | ``JSONComponents/FlatMap`` | Produces a new component after parsing the upstream component. | 11 | | ``JSONComponents/OptionalComponent`` | Makes a wrapped component optional. If no component is provided, any value is accepted. | 12 | | ``JSONComponents/PassthroughComponent`` | Validates with a wrapped component but returns the original input value. | 13 | | ``JSONComponents/AdditionalProperties`` | Adds ``additionalProperties`` validation for objects. | 14 | | ``JSONComponents/PatternProperties`` | Adds ``patternProperties`` validation for objects. | 15 | | ``JSONComponents/PropertyNames`` | Validates and captures property names for objects. | 16 | | ``JSONComponents/Conditional`` | Stores either of two components for ``buildEither`` cases. | 17 | | ``JSONComposition`` wrappers | ``AnyOf``/``AllOf``/``OneOf``/``Not`` compose multiple schemas. | 18 | | ``JSONSchema`` | Groups several components together and optionally maps them to a new type. | 19 | | ``RuntimeComponent`` | Wraps a runtime ``Schema`` and validates returning the raw ``JSONValue``. | 20 | | ``JSONAnyValue`` init | `init(_:)` copies schema metadata from any component without validation. | 21 | 22 | You can also use ``JSONValue`` directly inside a schema or macro expansion via ``JSONValue/schema``, 23 | which simply wraps ``JSONAnyValue`` so the generated schema accepts and returns any JSON document. 24 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent+Identifiers.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | public func id(_ value: String) -> Self { 5 | var copy = self 6 | copy.schemaValue[Keywords.Identifier.name] = .string(value) 7 | return copy 8 | } 9 | 10 | public func schema(_ uri: String) -> Self { 11 | var copy = self 12 | copy.schemaValue[Keywords.SchemaKeyword.name] = .string(uri) 13 | return copy 14 | } 15 | 16 | public func vocabulary(_ mapping: [String: Bool]) -> Self { 17 | var copy = self 18 | copy.schemaValue[Keywords.Vocabulary.name] = .object( 19 | Dictionary(uniqueKeysWithValues: mapping.map { ($0.key, .boolean($0.value)) }) 20 | ) 21 | return copy 22 | } 23 | 24 | public func anchor(_ name: String) -> Self { 25 | var copy = self 26 | copy.schemaValue[Keywords.Anchor.name] = .string(name) 27 | return copy 28 | } 29 | 30 | public func dynamicAnchor(_ name: String) -> Self { 31 | var copy = self 32 | copy.schemaValue[Keywords.DynamicAnchor.name] = .string(name) 33 | return copy 34 | } 35 | 36 | public func dynamicRef(_ uri: String) -> Self { 37 | var copy = self 38 | copy.schemaValue[Keywords.DynamicReference.name] = .string(uri) 39 | return copy 40 | } 41 | 42 | public func ref(_ uri: String) -> Self { 43 | var copy = self 44 | copy.schemaValue[Keywords.Reference.name] = .string(uri) 45 | return copy 46 | } 47 | 48 | public func recursiveAnchor(_ name: String) -> Self { 49 | var copy = self 50 | copy.schemaValue[Keywords.RecursiveAnchor.name] = .string(name) 51 | return copy 52 | } 53 | 54 | public func recursiveRef(_ uri: String) -> Self { 55 | var copy = self 56 | copy.schemaValue[Keywords.RecursiveReference.name] = .string(uri) 57 | return copy 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/ConditionalSchema.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | // MARK: - Conditional schema component 4 | public struct ConditionalSchema: JSONSchemaComponent { 5 | public var schemaValue: SchemaValue 6 | 7 | private let ifSchema: any JSONSchemaComponent 8 | private let thenSchema: any JSONSchemaComponent 9 | private let elseSchema: (any JSONSchemaComponent)? 10 | 11 | public init( 12 | if ifSchema: any JSONSchemaComponent, 13 | then thenSchema: any JSONSchemaComponent, 14 | else elseSchema: (any JSONSchemaComponent)? = nil 15 | ) { 16 | self.ifSchema = ifSchema 17 | self.thenSchema = thenSchema 18 | self.elseSchema = elseSchema 19 | 20 | var dict: [String: JSONValue] = [ 21 | Keywords.If.name: ifSchema.schemaValue.value, 22 | Keywords.Then.name: thenSchema.schemaValue.value, 23 | ] 24 | if let e = elseSchema { 25 | dict[Keywords.Else.name] = e.schemaValue.value 26 | } 27 | self.schemaValue = .object(dict) 28 | } 29 | 30 | public func parse(_ value: JSONValue) -> Parsed { 31 | .valid(value) 32 | } 33 | } 34 | 35 | // MARK: - DSL helper 36 | // swift-format-ignore: AlwaysUseLowerCamelCase 37 | @inlinable 38 | public func If( 39 | @JSONSchemaBuilder _ ifSchema: () -> some JSONSchemaComponent, 40 | then thenSchema: () -> some JSONSchemaComponent 41 | ) -> some JSONSchemaComponent { 42 | ConditionalSchema(if: ifSchema(), then: thenSchema()) 43 | } 44 | 45 | // swift-format-ignore: AlwaysUseLowerCamelCase 46 | @inlinable 47 | public func If( 48 | @JSONSchemaBuilder _ ifSchema: () -> some JSONSchemaComponent, 49 | then thenSchema: () -> some JSONSchemaComponent, 50 | else elseSchema: () -> some JSONSchemaComponent 51 | ) -> some JSONSchemaComponent { 52 | ConditionalSchema(if: ifSchema(), then: thenSchema(), else: elseSchema()) 53 | } 54 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/TypeSpecific/ObjectOptions.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @attached(peer) 4 | public macro ObjectOptions( 5 | _ traits: ObjectTrait... 6 | ) = #externalMacro(module: "JSONSchemaMacro", type: "ObjectOptionsMacro") 7 | 8 | public protocol ObjectTrait {} 9 | 10 | public struct ObjectSchemaTrait: ObjectTrait { 11 | fileprivate init() {} 12 | 13 | fileprivate static let errorMessage = 14 | "This method should only be used within @ObjectOptions macro" 15 | } 16 | 17 | extension ObjectTrait where Self == ObjectSchemaTrait { 18 | public static func additionalProperties( 19 | @JSONSchemaBuilder _ content: @escaping () -> some JSONSchemaComponent 20 | ) -> ObjectSchemaTrait { 21 | fatalError(ObjectSchemaTrait.errorMessage) 22 | } 23 | 24 | public static func additionalProperties(_ flag: Bool) -> ObjectSchemaTrait { 25 | fatalError(ObjectSchemaTrait.errorMessage) 26 | } 27 | 28 | public static func patternProperties( 29 | @JSONPropertySchemaBuilder _ patternProperties: @escaping () -> some PropertyCollection 30 | ) -> ObjectSchemaTrait { 31 | fatalError(ObjectSchemaTrait.errorMessage) 32 | } 33 | 34 | public static func unevaluatedProperties( 35 | @JSONSchemaBuilder _ content: @escaping () -> some JSONSchemaComponent 36 | ) -> ObjectSchemaTrait { 37 | fatalError(ObjectSchemaTrait.errorMessage) 38 | } 39 | 40 | public static func minProperties(_ value: Int) -> ObjectSchemaTrait { 41 | fatalError(ObjectSchemaTrait.errorMessage) 42 | } 43 | 44 | public static func maxProperties(_ value: Int) -> ObjectSchemaTrait { 45 | fatalError(ObjectSchemaTrait.errorMessage) 46 | } 47 | 48 | public static func propertyNames( 49 | @JSONSchemaBuilder _ content: @escaping () -> some JSONSchemaComponent 50 | ) -> ObjectSchemaTrait { 51 | fatalError(ObjectSchemaTrait.errorMessage) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONDynamicReference.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A typed wrapper around the ``$dynamicRef`` keyword that plugs directly into the 4 | /// ``JSONSchemaBuilder`` APIs. 5 | /// 6 | /// Use this component when you have declared a matching ``JSONSchemaComponent/dynamicAnchor(_:)`` 7 | /// elsewhere in the same schema (for example via the ``@Schemable`` macro) and you want to reuse 8 | /// that object definition without re-encoding it. The component preserves strong typing by 9 | /// delegating validation back through ``Schemable/schema`` for `T`. 10 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 11 | public struct JSONDynamicReference: JSONSchemaComponent { 12 | public typealias Output = T 13 | 14 | public var schemaValue: SchemaValue 15 | private let anchor: String 16 | 17 | /// Creates a dynamic reference to the specified anchor name. 18 | /// 19 | /// - Parameter anchor: The fragment identifier (sans leading `#`) that points to a 20 | /// ``JSONSchemaComponent/dynamicAnchor(_:)`` declaration. 21 | public init(anchor: String) { 22 | self.anchor = anchor 23 | self.schemaValue = .object([Keywords.DynamicReference.name: .string("#\(anchor)")]) 24 | } 25 | 26 | /// Creates a dynamic reference that automatically resolves its anchor name from the 27 | /// referenced type using `String(reflecting:)`. 28 | public init() { 29 | self.init(anchor: T.defaultAnchor) 30 | } 31 | 32 | /// Parses the incoming value using `T`'s schema so the caller continues to work with strongly 33 | /// typed Swift values. 34 | public func parse(_ value: JSONValue) -> Parsed { 35 | T.schema.parse(value) 36 | .flatMap { output in 37 | guard let typedOutput = output as? T else { 38 | return .invalid([.compactMapValueNil(value: value)]) 39 | } 40 | return .valid(typedOutput) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/TypeSpecific/ArrayOptions.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @attached(peer) 4 | public macro ArrayOptions( 5 | _ traits: ArrayTrait... 6 | ) = #externalMacro(module: "JSONSchemaMacro", type: "ArrayOptionsMacro") 7 | 8 | public protocol ArrayTrait {} 9 | 10 | public struct ArraySchemaTrait: ArrayTrait { 11 | fileprivate init() {} 12 | 13 | fileprivate static let errorMessage = "This method should only be used within @ArrayOptions macro" 14 | } 15 | 16 | extension ArrayTrait where Self == ArraySchemaTrait { 17 | public static func minContains(_ value: Int) -> ArraySchemaTrait { 18 | fatalError(ArraySchemaTrait.errorMessage) 19 | } 20 | 21 | public static func maxContains(_ value: Int) -> ArraySchemaTrait { 22 | fatalError(ArraySchemaTrait.errorMessage) 23 | } 24 | 25 | public static func minItems(_ value: Int) -> ArraySchemaTrait { 26 | fatalError(ArraySchemaTrait.errorMessage) 27 | } 28 | 29 | public static func maxItems(_ value: Int) -> ArraySchemaTrait { 30 | fatalError(ArraySchemaTrait.errorMessage) 31 | } 32 | 33 | public static func uniqueItems(_ value: Bool = true) -> ArraySchemaTrait { 34 | fatalError(ArraySchemaTrait.errorMessage) 35 | } 36 | 37 | public static func prefixItems( 38 | @JSONSchemaCollectionBuilder _ prefixItems: 39 | @escaping () -> [JSONComponents 40 | .AnySchemaComponent] 41 | ) -> ArraySchemaTrait { 42 | fatalError(ArraySchemaTrait.errorMessage) 43 | } 44 | 45 | public static func unevaluatedItems( 46 | @JSONSchemaBuilder _ unevaluatedItems: @escaping () -> Component 47 | ) -> ArraySchemaTrait { 48 | fatalError(ArraySchemaTrait.errorMessage) 49 | } 50 | 51 | public static func contains( 52 | @JSONSchemaBuilder _ contains: @escaping () -> any JSONSchemaComponent 53 | ) -> ArraySchemaTrait { 54 | fatalError(ArraySchemaTrait.errorMessage) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Utils/SchemaReferenceURI.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// Utility helpers for constructing well-formed `$ref` URIs without manually assembling strings. 4 | public struct SchemaReferenceURI: Equatable, CustomStringConvertible { 5 | public enum LocalLocation { 6 | /// Points at a definition stored under `$defs`. 7 | case defs 8 | /// Points at the legacy `definitions` keyword for compatibility with older drafts. 9 | case definitions 10 | } 11 | 12 | public let rawValue: String 13 | 14 | public var description: String { rawValue } 15 | 16 | public init(rawValue: String) { 17 | self.rawValue = rawValue 18 | } 19 | 20 | /// Creates a URI referencing a schema stored in `$defs`/`definitions`. 21 | public static func definition( 22 | named name: String, 23 | location: LocalLocation = .defs 24 | ) -> SchemaReferenceURI { 25 | let container: String 26 | switch location { 27 | case .defs: container = Keywords.Defs.name 28 | case .definitions: container = Keywords.Definitions.name 29 | } 30 | 31 | return documentPointer(JSONPointer(tokens: [container, name])) 32 | } 33 | 34 | /// Creates a URI pointing somewhere inside the current document using a JSON Pointer. 35 | public static func documentPointer(_ pointer: JSONPointer) -> SchemaReferenceURI { 36 | SchemaReferenceURI(rawValue: pointer.description) 37 | } 38 | 39 | /// Creates a URI referencing an external schema. 40 | /// - Parameters: 41 | /// - baseURI: The absolute or relative URL of the remote schema encoded as a string. 42 | /// - pointer: Optional JSON Pointer that will be appended as a fragment. 43 | /// - Returns: A well-formed URI referencing the remote schema and optional location inside it. 44 | public static func remote(_ baseURI: String, pointer: JSONPointer? = nil) -> SchemaReferenceURI { 45 | guard let pointer else { return SchemaReferenceURI(rawValue: baseURI) } 46 | return SchemaReferenceURI(rawValue: baseURI + pointer.description) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Annotations/AnnotationContainer.swift: -------------------------------------------------------------------------------- 1 | package struct AnnotationContainer { 2 | struct AnnotationKey: Hashable { 3 | let keywordType: ObjectIdentifier 4 | let instanceLocation: JSONPointer 5 | } 6 | 7 | private var storage: [AnnotationKey: AnyAnnotation] = [:] 8 | 9 | package init() {} 10 | 11 | mutating func insert( 12 | keyword: K, 13 | at instanceLocation: JSONPointer, 14 | value: K.AnnotationValue 15 | ) { 16 | let annotation = Annotation( 17 | keyword: type(of: keyword).name, 18 | instanceLocation: instanceLocation, 19 | schemaLocation: keyword.context.location, 20 | value: value 21 | ) 22 | insert(annotation) 23 | } 24 | 25 | mutating func insert(_ annotation: Annotation) { 26 | let key = AnnotationKey( 27 | keywordType: ObjectIdentifier(K.self), 28 | instanceLocation: annotation.instanceLocation 29 | ) 30 | if let existingAnnotation = storage[key] { 31 | let mergedAnnotation = existingAnnotation.merged(with: annotation) 32 | storage[key] = mergedAnnotation 33 | } else { 34 | storage[key] = annotation 35 | } 36 | } 37 | 38 | func annotation( 39 | for keyword: K.Type, 40 | at instanceLocation: JSONPointer 41 | ) -> Annotation? { 42 | let key = AnnotationKey( 43 | keywordType: ObjectIdentifier(keyword), 44 | instanceLocation: instanceLocation 45 | ) 46 | return storage[key] as? Annotation 47 | } 48 | 49 | func allAnnotations() -> [AnyAnnotation] { 50 | Array(storage.values) 51 | } 52 | 53 | mutating func merge(_ other: AnnotationContainer) { 54 | for (key, otherAnnotation) in other.storage { 55 | if let existingAnnotation = storage[key] { 56 | let mergedAnnotation = existingAnnotation.merged(with: otherAnnotation) 57 | storage[key] = mergedAnnotation 58 | } else { 59 | storage[key] = otherAnnotation 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "6433ba70ddaf380c9141c544be94accfb22e933d08096f294832f6e814b39c4a", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-custom-dump", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 8 | "state" : { 9 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 10 | "version" : "1.3.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-docc-plugin", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-docc-plugin", 17 | "state" : { 18 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 19 | "version" : "1.3.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-docc-symbolkit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-docc-symbolkit", 26 | "state" : { 27 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 28 | "version" : "1.0.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-snapshot-testing", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 35 | "state" : { 36 | "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", 37 | "version" : "1.18.6" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-syntax", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/swiftlang/swift-syntax.git", 44 | "state" : { 45 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 46 | "version" : "601.0.1" 47 | } 48 | }, 49 | { 50 | "identity" : "xctest-dynamic-overlay", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 53 | "state" : { 54 | "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", 55 | "version" : "1.6.1" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | @attached(peer) 4 | public macro SchemaOptions( 5 | _ traits: SchemaTrait... 6 | ) = #externalMacro(module: "JSONSchemaMacro", type: "SchemaOptionsMacro") 7 | 8 | public protocol SchemaTrait {} 9 | 10 | public struct SchemaOptionsTrait: SchemaTrait { 11 | fileprivate init() {} 12 | 13 | fileprivate static let errorMessage = 14 | "This method should only be used within @SchemaOptions macro" 15 | } 16 | 17 | extension SchemaTrait where Self == SchemaOptionsTrait { 18 | public static func title(_ value: String) -> SchemaOptionsTrait { 19 | fatalError(SchemaOptionsTrait.errorMessage) 20 | } 21 | 22 | public static func description(_ value: String) -> SchemaOptionsTrait { 23 | fatalError(SchemaOptionsTrait.errorMessage) 24 | } 25 | 26 | public static func key(_ value: String) -> SchemaOptionsTrait { 27 | fatalError(SchemaOptionsTrait.errorMessage) 28 | } 29 | 30 | public static func `default`(_ value: JSONValue) -> SchemaOptionsTrait { 31 | fatalError(SchemaOptionsTrait.errorMessage) 32 | } 33 | 34 | public static func examples(_ value: JSONValue) -> SchemaOptionsTrait { 35 | fatalError(SchemaOptionsTrait.errorMessage) 36 | } 37 | 38 | public static func readOnly(_ value: Bool) -> SchemaOptionsTrait { 39 | fatalError(SchemaOptionsTrait.errorMessage) 40 | } 41 | 42 | public static func writeOnly(_ value: Bool) -> SchemaOptionsTrait { 43 | fatalError(SchemaOptionsTrait.errorMessage) 44 | } 45 | 46 | public static func deprecated(_ value: Bool) -> SchemaOptionsTrait { 47 | fatalError(SchemaOptionsTrait.errorMessage) 48 | } 49 | 50 | public static func comment(_ value: String) -> SchemaOptionsTrait { 51 | fatalError(SchemaOptionsTrait.errorMessage) 52 | } 53 | 54 | public static func customSchema(_ conversion: S.Type) -> SchemaOptionsTrait { 55 | fatalError(SchemaOptionsTrait.errorMessage) 56 | } 57 | 58 | public static func orNull(style: OrNullStyle) -> SchemaOptionsTrait { 59 | fatalError(SchemaOptionsTrait.errorMessage) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Annotations/Annotation.swift: -------------------------------------------------------------------------------- 1 | struct Annotation: Sendable { 2 | let keyword: KeywordIdentifier 3 | let instanceLocation: JSONPointer 4 | let schemaLocation: JSONPointer 5 | let absoluteSchemaLocation: JSONPointer? 6 | let value: Keyword.AnnotationValue 7 | 8 | init( 9 | keyword: KeywordIdentifier, 10 | instanceLocation: JSONPointer, 11 | schemaLocation: JSONPointer, 12 | absoluteSchemaLocation: JSONPointer? = nil, 13 | value: Keyword.AnnotationValue 14 | ) { 15 | self.keyword = keyword 16 | self.instanceLocation = instanceLocation 17 | self.schemaLocation = schemaLocation 18 | self.absoluteSchemaLocation = absoluteSchemaLocation 19 | self.value = value 20 | } 21 | 22 | init(keyword: Keyword, instanceLocation: JSONPointer, value: Keyword.AnnotationValue) { 23 | self.keyword = type(of: keyword).name 24 | self.instanceLocation = instanceLocation 25 | self.schemaLocation = keyword.context.location 26 | self.absoluteSchemaLocation = nil 27 | self.value = value 28 | } 29 | } 30 | 31 | public protocol AnyAnnotation: Sendable { 32 | var keyword: KeywordIdentifier { get } 33 | var instanceLocation: JSONPointer { get } 34 | var schemaLocation: JSONPointer { get } 35 | var absoluteSchemaLocation: JSONPointer? { get } 36 | var jsonValue: JSONValue { get } 37 | 38 | func merged(with other: AnyAnnotation) -> AnyAnnotation? 39 | } 40 | 41 | extension Annotation: AnyAnnotation where Keyword.AnnotationValue: AnnotationValueConvertible { 42 | var jsonValue: JSONValue { 43 | self.value.value 44 | } 45 | } 46 | 47 | extension Annotation { 48 | func merged(with other: AnyAnnotation) -> AnyAnnotation? { 49 | guard let otherAnnotation = other as? Annotation else { 50 | return nil 51 | } 52 | 53 | let mergedValue = self.value.merged(with: otherAnnotation.value) 54 | 55 | return Annotation( 56 | keyword: self.keyword, 57 | instanceLocation: self.instanceLocation, 58 | schemaLocation: self.schemaLocation, 59 | value: mergedValue 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/SchemaValue.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | public enum SchemaValue: Sendable, Equatable { 4 | case boolean(Bool) 5 | case object([KeywordIdentifier: JSONValue]) 6 | 7 | public var object: [KeywordIdentifier: JSONValue]? { 8 | switch self { 9 | case .boolean: return nil 10 | case .object(let dict): return dict 11 | } 12 | } 13 | 14 | public var value: JSONValue { 15 | switch self { 16 | case .boolean(let bool): 17 | return .boolean(bool) 18 | case .object(let dict): 19 | return .object(dict) 20 | } 21 | } 22 | 23 | public subscript(key: KeywordIdentifier) -> JSONValue? { 24 | get { 25 | switch self { 26 | case .boolean: 27 | return nil 28 | case .object(let dict): 29 | return dict[key] 30 | } 31 | } 32 | set { 33 | switch self { 34 | case .boolean: 35 | self = .object([key: newValue!]) 36 | case .object(var dict): 37 | dict[key] = newValue 38 | self = .object(dict) 39 | } 40 | } 41 | } 42 | 43 | public mutating func merge(_ other: SchemaValue) { 44 | switch (self, other) { 45 | case (.boolean, .boolean): 46 | break 47 | case (.boolean, .object(let dict)): 48 | self = .object(dict) 49 | case (.object(let dict), .boolean): 50 | self = .object(dict) 51 | case (.object(var dict1), .object(let dict2)): 52 | for (key, value2) in dict2 { 53 | if let value1 = dict1[key] { 54 | // If both values are objects, merge recursively 55 | var merged = value1 56 | merged.merge(value2) 57 | dict1[key] = merged 58 | } else { 59 | dict1[key] = value2 60 | } 61 | } 62 | self = .object(dict1) 63 | } 64 | } 65 | } 66 | 67 | extension SchemaValue: Encodable { 68 | public func encode(to encoder: Encoder) throws { 69 | var container = encoder.singleValueContainer() 70 | switch self { 71 | case .boolean(let bool): 72 | try container.encode(bool) 73 | case .object(let dict): 74 | try container.encode(dict) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONProperty.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON property component for use in ``JSONPropertySchemaBuilder``. 4 | /// 5 | /// This component is used to represent a key-value pair in a JSON object. 6 | /// The key is a `String` and the value is a ``JSONSchemaComponent``. 7 | public struct JSONProperty: JSONPropertyComponent { 8 | public let key: String 9 | public let isRequired = false 10 | public let value: Value 11 | 12 | /// Creates a new JSON property component with a key and a value. 13 | /// The value is created with a builder closure and is used the validate the key-value pair. 14 | /// - Parameters: 15 | /// - key: The key for the property. 16 | /// - builder: The builder closure to create the schema component for the value. 17 | public init(key: String, @JSONSchemaBuilder builder: () -> Value) { 18 | self.key = key 19 | self.value = builder() 20 | } 21 | 22 | /// Creates a new JSON property component with a key and a value. 23 | /// The schema component for validating the key-value pair. 24 | /// - Parameters: 25 | /// - key: The key for the property. 26 | /// - value: The schema component for validating the value. 27 | public init(key: String, value: Value) { 28 | self.key = key 29 | self.value = value 30 | } 31 | 32 | /// Creates a new JSON property component with a key. It accepts any value type. 33 | public init(key: String) where Value == JSONAnyValue { 34 | self.key = key 35 | self.value = JSONAnyValue() 36 | } 37 | 38 | public func parse(_ input: [String: JSONValue]) -> Parsed { 39 | if let jsonValue = input[key] { return value.parse(jsonValue).map(Optional.some) } 40 | return .valid(nil) 41 | } 42 | 43 | /// By default, a property is not required and will validate as `.valid(nil)` if the key is not present in the input value. 44 | /// This method will mark the property as required and will validate as `.error` if the key is not present in the input value. 45 | public func required() -> JSONPropertyComponents.CompactMap { 46 | self.compactMap { $0 } // CompactMap type will also wrap property as `isRequired = true` 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/KeyEncodingStrategyTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | import JSONSchemaBuilder 4 | import Testing 5 | 6 | @Schemable 7 | struct SnakeCaseOverride { 8 | let displayName: String 9 | let postalCode: Int 10 | } 11 | 12 | extension SnakeCaseOverride { 13 | static var keyEncodingStrategy: KeyEncodingStrategies { .snakeCase } 14 | } 15 | 16 | struct KeyEncodingStrategyTests { 17 | @Test func identityStrategy() { 18 | let strategy = KeyEncodingStrategies.identity 19 | #expect(strategy.encode("helloWorld") == "helloWorld") 20 | #expect(strategy.encode("JSONSchema") == "JSONSchema") 21 | #expect(strategy.encode("userID") == "userID") 22 | } 23 | 24 | @Test func snakeCaseStrategy() { 25 | let strategy = KeyEncodingStrategies.snakeCase 26 | #expect(strategy.encode("helloWorld") == "hello_world") 27 | #expect(strategy.encode("userID") == "user_id") 28 | #expect(strategy.encode("JSONSchema") == "json_schema") 29 | #expect(strategy.encode("UrlRequest") == "url_request") 30 | } 31 | 32 | @Test func kebabCaseStrategy() { 33 | let strategy = KeyEncodingStrategies.kebabCase 34 | #expect(strategy.encode("helloWorld") == "hello-world") 35 | #expect(strategy.encode("JSONSchema") == "json-schema") 36 | #expect(strategy.encode("userID") == "user-id") 37 | #expect(strategy.encode("URLRequest") == "url-request") 38 | } 39 | 40 | @Test func customStrategy() { 41 | struct UppercaseStrategy: KeyEncodingStrategy { 42 | static func encode(_ key: String) -> String { 43 | key.uppercased() 44 | } 45 | } 46 | 47 | let strategy = KeyEncodingStrategies.custom(UppercaseStrategy.self) 48 | #expect(strategy.encode("helloWorld") == "HELLOWORLD") 49 | #expect(strategy.encode("JSONSchema") == "JSONSCHEMA") 50 | #expect(strategy.encode("userID") == "USERID") 51 | } 52 | 53 | @Test func schemableRespectsCustomKeyEncodingStrategy() throws { 54 | guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) else { return } 55 | 56 | let strategy = SnakeCaseOverride.keyEncodingStrategy 57 | #expect(strategy.encode("displayName") == "display_name") 58 | #expect(strategy.encode("postalCode") == "postal_code") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/AdditionalProperties.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// The result of validating additionalProperties against an input object. 4 | /// Stores all extra key-value pairs that were not handled by the base schema. 5 | public struct AdditionalPropertiesParseResult { 6 | public let matches: [String: AdditionalOut] 7 | } 8 | 9 | extension JSONComponents { 10 | /// A JSON schema component that augments a base schema with additionalProperties support. 11 | /// Any properties not consumed by the base schema are validated using a fallback subschema. 12 | public struct AdditionalProperties< 13 | Base: JSONSchemaComponent, 14 | AdditionalProps: JSONSchemaComponent 15 | >: JSONSchemaComponent { 16 | public var schemaValue: SchemaValue 17 | 18 | var base: Base 19 | let additionalPropertiesSchema: AdditionalProps 20 | 21 | public init(base: Base, additionalProperties: AdditionalProps) { 22 | self.base = base 23 | self.additionalPropertiesSchema = additionalProperties 24 | schemaValue = base.schemaValue 25 | schemaValue[Keywords.AdditionalProperties.name] = additionalProperties.schemaValue.value 26 | } 27 | 28 | public func parse( 29 | _ input: JSONValue 30 | ) -> Parsed<(Base.Output, AdditionalPropertiesParseResult), ParseIssue> 31 | { 32 | guard case .object(let dictionary) = input else { 33 | return .error(.typeMismatch(expected: .object, actual: input)) 34 | } 35 | 36 | // Validate the base properties 37 | let baseValidation = base.parse(input) 38 | 39 | // Validate the additional properties 40 | var additionalProperties: [String: AdditionalProps.Output] = [:] 41 | for (key, value) in dictionary where base.schemaValue.object?.keys.contains(key) == false { 42 | switch additionalPropertiesSchema.parse(value) { 43 | case .valid(let output): additionalProperties[key] = output 44 | case .invalid: continue 45 | } 46 | } 47 | 48 | // Combine the base properties and additional properties 49 | switch baseValidation { 50 | case .valid(let baseOutput): return .valid((baseOutput, .init(matches: additionalProperties))) 51 | case .invalid(let errors): return .invalid(errors) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/TypeSpecific/JSONString.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A JSON string schema component for use in ``JSONSchemaBuilder``. 4 | public struct JSONString: JSONSchemaComponent { 5 | public var schemaValue = SchemaValue.object([:]) 6 | 7 | public init() { 8 | schemaValue[Keywords.TypeKeyword.name] = .string(JSONType.string.rawValue) 9 | } 10 | 11 | public func parse(_ value: JSONValue) -> Parsed { 12 | if case .string(let string) = value { return .valid(string) } 13 | return .error(.typeMismatch(expected: .string, actual: value)) 14 | } 15 | } 16 | 17 | extension JSONString { 18 | /// Adds a minimum length constraint to the schema. 19 | /// - Parameter length: The minimum length that the string must be greater than or equal to. 20 | /// - Returns: A new `JSONString` with the minimum length constraint set. 21 | public func minLength(_ length: Int) -> Self { 22 | var copy = self 23 | copy.schemaValue[Keywords.MinLength.name] = .integer(length) 24 | return copy 25 | } 26 | 27 | /// Adds a maximum length constraint to the schema. 28 | /// - Parameter length: The maximum length that the string must be less than or equal to. 29 | /// - Returns: A new `JSONString` with the maximum length constraint set. 30 | public func maxLength(_ length: Int) -> Self { 31 | var copy = self 32 | copy.schemaValue[Keywords.MaxLength.name] = .integer(length) 33 | return copy 34 | } 35 | 36 | /// Adds a pattern constraint to the schema. 37 | /// - Parameter pattern: The regular expression pattern that the string must match. 38 | /// - Returns: A new `JSONString` with the pattern constraint set. 39 | public func pattern(_ pattern: String) -> Self { 40 | var copy = self 41 | copy.schemaValue[Keywords.Pattern.name] = .string(pattern) 42 | return copy 43 | } 44 | 45 | /// Adds constraint for basic semantic identification of certain kinds of string values that are commonly used. 46 | /// [JSON Schema Reference](https://json-schema.org/understanding-json-schema/reference/string#format) 47 | /// - Parameter format: The format that the string must adhere to. 48 | /// - Returns: A new `JSONString` with the format constraint set. 49 | public func format(_ format: String) -> Self { 50 | var copy = self 51 | copy.schemaValue[Keywords.Format.name] = .string(format) 52 | return copy 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/JSONConditionalTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct JSONConditionalBuilderTests { 7 | @Test func conditionalDSL() { 8 | @JSONSchemaBuilder var sample: some JSONSchemaComponent { 9 | If( 10 | { JSONString().minLength(1) }, 11 | then: { JSONString().pattern("^foo") }, 12 | else: { JSONString().pattern("^bar") } 13 | ) 14 | } 15 | 16 | let expected: [String: JSONValue] = [ 17 | "if": ["type": "string", "minLength": 1], 18 | "then": ["type": "string", "pattern": "^foo"], 19 | "else": ["type": "string", "pattern": "^bar"], 20 | ] 21 | 22 | #expect(sample.schemaValue == .object(expected)) 23 | } 24 | 25 | @Test func dependentRequired() { 26 | @JSONSchemaBuilder var sample: some JSONSchemaComponent { 27 | JSONObject { 28 | JSONProperty(key: "a") { JSONInteger() } 29 | JSONProperty(key: "b") { JSONInteger() } 30 | } 31 | .dependentRequired(["a": ["b"]]) 32 | } 33 | 34 | let expected: [String: JSONValue] = [ 35 | "type": "object", 36 | "properties": [ 37 | "a": ["type": "integer"], 38 | "b": ["type": "integer"], 39 | ], 40 | "dependentRequired": ["a": ["b"]], 41 | ] 42 | 43 | #expect(sample.schemaValue == .object(expected)) 44 | } 45 | 46 | @Test func dependentSchemas() { 47 | @JSONSchemaBuilder var sample: some JSONSchemaComponent { 48 | JSONObject { 49 | JSONProperty(key: "credit_card") { JSONInteger() } 50 | JSONProperty(key: "billing_address") { JSONString() } 51 | } 52 | .dependentSchemas([ 53 | "credit_card": JSONObject { 54 | JSONProperty(key: "billing_address") { JSONString() }.required() 55 | } 56 | ]) 57 | } 58 | 59 | let expected: [String: JSONValue] = [ 60 | "type": "object", 61 | "properties": [ 62 | "credit_card": ["type": "integer"], 63 | "billing_address": ["type": "string"], 64 | ], 65 | "dependentSchemas": [ 66 | "credit_card": [ 67 | "type": "object", 68 | "properties": [ 69 | "billing_address": ["type": "string"] 70 | ], 71 | "required": ["billing_address"], 72 | ] 73 | ], 74 | ] 75 | 76 | #expect(sample.schemaValue == .object(expected)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONReference.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// A typed wrapper around the ``$ref`` keyword that keeps validation strongly typed. 4 | /// 5 | /// Use this component when reusing an existing schema (local or remote) and you know the target 6 | /// type conforms to ``Schemable``. The component emits `$ref` in the JSON Schema document while 7 | /// delegating parsing back through `T.schema` so downstream code continues to work with the 8 | /// expected Swift value. 9 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 10 | public struct JSONReference: JSONSchemaComponent { 11 | public typealias Output = T 12 | 13 | public var schemaValue: SchemaValue 14 | private let uri: String 15 | 16 | /// Creates a reference to the provided URI (absolute or relative). 17 | /// 18 | /// - Parameter uri: The URI that will be stored in the generated `$ref` keyword. 19 | public init(uri: String) { 20 | self.uri = uri 21 | self.schemaValue = .object([Keywords.Reference.name: .string(uri)]) 22 | } 23 | 24 | /// Creates a reference using a prebuilt ``SchemaReferenceURI`` helper. 25 | public init(uri: SchemaReferenceURI) { 26 | self.init(uri: uri.rawValue) 27 | } 28 | 29 | /// References a schema stored under `#/$defs/` (or legacy `#/definitions/`). 30 | public static func definition( 31 | named name: String, 32 | location: SchemaReferenceURI.LocalLocation = .defs 33 | ) -> JSONReference { 34 | JSONReference(uri: SchemaReferenceURI.definition(named: name, location: location)) 35 | } 36 | 37 | /// References a schema inside the current document via a JSON Pointer. 38 | public static func documentPointer(_ pointer: JSONPointer) -> JSONReference { 39 | JSONReference(uri: SchemaReferenceURI.documentPointer(pointer)) 40 | } 41 | 42 | /// References a remote schema, optionally targeting a JSON Pointer inside it. 43 | public static func remote( 44 | _ uri: String, 45 | pointer: JSONPointer? = nil 46 | ) -> JSONReference { 47 | JSONReference(uri: SchemaReferenceURI.remote(uri, pointer: pointer)) 48 | } 49 | 50 | /// Parses the referenced schema by delegating back to `T.schema`. 51 | public func parse(_ value: JSONValue) -> Parsed { 52 | T.schema.parse(value) 53 | .flatMap { output in 54 | guard let typedOutput = output as? T else { 55 | return .invalid([.compactMapValueNil(value: value)]) 56 | } 57 | return .valid(typedOutput) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | pull_request_target: 7 | types: 8 | - labeled 9 | 10 | jobs: 11 | format: 12 | runs-on: macos-latest 13 | 14 | permissions: 15 | contents: write # Required for internal PRs to allow auto-commits 16 | 17 | name: Lint and format 18 | steps: 19 | - name: Checkout for internal PR 20 | if: ${{ github.event.pull_request.head.repo.owner.login == github.repository_owner }} 21 | uses: actions/checkout@v5 22 | with: 23 | # Use PAT which has write access 24 | token: ${{ secrets.PAT }} 25 | ref: ${{ github.head_ref }} 26 | 27 | - name: Checkout for external PR 28 | if: ${{ github.event.pull_request.head.repo.owner.login != github.repository_owner }} 29 | uses: actions/checkout@v5 30 | 31 | - name: Format 32 | run: swift format --in-place --parallel --recursive Sources/ Tests/ 33 | 34 | - name: Check for formatting changes 35 | id: check_changes 36 | run: | 37 | git diff --exit-code 38 | continue-on-error: true 39 | 40 | - name: Check if PR has a specific label 41 | id: check_label 42 | uses: actions/github-script@v8 43 | with: 44 | result-encoding: string 45 | script: | 46 | const labelToCheck = 'auto-format'; 47 | const prLabels = context.payload.pull_request.labels.map(label => label.name); 48 | return prLabels.includes(labelToCheck); 49 | 50 | - name: Apply auto-formatting 51 | if: > 52 | steps.check_changes.outcome == 'failure' && 53 | steps.check_label.outputs.result == 'true' && 54 | github.event.pull_request.head.repo.owner.login == github.repository_owner 55 | uses: stefanzweifel/git-auto-commit-action@v7 56 | with: 57 | commit_message: Apply auto-formatting 58 | 59 | - name: Fail if changes detected (optional) 60 | if: > 61 | steps.check_changes.outcome == 'failure' && 62 | ( 63 | github.event.pull_request.head.repo.owner.login != github.repository_owner || 64 | steps.check_label.outputs.result == 'false' 65 | ) 66 | run: | 67 | echo "::error::Formatting issues detected. Please fix them." 68 | echo "Run 'swift format --in-place --parallel --recursive Sources/ Tests/' locally to fix formatting." 69 | exit 1 70 | 71 | - name: Lint 72 | id: lint 73 | run: | 74 | swift format lint --strict --parallel --recursive Sources/ Tests/ 75 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/ConditionalTests.swift: -------------------------------------------------------------------------------- 1 | import InlineSnapshotTesting 2 | import JSONSchema 3 | import JSONSchemaBuilder 4 | import Testing 5 | 6 | struct CreditInfo: Equatable { 7 | let creditCard: Int? 8 | let billingAddress: String? 9 | 10 | static var schema: some JSONSchemaComponent { 11 | JSONSchema(CreditInfo.init) { 12 | JSONObject { 13 | JSONProperty(key: "credit_card") { JSONInteger() } 14 | JSONProperty(key: "billing_address") { JSONString() } 15 | } 16 | .dependentRequired(["credit_card": ["billing_address"]]) 17 | } 18 | } 19 | } 20 | 21 | struct NameConditional { 22 | static var schema: some JSONSchemaComponent { 23 | If( 24 | { JSONString().minLength(1) }, 25 | then: { JSONString().pattern("^foo") }, 26 | else: { JSONString().pattern("^bar") } 27 | ) 28 | } 29 | } 30 | 31 | struct ConditionalTests { 32 | @Test(.snapshots(record: false)) 33 | func schema() { 34 | let schema = CreditInfo.schema.schemaValue 35 | assertInlineSnapshot(of: schema, as: .json) { 36 | #""" 37 | { 38 | "dependentRequired" : { 39 | "credit_card" : [ 40 | "billing_address" 41 | ] 42 | }, 43 | "properties" : { 44 | "billing_address" : { 45 | "type" : "string" 46 | }, 47 | "credit_card" : { 48 | "type" : "integer" 49 | } 50 | }, 51 | "type" : "object" 52 | } 53 | """# 54 | } 55 | } 56 | 57 | @Test(.snapshots(record: false)) 58 | func dslSchema() { 59 | let schema = NameConditional.schema.schemaValue 60 | assertInlineSnapshot(of: schema, as: .json) { 61 | #""" 62 | { 63 | "else" : { 64 | "pattern" : "^bar", 65 | "type" : "string" 66 | }, 67 | "if" : { 68 | "minLength" : 1, 69 | "type" : "string" 70 | }, 71 | "then" : { 72 | "pattern" : "^foo", 73 | "type" : "string" 74 | } 75 | } 76 | """# 77 | } 78 | } 79 | 80 | @Test(arguments: [ 81 | ( 82 | JSONValue.object(["credit_card": .integer(1234), "billing_address": .string("123 St")]), true 83 | ), 84 | (JSONValue.object(["credit_card": .integer(1234)]), false), 85 | (JSONValue.object(["billing_address": .string("123 St")]), true), 86 | ]) 87 | func validate(instance: JSONValue, isValid: Bool) { 88 | let schema = CreditInfo.schema.definition() 89 | let result = schema.validate(instance) 90 | #expect(result.isValid == isValid) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentConditionalCompilationBlocks" : true, 6 | "indentSwitchCaseLabels" : false, 7 | "indentation" : { 8 | "spaces" : 2 9 | }, 10 | "lineBreakAroundMultilineExpressionChainComponents" : true, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : true, 13 | "lineBreakBeforeEachGenericRequirement" : true, 14 | "lineLength" : 100, 15 | "maximumBlankLines" : 1, 16 | "multiElementCollectionTrailingCommas" : true, 17 | "noAssignmentInExpressions" : { 18 | "allowedFunctions" : [] 19 | }, 20 | "prioritizeKeepingFunctionOutputTogether" : true, 21 | "respectsExistingLineBreaks" : true, 22 | "rules" : { 23 | "AllPublicDeclarationsHaveDocumentation" : false, 24 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 25 | "AlwaysUseLowerCamelCase" : true, 26 | "AmbiguousTrailingClosureOverload" : true, 27 | "BeginDocumentationCommentWithOneLineSummary" : false, 28 | "DoNotUseSemicolons" : true, 29 | "DontRepeatTypeInStaticProperties" : true, 30 | "FileScopedDeclarationPrivacy" : true, 31 | "FullyIndirectEnum" : true, 32 | "GroupNumericLiterals" : true, 33 | "IdentifiersMustBeASCII" : true, 34 | "NeverForceUnwrap" : false, 35 | "NeverUseForceTry" : false, 36 | "NeverUseImplicitlyUnwrappedOptionals" : false, 37 | "NoAccessLevelOnExtensionDeclaration" : true, 38 | "NoAssignmentInExpressions" : true, 39 | "NoBlockComments" : true, 40 | "NoCasesWithOnlyFallthrough" : true, 41 | "NoEmptyTrailingClosureParentheses" : true, 42 | "NoLabelsInCasePatterns" : true, 43 | "NoLeadingUnderscores" : true, 44 | "NoParensAroundConditions" : true, 45 | "NoPlaygroundLiterals" : true, 46 | "NoVoidReturnOnFunctionSignature" : true, 47 | "OmitExplicitReturns" : true, 48 | "OneCasePerLine" : false, 49 | "OneVariableDeclarationPerLine" : false, 50 | "OnlyOneTrailingClosureArgument" : true, 51 | "OrderedImports" : true, 52 | "ReplaceForEachWithForLoop" : true, 53 | "ReturnVoidInsteadOfEmptyTuple" : true, 54 | "TypeNamesShouldBeCapitalized" : true, 55 | "UseEarlyExits" : true, 56 | "UseLetInEveryBoundCaseVariable" : true, 57 | "UseShorthandTypeNames" : true, 58 | "UseSingleLinePropertyGetter" : true, 59 | "UseSynthesizedInitializer" : true, 60 | "UseTripleSlashForDocumentationComments" : true, 61 | "UseWhereClausesInForLoops" : true, 62 | "ValidateDocumentationComments" : true 63 | }, 64 | "spacesAroundRangeFormationOperators" : true, 65 | "tabWidth" : 8, 66 | "version" : 1 67 | } 68 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/schema", 4 | "$vocabulary": { 5 | "https://json-schema.org/draft/2020-12/vocab/core": true, 6 | "https://json-schema.org/draft/2020-12/vocab/applicator": true, 7 | "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, 8 | "https://json-schema.org/draft/2020-12/vocab/validation": true, 9 | "https://json-schema.org/draft/2020-12/vocab/meta-data": true, 10 | "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, 11 | "https://json-schema.org/draft/2020-12/vocab/content": true 12 | }, 13 | "$dynamicAnchor": "meta", 14 | 15 | "title": "Core and Validation specifications meta-schema", 16 | "allOf": [ 17 | {"$ref": "meta/core"}, 18 | {"$ref": "meta/applicator"}, 19 | {"$ref": "meta/unevaluated"}, 20 | {"$ref": "meta/validation"}, 21 | {"$ref": "meta/meta-data"}, 22 | {"$ref": "meta/format-annotation"}, 23 | {"$ref": "meta/content"} 24 | ], 25 | "type": ["object", "boolean"], 26 | "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", 27 | "properties": { 28 | "definitions": { 29 | "$comment": "\"definitions\" has been replaced by \"$defs\".", 30 | "type": "object", 31 | "additionalProperties": { "$dynamicRef": "#meta" }, 32 | "deprecated": true, 33 | "default": {} 34 | }, 35 | "dependencies": { 36 | "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", 37 | "type": "object", 38 | "additionalProperties": { 39 | "anyOf": [ 40 | { "$dynamicRef": "#meta" }, 41 | { "$ref": "meta/validation#/$defs/stringArray" } 42 | ] 43 | }, 44 | "deprecated": true, 45 | "default": {} 46 | }, 47 | "$recursiveAnchor": { 48 | "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", 49 | "$ref": "meta/core#/$defs/anchorString", 50 | "deprecated": true 51 | }, 52 | "$recursiveRef": { 53 | "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", 54 | "$ref": "meta/core#/$defs/uriReferenceString", 55 | "deprecated": true 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/JSONSchemaConversion/DateConversion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchemaBuilder 3 | 4 | /// A conversion for JSON Schema `date-time` format to `Foundation.Date`. 5 | /// 6 | /// Accepts strings in ISO 8601 date-time format, e.g. "2023-05-01T12:34:56.789Z". 7 | /// Returns a `Date` if parsing succeeds, otherwise fails validation. 8 | public struct DateTimeConversion: Schemable { 9 | public static var schema: some JSONSchemaComponent { 10 | JSONString() 11 | .format("date-time") 12 | .compactMap { value in 13 | let formatter = ISO8601DateFormatter() 14 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 15 | return formatter.date(from: value) 16 | } 17 | } 18 | } 19 | 20 | /// A conversion for JSON Schema `date` format to `Foundation.Date`. 21 | /// 22 | /// Accepts strings in the format "yyyy-MM-dd" (e.g. "2023-05-01"). 23 | /// Returns a `Date` if parsing succeeds, otherwise fails validation. 24 | public struct DateConversion: Schemable { 25 | public static var schema: some JSONSchemaComponent { 26 | JSONString() 27 | .format("date") 28 | .compactMap { value in 29 | let formatter = DateFormatter() 30 | formatter.locale = Locale(identifier: "en_US_POSIX") 31 | formatter.dateFormat = "yyyy-MM-dd" 32 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 33 | return formatter.date(from: value) 34 | } 35 | } 36 | } 37 | 38 | /// A conversion for JSON Schema `time` format to `Foundation.DateComponents`. 39 | /// 40 | /// Accepts strings in the format "hh:mm:ss(.sss)?(Z|+hh:mm)?" (e.g. "12:34:56.789Z"). 41 | /// Returns `DateComponents` if parsing succeeds, otherwise fails validation. 42 | public struct TimeConversion: Schemable { 43 | public static var schema: some JSONSchemaComponent { 44 | JSONString() 45 | .format("time") 46 | .compactMap { value -> DateComponents? in 47 | // Basic ISO8601 time parsing (hh:mm:ss(.sss)?(Z|+hh:mm)?) 48 | let isoFormatter = ISO8601DateFormatter() 49 | isoFormatter.formatOptions = [ 50 | .withTime, .withColonSeparatorInTime, .withFractionalSeconds, .withTimeZone, 51 | ] 52 | if let date = isoFormatter.date(from: value) { 53 | var calendar = Calendar(identifier: .gregorian) 54 | guard let timeZone = TimeZone(secondsFromGMT: 0) else { 55 | return nil 56 | } 57 | calendar.timeZone = timeZone 58 | return calendar.dateComponents( 59 | [.hour, .minute, .second, .nanosecond, .timeZone], 60 | from: date 61 | ) 62 | } 63 | return nil 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/SchemaCodableIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | import JSONSchemaBuilder 4 | import JSONSchemaConversion 5 | import Testing 6 | 7 | struct SchemaCodableIntegrationTests { 8 | @Test 9 | func decodeBooleanSchema() throws { 10 | let json = "true" 11 | let decoder = JSONDecoder() 12 | let schema = try decoder.decode(Schema.self, from: Data(json.utf8)) 13 | 14 | let expected = try Schema(rawSchema: .boolean(true), context: Context(dialect: .draft2020_12)) 15 | #expect(schema == expected) 16 | 17 | } 18 | 19 | @Test 20 | func decodeObjectSchema() throws { 21 | let json = """ 22 | { 23 | "type": "object", 24 | "properties": { 25 | "name": { "type": "string" }, 26 | "age": { "type": "integer", "minimum": 0 } 27 | }, 28 | "required": ["name"] 29 | } 30 | """ 31 | let decoder = JSONDecoder() 32 | let schema = try decoder.decode(Schema.self, from: Data(json.utf8)) 33 | 34 | let expected: JSONValue = [ 35 | "type": "object", 36 | "properties": [ 37 | "name": ["type": "string"], 38 | "age": ["type": "integer", "minimum": 0], 39 | ], 40 | "required": ["name"], 41 | ] 42 | let expectedSchema = try Schema(rawSchema: expected, context: Context(dialect: .draft2020_12)) 43 | #expect(schema == expectedSchema) 44 | 45 | } 46 | } 47 | 48 | struct IPAddress: Schemable { 49 | static var schema: some JSONSchemaComponent { 50 | JSONString() 51 | .format("ipv4") 52 | } 53 | } 54 | 55 | @Schemable 56 | struct User { 57 | @SchemaOptions(.customSchema(Conversions.uuid)) 58 | let id: UUID 59 | 60 | @SchemaOptions(.customSchema(Conversions.dateTime)) 61 | let createdAt: Date 62 | 63 | @SchemaOptions(.customSchema(Conversions.url)) 64 | let website: URL 65 | 66 | @SchemaOptions(.customSchema(IPAddress.self)) 67 | let ipAddress: String 68 | } 69 | 70 | struct CustomSchemaIntegrationTests { 71 | @Test func parseValidInstance() throws { 72 | let json = """ 73 | {"id":"123e4567-e89b-12d3-a456-426614174000","createdAt":"2025-06-27T12:34:56.789Z","website":"https://example.com","ipAddress":"192.168.0.1"} 74 | """ 75 | let result = try User.schema.parse(instance: json) 76 | #expect(result.value != nil) 77 | #expect(result.errors == nil) 78 | } 79 | 80 | @Test func parseInvalidInstance() throws { 81 | let json = """ 82 | {"id":"not-a-uuid","createdAt":"not-a-date","website":"not-a-url","ipAddress":"256.256.256.256"} 83 | """ 84 | let result = try User.schema.parse(instance: json) 85 | #expect(result.value == nil) 86 | #expect(result.errors != nil) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/JSONSchemaMacro/Schemable/SchemaOptionsGenerator.swift: -------------------------------------------------------------------------------- 1 | import SwiftSyntax 2 | 3 | enum SchemaOptionsGenerator { 4 | static func apply( 5 | _ arguments: LabeledExprListSyntax, 6 | to codeBlockItem: CodeBlockItemSyntax, 7 | for type: String 8 | ) -> CodeBlockItemSyntax { 9 | var result = codeBlockItem 10 | 11 | for argument in arguments { 12 | result = applyOption(argument, to: result) 13 | } 14 | 15 | return result 16 | } 17 | 18 | private static func applyOption( 19 | _ argument: LabeledExprSyntax, 20 | to codeBlockItem: CodeBlockItemSyntax 21 | ) -> CodeBlockItemSyntax { 22 | guard let functionCall = argument.expression.as(FunctionCallExprSyntax.self), 23 | let memberAccess = functionCall.calledExpression.as(MemberAccessExprSyntax.self) 24 | else { 25 | return codeBlockItem 26 | } 27 | 28 | let optionName = memberAccess.declName.baseName.text 29 | 30 | if optionName == "key" { 31 | return codeBlockItem 32 | } 33 | 34 | // Handle customSchema specially - it replaces the entire schema 35 | if optionName == "customSchema" { 36 | guard let value = functionCall.arguments.first else { 37 | return codeBlockItem 38 | } 39 | 40 | return "\(value).schema" 41 | } 42 | 43 | if let closure = functionCall.trailingClosure { 44 | return applyClosureBasedOption(optionName, closure: closure, to: codeBlockItem) 45 | } else if let value = functionCall.arguments.first { 46 | return """ 47 | \(codeBlockItem) 48 | .\(raw: optionName)(\(value)) 49 | """ 50 | } 51 | 52 | return codeBlockItem 53 | } 54 | 55 | private static func applyClosureBasedOption( 56 | _ optionName: String, 57 | closure: ClosureExprSyntax, 58 | to codeBlockItem: CodeBlockItemSyntax 59 | ) -> CodeBlockItemSyntax { 60 | switch optionName { 61 | case "additionalProperties": 62 | if closure.statements.count == 1, 63 | let first = closure.statements.first, 64 | let expr = first.item.as(ExprSyntax.self), 65 | let bool = expr.as(BooleanLiteralExprSyntax.self) 66 | { 67 | return """ 68 | \(codeBlockItem) 69 | .additionalProperties(\(raw: bool.literal.text)) 70 | """ 71 | } 72 | // Intentionally fall through to "patternProperties" to handle shared logic. 73 | fallthrough 74 | case "patternProperties", "propertyNames": 75 | return """ 76 | \(codeBlockItem) 77 | .\(raw: optionName) { \(closure.statements) } 78 | // Drop the parse information. Use custom builder if needed. 79 | .map { $0.0 } 80 | """ 81 | default: 82 | return """ 83 | \(codeBlockItem) 84 | .\(raw: optionName) { \(closure.statements) } 85 | """ 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/JSONSchemaTests/JSONValueTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | import Testing 4 | 5 | struct JSONValueTests { 6 | @Test func decodeNil() throws { 7 | let jsonString = """ 8 | { 9 | "const": null 10 | } 11 | """ 12 | let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonString.data(using: .utf8)!) 13 | #expect(jsonValue == ["const": .null]) 14 | } 15 | 16 | @Test 17 | func mergeObjects() { 18 | var a: JSONValue = .object([ 19 | "type": .string("object"), 20 | "properties": .object([ 21 | "to": .object(["type": .string("string")]) 22 | ]), 23 | ]) 24 | 25 | let b: JSONValue = .object([ 26 | "properties": .object([ 27 | "from": .object(["type": .string("string")]) 28 | ]) 29 | ]) 30 | 31 | a.merge(b) 32 | 33 | #expect(a.object?["type"] == .string("object")) 34 | #expect(a.object?["properties"]?.object?.count == 2) 35 | #expect(a.object?["properties"]?.object?["to"] != nil) 36 | #expect(a.object?["properties"]?.object?["from"] != nil) 37 | } 38 | 39 | @Test 40 | func mergeArrays() { 41 | var a: JSONValue = .array([.string("a")]) 42 | let b: JSONValue = .array([.string("b"), .string("c")]) 43 | 44 | a.merge(b) 45 | 46 | #expect(a == .array([.string("a"), .string("b"), .string("c")])) 47 | } 48 | 49 | @Test 50 | func mergeScalarPreserve() { 51 | var a: JSONValue = .string("keep me") 52 | let b: JSONValue = .string("overwrite me") 53 | 54 | a.merge(b) 55 | 56 | #expect(a == .string("keep me")) // scalar is preserved 57 | } 58 | 59 | @Test 60 | func mergeNullGetsOverwritten() { 61 | var a: JSONValue = .null 62 | let b: JSONValue = .boolean(true) 63 | 64 | a.merge(b) 65 | 66 | #expect(a == .boolean(true)) 67 | } 68 | 69 | @Test 70 | func deeplyNestedMerge() { 71 | var a: JSONValue = .object([ 72 | "outer": .object([ 73 | "inner": .object([ 74 | "a": .string("a") 75 | ]) 76 | ]) 77 | ]) 78 | 79 | let b: JSONValue = .object([ 80 | "outer": .object([ 81 | "inner": .object([ 82 | "b": .string("b") 83 | ]) 84 | ]) 85 | ]) 86 | 87 | a.merge(b) 88 | 89 | let inner = a.object?["outer"]?.object?["inner"]?.object 90 | #expect(inner?["a"] == .string("a")) 91 | #expect(inner?["b"] == .string("b")) 92 | } 93 | 94 | @Test 95 | func stringEqualityIsScalarExact() { 96 | let composed: JSONValue = .string("ä") // U+00E4 97 | let decomposed: JSONValue = .string("a\u{0308}") // U+0061 U+0308 98 | 99 | #expect(composed != decomposed) 100 | #expect(JSONValue.string("hello") == JSONValue.string("hello")) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/PatternProperties.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// The result of validating `patternProperties` against an input object. 4 | public struct PatternPropertiesParseResult { 5 | public typealias MatchingKey = String 6 | 7 | public struct Match { 8 | /// The instance value for the match. 9 | public let value: PatternOut 10 | /// The regex that caused the match, from the schema. 11 | public let regex: String 12 | } 13 | 14 | /// The key is the instance string that matches the regex. 15 | public let matches: [MatchingKey: Match] 16 | } 17 | 18 | extension JSONComponents { 19 | /// A JSON schema component that augments a base schema with patternProperties support. 20 | /// Each key in the input object is tested against the provided regex patterns, 21 | /// and matched values are validated using the associated subschemas. 22 | public struct PatternProperties< 23 | Base: JSONSchemaComponent, 24 | PatternProps: PropertyCollection 25 | >: JSONSchemaComponent { 26 | public var schemaValue: SchemaValue 27 | 28 | var base: Base 29 | let patternPropertiesSchema: PatternProps 30 | 31 | public init(base: Base, patternPropertiesSchema: PatternProps) { 32 | self.base = base 33 | self.patternPropertiesSchema = patternPropertiesSchema 34 | schemaValue = base.schemaValue 35 | schemaValue[Keywords.PatternProperties.name] = patternPropertiesSchema.schemaValue.value 36 | } 37 | 38 | public func parse( 39 | _ input: JSONValue 40 | ) -> Parsed<(Base.Output, PatternPropertiesParseResult), ParseIssue> { 41 | guard case .object(let dict) = input else { 42 | return .error(.typeMismatch(expected: .object, actual: input)) 43 | } 44 | 45 | let baseResult = base.parse(input) 46 | 47 | var matches = [String: PatternPropertiesParseResult.Match]() 48 | for (patternString, _) in patternPropertiesSchema.schemaValue.object ?? [:] { 49 | let regex: Regex 50 | do { 51 | regex = try Regex(patternString) 52 | } catch { 53 | // Skip invalid regex patterns 54 | continue 55 | } 56 | for (key, value) in dict where key.firstMatch(of: regex) != nil { 57 | let singleKeyDict = [patternString: value] 58 | switch patternPropertiesSchema.validate(singleKeyDict) { 59 | case .valid(let out): 60 | matches[key] = .init(value: out, regex: patternString) 61 | case .invalid: 62 | continue 63 | } 64 | } 65 | } 66 | 67 | switch baseResult { 68 | case .valid(let baseOut): 69 | return .valid((baseOut, .init(matches: matches))) 70 | case .invalid(let errs): 71 | return .invalid(errs) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/Modifier/PropertyNames.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | /// Captures property names encountered during validation. 4 | public struct CapturedPropertyNames { 5 | /// Property names that were validated successfully, in encounter order. 6 | public var seen: [Name] 7 | /// The original string keys from the instance. 8 | public var raw: [String] 9 | 10 | public init(seen: [Name] = [], raw: [String] = []) { 11 | self.seen = seen 12 | self.raw = raw 13 | } 14 | } 15 | 16 | extension JSONComponents { 17 | /// A JSON schema component that augments a base schema with `propertyNames` support 18 | /// and captures property names that successfully validate. 19 | public struct PropertyNames< 20 | Base: JSONSchemaComponent, 21 | Names: JSONSchemaComponent 22 | >: JSONSchemaComponent where Names.Output: Hashable { 23 | public var schemaValue: SchemaValue 24 | 25 | var base: Base 26 | let propertyNamesSchema: Names 27 | 28 | public init(base: Base, propertyNamesSchema: Names) { 29 | self.base = base 30 | self.propertyNamesSchema = propertyNamesSchema 31 | schemaValue = base.schemaValue 32 | schemaValue[Keywords.PropertyNames.name] = propertyNamesSchema.schemaValue.value 33 | } 34 | 35 | public func parse( 36 | _ input: JSONValue 37 | ) -> Parsed<(Base.Output, CapturedPropertyNames), ParseIssue> { 38 | guard case .object(let dict) = input else { 39 | return .error(.typeMismatch(expected: .object, actual: input)) 40 | } 41 | 42 | let baseResult = base.parse(input) 43 | 44 | var seen: [Names.Output] = [] 45 | var raw: [String] = [] 46 | for (key, _) in dict { 47 | switch propertyNamesSchema.parse(.string(key)) { 48 | case .valid(let name): 49 | seen.append(name) 50 | raw.append(key) 51 | case .invalid: 52 | continue 53 | } 54 | } 55 | 56 | let capture = CapturedPropertyNames(seen: seen, raw: raw) 57 | 58 | switch baseResult { 59 | case .valid(let baseOut): 60 | return .valid((baseOut, capture)) 61 | case .invalid(let errs): 62 | return .invalid(errs) 63 | } 64 | } 65 | } 66 | } 67 | 68 | extension JSONSchemaComponent { 69 | /// Adds property name validation to the schema and captures any names that 70 | /// successfully validate. 71 | /// 72 | /// - Parameter content: A string schema component used to validate property names. 73 | /// - Returns: A new component that validates and captures property names. 74 | public func propertyNames( 75 | @JSONSchemaBuilder _ content: () -> C 76 | ) -> JSONComponents.PropertyNames where C.Output: Hashable { 77 | JSONComponents.PropertyNames( 78 | base: self, 79 | propertyNamesSchema: content() 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/JSONSchemaTests/FormatValidatorTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import JSONSchema 5 | 6 | struct FormatValidatorTests { 7 | @Test(arguments: [ 8 | ("2024-01-01T12:00:00.000Z", true), 9 | ("2024-01-01T12:00:00Z", false), 10 | ]) 11 | func dateTimeValidator(value: String, isValid: Bool) { 12 | let validator = DateTimeFormatValidator() 13 | #expect(validator.validate(value) == isValid) 14 | } 15 | 16 | @Test(arguments: [ 17 | ("2024-01-01", true), 18 | ("01-01-2024", false), 19 | ]) 20 | func dateValidator(value: String, isValid: Bool) { 21 | let validator = DateFormatValidator() 22 | #expect(validator.validate(value) == isValid) 23 | } 24 | 25 | @Test(arguments: [ 26 | ("12:34:56Z", true), 27 | ("12:34", false), 28 | ]) 29 | func timeValidator(value: String, isValid: Bool) { 30 | let validator = TimeFormatValidator() 31 | #expect(validator.validate(value) == isValid) 32 | } 33 | 34 | @Test(arguments: [ 35 | ("test@example.com", true), 36 | ("invalid", false), 37 | ]) 38 | func emailValidator(value: String, isValid: Bool) { 39 | let validator = EmailFormatValidator() 40 | #expect(validator.validate(value) == isValid) 41 | } 42 | 43 | @Test(arguments: [ 44 | ("example.com", true), 45 | ("-invalid", false), 46 | ]) 47 | func hostnameValidator(value: String, isValid: Bool) { 48 | let validator = HostnameFormatValidator() 49 | #expect(validator.validate(value) == isValid) 50 | } 51 | 52 | @Test(arguments: [ 53 | ("192.168.0.1", true), 54 | ("256.256.256.256", false), 55 | ]) 56 | func ipv4Validator(value: String, isValid: Bool) { 57 | let validator = IPv4FormatValidator() 58 | #expect(validator.validate(value) == isValid) 59 | } 60 | 61 | @Test(arguments: [ 62 | ("2001:0db8:85a3:0000:0000:8a2e:0370:7334", true), 63 | ("2001:db8::1", false), 64 | ]) 65 | func ipv6Validator(value: String, isValid: Bool) { 66 | let validator = IPv6FormatValidator() 67 | #expect(validator.validate(value) == isValid) 68 | } 69 | 70 | @Test(arguments: [ 71 | ("00000000-0000-0000-0000-000000000000", true), 72 | ("not-a-uuid", false), 73 | ]) 74 | func uuidValidator(value: String, isValid: Bool) { 75 | let validator = UUIDFormatValidator() 76 | #expect(validator.validate(value) == isValid) 77 | } 78 | 79 | @Test(arguments: [ 80 | ("https://example.com", true), 81 | ("ht!tp://example.com", false), 82 | ]) 83 | func uriValidator(value: String, isValid: Bool) { 84 | let validator = URIFormatValidator() 85 | #expect(validator.validate(value) == isValid) 86 | } 87 | 88 | @Test(arguments: [ 89 | ("foo/bar", true), 90 | ("ht!tp://example.com", false), 91 | ]) 92 | func uriReferenceValidator(value: String, isValid: Bool) { 93 | let validator = URIReferenceFormatValidator() 94 | #expect(validator.validate(value) == isValid) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Tests/JSONSchemaMacroTests/BacktickEnumTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchemaMacro 2 | import SwiftSyntaxMacros 3 | import Testing 4 | 5 | @Suite struct BacktickEnumTests { 6 | let testMacros: [String: Macro.Type] = ["Schemable": SchemableMacro.self] 7 | 8 | @Test func backtickCasesWithoutRawValues() { 9 | assertMacroExpansion( 10 | """ 11 | @Schemable 12 | enum Keywords { 13 | case `default` 14 | case `public` 15 | case normal 16 | } 17 | """, 18 | expandedSource: """ 19 | enum Keywords { 20 | case `default` 21 | case `public` 22 | case normal 23 | 24 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 25 | static var schema: some JSONSchemaComponent { 26 | JSONString() 27 | .enumValues { 28 | "default" 29 | "public" 30 | "normal" 31 | } 32 | .compactMap { 33 | switch $0 { 34 | case "default": 35 | return Self.`default` 36 | case "public": 37 | return Self.`public` 38 | case "normal": 39 | return Self.normal 40 | default: 41 | return nil 42 | } 43 | } 44 | } 45 | } 46 | 47 | extension Keywords: Schemable { 48 | } 49 | """, 50 | macros: testMacros 51 | ) 52 | } 53 | 54 | @Test func backtickCasesWithRawValues() { 55 | assertMacroExpansion( 56 | """ 57 | @Schemable 58 | enum Keywords: String { 59 | case `default` = "default_value" 60 | case `public` 61 | case normal 62 | } 63 | """, 64 | expandedSource: """ 65 | enum Keywords: String { 66 | case `default` = "default_value" 67 | case `public` 68 | case normal 69 | 70 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 71 | static var schema: some JSONSchemaComponent { 72 | JSONString() 73 | .enumValues { 74 | "default_value" 75 | "public" 76 | "normal" 77 | } 78 | .compactMap { 79 | switch $0 { 80 | case "default_value": 81 | return Self.`default` 82 | case "public": 83 | return Self.`public` 84 | case "normal": 85 | return Self.normal 86 | default: 87 | return nil 88 | } 89 | } 90 | } 91 | } 92 | 93 | extension Keywords: Schemable { 94 | } 95 | """, 96 | macros: testMacros 97 | ) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/Parsing/Parsed.swift: -------------------------------------------------------------------------------- 1 | /// Similar to `Result` from Swift but `invalid` case has an array of errors. 2 | /// Adapted from [Point-Free](https://github.com/pointfreeco/swift-validated) to use variadic parameters. 3 | public enum Parsed { 4 | case valid(Value) 5 | case invalid([Error]) 6 | 7 | static func error(_ error: Error) -> Self { .invalid([error]) } 8 | 9 | public func map( 10 | _ transform: (Value) -> ValueOfResult 11 | ) -> Parsed { 12 | switch self { 13 | case .valid(let value): return .valid(transform(value)) 14 | case .invalid(let errors): return .invalid(errors) 15 | } 16 | } 17 | 18 | public func flatMap( 19 | _ transform: (Value) -> Parsed 20 | ) -> Parsed { 21 | switch self { 22 | case .valid(let value): return transform(value) 23 | case .invalid(let errors): return .invalid(errors) 24 | } 25 | } 26 | 27 | public var value: Value? { 28 | switch self { 29 | case .valid(let value): return value 30 | case .invalid: return nil 31 | } 32 | } 33 | 34 | public var errors: [Error]? { 35 | switch self { 36 | case .valid: return nil 37 | case .invalid(let array): return array 38 | } 39 | } 40 | } 41 | 42 | extension Parsed: Equatable where Value: Equatable, Error: Equatable {} 43 | 44 | struct Invalid: Error {} 45 | 46 | /// Combine values of Validated together into a tuple. 47 | /// Example: 48 | /// `zip(Parsed, Parsed, Parsed)` -> `Parsed<(A, B, C), E>` 49 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 50 | public func zip( 51 | _ validated: repeat Parsed 52 | ) -> Parsed<(repeat each Value), Error> { 53 | func valid(_ v: Parsed) throws -> V { 54 | switch v { 55 | case .valid(let x): return x 56 | case .invalid: 57 | // Could stash errors here, but would be incomplete if invalid in 58 | // a middle variadic parameter. Instead fetch all errors in catch. 59 | throw Invalid() 60 | } 61 | } 62 | 63 | do { 64 | // Compiler crash in Swift 5.10 when simply `.valid((repeat try valid(each validated)))` 65 | let tuple = (repeat try valid(each validated)) 66 | return .valid(tuple) 67 | } catch { 68 | var errors: [Error] = [] 69 | 70 | #if swift(>=6) 71 | for validated in repeat each validated { 72 | switch validated { 73 | case .valid: continue 74 | case .invalid(let array): errors.append(contentsOf: array) 75 | } 76 | } 77 | #else 78 | func collectErrors(_ v: Parsed) { 79 | switch v { 80 | case .valid: break 81 | case .invalid(let array): errors.append(contentsOf: array) 82 | } 83 | } 84 | repeat collectErrors(each validated) 85 | #endif 86 | 87 | return .invalid(errors) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Keywords/Keywords.swift: -------------------------------------------------------------------------------- 1 | /// Namespace for `Keyword` definitions. 2 | package enum Keywords { 3 | /// https://json-schema.org/draft/2020-12/json-schema-core#name-the-schema-keyword 4 | package struct SchemaKeyword: CoreKeyword { 5 | package static let name = "$schema" 6 | 7 | package let value: JSONValue 8 | package let context: KeywordContext 9 | 10 | package init(value: JSONValue, context: KeywordContext) { 11 | self.value = value 12 | self.context = context 13 | } 14 | 15 | func processIdentifier(into context: inout Context) { context.dialect = .draft2020_12 } 16 | 17 | func resolvedVocabularies() -> Set? { 18 | guard let schemaURI = value.string else { return nil } 19 | if let rawSchema = context.context.remoteSchemaStorage[schemaURI], 20 | let vocabObject = rawSchema.object?[Vocabulary.name]?.object 21 | { 22 | return Set(vocabObject.keys) 23 | } 24 | return nil 25 | } 26 | } 27 | 28 | /// https://json-schema.org/draft/2020-12/json-schema-core#name-the-vocabulary-keyword 29 | package struct Vocabulary: CoreKeyword { 30 | package static let name = "$vocabulary" 31 | 32 | package let value: JSONValue 33 | package let context: KeywordContext 34 | 35 | package init(value: JSONValue, context: KeywordContext) { 36 | self.value = value 37 | self.context = context 38 | } 39 | 40 | /// Validates that all required vocabularies are supported 41 | func validateVocabularies() throws(SchemaIssue) { 42 | guard let vocabObject = value.object else { 43 | throw .invalidVocabularyFormat 44 | } 45 | 46 | let supportedVocabularies = context.context.dialect.supportedVocabularies 47 | 48 | for (vocabularyURI, required) in vocabObject { 49 | guard let isRequired = required.boolean else { 50 | throw .invalidVocabularyFormat 51 | } 52 | 53 | if isRequired && !supportedVocabularies.contains(vocabularyURI) { 54 | throw .unsupportedRequiredVocabulary(vocabularyURI) 55 | } 56 | } 57 | } 58 | 59 | /// Returns the set of active vocabularies (those listed with any value, true or false) 60 | func getActiveVocabularies() -> Set? { 61 | guard let vocabObject = value.object else { 62 | return nil 63 | } 64 | 65 | var activeVocabularies = Set() 66 | for (vocabularyURI, _) in vocabObject { 67 | activeVocabularies.insert(vocabularyURI) 68 | } 69 | 70 | return activeVocabularies 71 | } 72 | } 73 | 74 | /// https://json-schema.org/draft/2020-12/json-schema-core#name-comments-with-comment 75 | package struct Comment: CoreKeyword { 76 | package static let name = "$comment" 77 | 78 | package let value: JSONValue 79 | package let context: KeywordContext 80 | 81 | package init(value: JSONValue, context: KeywordContext) { 82 | self.value = value 83 | self.context = context 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | 4 | /// A component for use in ``JSONSchemaBuilder`` to build, annotate, and validate schemas. 5 | public protocol JSONSchemaComponent { 6 | associatedtype Output 7 | 8 | var schemaValue: SchemaValue { get set } 9 | 10 | /// Parse a JSON instance into a Swift type using the schema. 11 | /// - Parameter value: The value (aka instance or document) to validate. 12 | /// - Returns: A validated output or error messages. 13 | func parse(_ value: JSONValue) -> Parsed 14 | } 15 | 16 | extension JSONSchemaComponent { 17 | public func definition(context: Context = .init(dialect: .draft2020_12)) -> Schema { 18 | do { 19 | return try Schema( 20 | rawSchema: schemaValue.value, 21 | location: .init(), 22 | context: context 23 | ) 24 | } catch { 25 | // If schema construction fails (e.g., vocabulary issues), fall back to a false schema. 26 | // This conservative default maintains previous behavior. 27 | return BooleanSchema( 28 | schemaValue: false, 29 | location: .init(), 30 | context: context 31 | ) 32 | .asSchema() 33 | } 34 | } 35 | 36 | public func parse( 37 | instance: String, 38 | decoder: JSONDecoder = JSONDecoder() 39 | ) throws -> Parsed { 40 | let value = try decoder.decode(JSONValue.self, from: Data(instance.utf8)) 41 | return parse(value) 42 | } 43 | 44 | public func parseAndValidate( 45 | instance: String, 46 | decoder: JSONDecoder = JSONDecoder(), 47 | validationContext: Context = .init(dialect: .draft2020_12) 48 | ) throws(ParseAndValidateIssue) -> Output { 49 | let value: JSONValue 50 | do { 51 | value = try decoder.decode(JSONValue.self, from: Data(instance.utf8)) 52 | } catch { 53 | throw .decodingFailed(error) 54 | } 55 | return try parseAndValidate(value, validationContext: validationContext) 56 | } 57 | 58 | public func parseAndValidate( 59 | _ value: JSONValue, 60 | validationContext: Context = .init(dialect: .draft2020_12) 61 | ) throws(ParseAndValidateIssue) -> Output { 62 | let parsingResult = parse(value) 63 | let validationResult = definition(context: validationContext).validate(value) 64 | switch (parsingResult, validationResult.isValid) { 65 | case (.valid(let output), true): 66 | return output 67 | case (.valid, false): 68 | throw .validationFailed(validationResult) 69 | case (.invalid(let errors), false): 70 | throw .parsingAndValidationFailed(errors, validationResult) 71 | case (.invalid(let errors), true): 72 | // This case should really not be possible 73 | throw .parsingFailed(errors) 74 | } 75 | } 76 | } 77 | 78 | public enum ParseAndValidateIssue: Error { 79 | case decodingFailed(Error) 80 | case parsingFailed([ParseIssue]) 81 | case validationFailed(ValidationResult) 82 | case parsingAndValidationFailed([ParseIssue], ValidationResult) 83 | } 84 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/JSONReferenceComponentTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct JSONReferenceComponentTests { 7 | private struct TestNode: Schemable, Equatable { 8 | let name: String 9 | 10 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 11 | static var schema: some JSONSchemaComponent { 12 | JSONSchema(TestNode.init) { 13 | JSONObject { 14 | JSONProperty(key: "name") { 15 | JSONString() 16 | } 17 | .required() 18 | } 19 | } 20 | } 21 | } 22 | 23 | @JSONSchemaBuilder 24 | private static func referenceHostSchema() -> some JSONSchemaComponent { 25 | JSONObject { 26 | JSONProperty(key: "node") { 27 | JSONReference.definition(named: "TestNode") 28 | } 29 | .required() 30 | } 31 | } 32 | 33 | @Test func referenceEmitsKeywordAndParses() throws { 34 | let reference = JSONReference.definition(named: "TestNode", location: .definitions) 35 | #expect(reference.schemaValue == .object(["$ref": "#/definitions/TestNode"])) 36 | 37 | let value: JSONValue = ["name": "leaf"] 38 | let parsed = reference.parse(value) 39 | 40 | #expect(parsed.value == TestNode(name: "leaf")) 41 | } 42 | 43 | @Test func remoteReferenceConvenienceBuildsURI() throws { 44 | let url = "https://example.com/schemas/tree.json" 45 | let pointer = JSONPointer(tokens: ["$defs", "Tree"]) 46 | let reference = JSONReference.remote(url, pointer: pointer) 47 | #expect( 48 | reference.schemaValue 49 | == .object([ 50 | "$ref": .string("https://example.com/schemas/tree.json#/$defs/Tree") 51 | ]) 52 | ) 53 | } 54 | 55 | @Test func documentPointerConvenienceEscapesTokens() throws { 56 | let pointer = JSONPointer(tokens: ["properties", "foo/bar"]) 57 | let reference = JSONReference.documentPointer(pointer) 58 | #expect(reference.schemaValue == .object(["$ref": "#/properties/foo~1bar"])) 59 | } 60 | 61 | @Test func referenceValidationUsingSchema() throws { 62 | var component = Self.referenceHostSchema() 63 | component.schemaValue["$defs"] = .object([ 64 | "TestNode": TestNode.schema.schemaValue.value 65 | ]) 66 | let schema = component.definition() 67 | 68 | let valid: JSONValue = ["node": ["name": "leaf"]] 69 | let invalid: JSONValue = ["node": ["name": 42]] 70 | 71 | #expect(schema.validate(valid).isValid) 72 | #expect(schema.validate(invalid).isValid == false) 73 | } 74 | 75 | @Test func dynamicReferenceEmitsKeywordAndParses() throws { 76 | let reference = JSONDynamicReference() 77 | #expect( 78 | reference.schemaValue 79 | == .object([ 80 | "$dynamicRef": .string("#\(TestNode.defaultAnchor)") 81 | ]) 82 | ) 83 | 84 | let value: JSONValue = ["name": "branch"] 85 | let parsed = reference.parse(value) 86 | 87 | #expect(parsed.value == TestNode(name: "branch")) 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Tests/JSONSchemaBuilderTests/WrapperTests.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | import Testing 3 | 4 | @testable import JSONSchemaBuilder 5 | 6 | struct WrapperTests { 7 | @Test func optionalComponent() { 8 | let opt = JSONComponents.OptionalComponent(wrapped: nil) 9 | let result = opt.parse(.string("hi")) 10 | switch result { 11 | case .valid(let value): 12 | #expect(value == nil) 13 | default: 14 | Issue.record("expected valid result") 15 | } 16 | } 17 | 18 | @Test func erasedComponent() { 19 | let any = JSONString().eraseToAnySchemaComponent() 20 | #expect(any.schemaValue == JSONString().schemaValue) 21 | } 22 | 23 | @Test func passthrough() { 24 | let passthrough = JSONComponents.PassthroughComponent(wrapped: JSONString()) 25 | let result = passthrough.parse(.string("hi")) 26 | #expect(result.value == .string("hi")) 27 | } 28 | 29 | @Test func runtimeComponent() throws { 30 | let raw: JSONValue = ["type": "string"] 31 | let runtime = try RuntimeComponent(rawSchema: raw) 32 | #expect(runtime.parse(.string("abc")).value == .string("abc")) 33 | } 34 | 35 | @Test func anyValueInit() { 36 | let any = JSONAnyValue(JSONString()) 37 | #expect(any.schemaValue.object?[Keywords.TypeKeyword.name] == .string("string")) 38 | } 39 | 40 | @Test func jsonValueSchemaPassesThroughInput() { 41 | let schema = JSONValue.schema 42 | let input: JSONValue = [ 43 | "message": "hi", 44 | "numbers": [1, 2, 3], 45 | "nested": [ 46 | "flag": true 47 | ], 48 | ] 49 | 50 | #expect(schema.parse(input).value == input) 51 | } 52 | 53 | @Test func jsonValueInObjectSchema() { 54 | struct Update: Equatable { 55 | let path: String 56 | let value: JSONValue 57 | let meta: [String: JSONValue] 58 | } 59 | 60 | let schema = JSONSchema(Update.init) { 61 | JSONObject { 62 | JSONProperty(key: "path") { 63 | JSONString() 64 | } 65 | .required() 66 | JSONProperty(key: "value") { 67 | JSONValue.schema 68 | } 69 | .required() 70 | JSONProperty(key: "meta") { 71 | JSONObject() 72 | .additionalProperties { 73 | JSONValue.schema 74 | } 75 | .map(\.1) 76 | .map(\.matches) 77 | } 78 | .required() 79 | } 80 | } 81 | 82 | let input: JSONValue = [ 83 | "path": "config.value", 84 | "value": [ 85 | "operation": "replace", 86 | "data": ["count": 2], 87 | ], 88 | "meta": [ 89 | "attempts": 1, 90 | "confirmed": true, 91 | ], 92 | ] 93 | 94 | let expected = Update( 95 | path: "config.value", 96 | value: [ 97 | "operation": "replace", 98 | "data": ["count": 2], 99 | ], 100 | meta: [ 101 | "attempts": 1, 102 | "confirmed": true, 103 | ] 104 | ) 105 | 106 | #expect(schema.parse(input).value == expected) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Resources/draft2020-12/meta/validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://json-schema.org/draft/2020-12/meta/validation", 4 | "$dynamicAnchor": "meta", 5 | 6 | "title": "Validation vocabulary meta-schema", 7 | "type": ["object", "boolean"], 8 | "properties": { 9 | "type": { 10 | "anyOf": [ 11 | { "$ref": "#/$defs/simpleTypes" }, 12 | { 13 | "type": "array", 14 | "items": { "$ref": "#/$defs/simpleTypes" }, 15 | "minItems": 1, 16 | "uniqueItems": true 17 | } 18 | ] 19 | }, 20 | "const": true, 21 | "enum": { 22 | "type": "array", 23 | "items": true 24 | }, 25 | "multipleOf": { 26 | "type": "number", 27 | "exclusiveMinimum": 0 28 | }, 29 | "maximum": { 30 | "type": "number" 31 | }, 32 | "exclusiveMaximum": { 33 | "type": "number" 34 | }, 35 | "minimum": { 36 | "type": "number" 37 | }, 38 | "exclusiveMinimum": { 39 | "type": "number" 40 | }, 41 | "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, 42 | "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, 43 | "pattern": { 44 | "type": "string", 45 | "format": "regex" 46 | }, 47 | "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, 48 | "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, 49 | "uniqueItems": { 50 | "type": "boolean", 51 | "default": false 52 | }, 53 | "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, 54 | "minContains": { 55 | "$ref": "#/$defs/nonNegativeInteger", 56 | "default": 1 57 | }, 58 | "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, 59 | "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, 60 | "required": { "$ref": "#/$defs/stringArray" }, 61 | "dependentRequired": { 62 | "type": "object", 63 | "additionalProperties": { 64 | "$ref": "#/$defs/stringArray" 65 | } 66 | } 67 | }, 68 | "$defs": { 69 | "nonNegativeInteger": { 70 | "type": "integer", 71 | "minimum": 0 72 | }, 73 | "nonNegativeIntegerDefault0": { 74 | "$ref": "#/$defs/nonNegativeInteger", 75 | "default": 0 76 | }, 77 | "simpleTypes": { 78 | "enum": [ 79 | "array", 80 | "boolean", 81 | "integer", 82 | "null", 83 | "number", 84 | "object", 85 | "string" 86 | ] 87 | }, 88 | "stringArray": { 89 | "type": "array", 90 | "items": { "type": "string" }, 91 | "uniqueItems": true, 92 | "default": [] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Validation/ValidationResult.swift: -------------------------------------------------------------------------------- 1 | public struct ValidationResult: Sendable, Encodable, Equatable { 2 | public let isValid: Bool 3 | public let keywordLocation: JSONPointer 4 | public let instanceLocation: JSONPointer 5 | public let errors: [ValidationError]? 6 | public let annotations: [AnyAnnotation]? 7 | 8 | init( 9 | valid: Bool, 10 | keywordLocation: JSONPointer, 11 | instanceLocation: JSONPointer, 12 | errors: [ValidationError]? = nil, 13 | annotations: [AnyAnnotation]? = nil 14 | ) { 15 | self.isValid = valid 16 | self.keywordLocation = keywordLocation 17 | self.instanceLocation = instanceLocation 18 | self.errors = errors 19 | self.annotations = annotations 20 | } 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case isValid = "valid" 24 | case keywordLocation 25 | case instanceLocation 26 | case errors 27 | case annotations 28 | } 29 | 30 | public func encode(to encoder: any Encoder) throws { 31 | var container = encoder.container(keyedBy: CodingKeys.self) 32 | try container.encode(isValid, forKey: .isValid) 33 | try container.encode(instanceLocation, forKey: .instanceLocation) 34 | try container.encodeIfPresent(errors, forKey: .errors) 35 | try container.encodeIfPresent( 36 | annotations?.map { AnyAnnotationWrapper(annotation: $0) }, 37 | forKey: .annotations 38 | ) 39 | } 40 | 41 | public static func == (lhs: ValidationResult, rhs: ValidationResult) -> Bool { 42 | lhs.isValid == rhs.isValid 43 | && lhs.keywordLocation == rhs.keywordLocation 44 | && lhs.instanceLocation == rhs.instanceLocation 45 | && lhs.errors == rhs.errors 46 | } 47 | } 48 | 49 | public struct ValidationError: Sendable, Codable, Equatable { 50 | public let keyword: String 51 | public let message: String 52 | public let keywordLocation: JSONPointer 53 | public let instanceLocation: JSONPointer 54 | public let errors: [ValidationError]? // For nested errors 55 | 56 | init( 57 | keyword: String, 58 | message: String, 59 | keywordLocation: JSONPointer, 60 | instanceLocation: JSONPointer, 61 | errors: [ValidationError]? = nil 62 | ) { 63 | self.keyword = keyword 64 | self.message = message 65 | self.keywordLocation = keywordLocation 66 | self.instanceLocation = instanceLocation 67 | self.errors = errors 68 | } 69 | } 70 | 71 | public struct AnyAnnotationWrapper: Sendable, Encodable { 72 | let annotation: AnyAnnotation 73 | 74 | enum CodingKeys: String, CodingKey { 75 | case keywordLocation 76 | case absoluteKeywordLocation 77 | case instanceLocation 78 | case annotation 79 | } 80 | 81 | public func encode(to encoder: any Encoder) throws { 82 | var container = encoder.container(keyedBy: CodingKeys.self) 83 | try container.encode(annotation.schemaLocation, forKey: .keywordLocation) 84 | try container.encodeIfPresent( 85 | annotation.absoluteSchemaLocation, 86 | forKey: .absoluteKeywordLocation 87 | ) 88 | try container.encode(annotation.instanceLocation, forKey: .instanceLocation) 89 | try container.encode(annotation.jsonValue, forKey: .annotation) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | import JSONSchemaBuilder 4 | import Testing 5 | 6 | @Suite struct BacktickEnumIntegrationTests { 7 | 8 | @Schemable 9 | enum Keywords { 10 | case `default` 11 | case `public` 12 | case normal 13 | } 14 | 15 | @Schemable 16 | enum KeywordsWithRawValues: String { 17 | case `default` = "default_value" 18 | case `public` 19 | case normal 20 | } 21 | 22 | @Test 23 | func backtickCasesWithoutRawValuesSchema() throws { 24 | let schema = Keywords.schema.definition() 25 | let jsonData = try JSONEncoder().encode(schema) 26 | let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) 27 | 28 | // Should have enum values without backticks 29 | let enumValues = try #require(jsonValue["enum"] as? [String]) 30 | #expect(enumValues.contains("default")) 31 | #expect(enumValues.contains("public")) 32 | #expect(enumValues.contains("normal")) 33 | 34 | // Should NOT contain backticks 35 | #expect(!enumValues.contains("`default`")) 36 | #expect(!enumValues.contains("`public`")) 37 | } 38 | 39 | @Test 40 | func backtickCasesWithRawValuesSchema() throws { 41 | let schema = KeywordsWithRawValues.schema.definition() 42 | let jsonData = try JSONEncoder().encode(schema) 43 | let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) 44 | 45 | // Should have enum values using raw values 46 | let enumValues = try #require(jsonValue["enum"] as? [String]) 47 | #expect(enumValues.contains("default_value")) // Custom raw value 48 | #expect(enumValues.contains("public")) // Implicit raw value 49 | #expect(enumValues.contains("normal")) // Implicit raw value 50 | 51 | // Should NOT contain backticks or case names for custom raw value 52 | #expect(!enumValues.contains("`default`")) 53 | #expect(!enumValues.contains("default")) 54 | } 55 | 56 | @Test 57 | func backtickCasesParsingWithoutRawValues() throws { 58 | // Test that parsing works correctly 59 | let jsonDefault = "\"default\"" 60 | let jsonPublic = "\"public\"" 61 | 62 | let parsedDefault = try Keywords.schema.parseAndValidate(instance: jsonDefault) 63 | let parsedPublic = try Keywords.schema.parseAndValidate(instance: jsonPublic) 64 | 65 | #expect(parsedDefault == .default) 66 | #expect(parsedPublic == .public) 67 | } 68 | 69 | @Test 70 | func backtickCasesParsingWithRawValues() throws { 71 | // Test that parsing works correctly with raw values 72 | let jsonDefault = "\"default_value\"" 73 | let jsonPublic = "\"public\"" 74 | 75 | let parsedDefault = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonDefault) 76 | let parsedPublic = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonPublic) 77 | 78 | #expect(parsedDefault == .default) 79 | #expect(parsedPublic == .public) 80 | 81 | // Should NOT parse case names when raw values exist 82 | #expect(throws: Error.self) { 83 | _ = try KeywordsWithRawValues.schema.parseAndValidate(instance: "\"default\"") 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/JSONSchemaIntegrationTests/CodingKeysIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import InlineSnapshotTesting 2 | import JSONSchemaBuilder 3 | import Testing 4 | 5 | @Schemable 6 | struct PersonWithCodingKeys { 7 | let firstName: String 8 | let lastName: String 9 | let emailAddress: String 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case firstName = "first_name" 13 | case lastName = "last_name" 14 | case emailAddress = "email" 15 | } 16 | } 17 | 18 | @Schemable 19 | struct PersonWithPartialCodingKeys { 20 | let firstName: String 21 | let middleName: String 22 | let lastName: String 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case firstName = "given_name" 26 | case middleName 27 | case lastName = "family_name" 28 | } 29 | } 30 | 31 | @Schemable 32 | struct PersonWithCodingKeysAndOverride { 33 | let firstName: String 34 | @SchemaOptions(.key("surname")) 35 | let lastName: String 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case firstName = "first_name" 39 | case lastName = "last_name" 40 | } 41 | } 42 | 43 | struct CodingKeysIntegrationTests { 44 | @Test(.snapshots(record: false)) func codingKeys() { 45 | let schema = PersonWithCodingKeys.schema.schemaValue 46 | assertInlineSnapshot(of: schema, as: .json) { 47 | #""" 48 | { 49 | "properties" : { 50 | "email" : { 51 | "type" : "string" 52 | }, 53 | "first_name" : { 54 | "type" : "string" 55 | }, 56 | "last_name" : { 57 | "type" : "string" 58 | } 59 | }, 60 | "required" : [ 61 | "first_name", 62 | "last_name", 63 | "email" 64 | ], 65 | "type" : "object" 66 | } 67 | """# 68 | } 69 | } 70 | 71 | @Test(.snapshots(record: false)) func partialCodingKeys() { 72 | let schema = PersonWithPartialCodingKeys.schema.schemaValue 73 | assertInlineSnapshot(of: schema, as: .json) { 74 | #""" 75 | { 76 | "properties" : { 77 | "family_name" : { 78 | "type" : "string" 79 | }, 80 | "given_name" : { 81 | "type" : "string" 82 | }, 83 | "middleName" : { 84 | "type" : "string" 85 | } 86 | }, 87 | "required" : [ 88 | "given_name", 89 | "middleName", 90 | "family_name" 91 | ], 92 | "type" : "object" 93 | } 94 | """# 95 | } 96 | } 97 | 98 | @Test(.snapshots(record: false)) func codingKeysWithOverride() { 99 | let schema = PersonWithCodingKeysAndOverride.schema.schemaValue 100 | assertInlineSnapshot(of: schema, as: .json) { 101 | #""" 102 | { 103 | "properties" : { 104 | "first_name" : { 105 | "type" : "string" 106 | }, 107 | "surname" : { 108 | "type" : "string" 109 | } 110 | }, 111 | "required" : [ 112 | "first_name", 113 | "surname" 114 | ], 115 | "type" : "object" 116 | } 117 | """# 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/TypeSpecific/JSONNumber.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | public protocol JSONNumberType: JSONSchemaComponent {} 4 | 5 | /// A JSON integer schema component for use in ``JSONSchemaBuilder``. 6 | public struct JSONInteger: JSONNumberType { 7 | public var schemaValue = SchemaValue.object([:]) 8 | 9 | public init() { 10 | schemaValue[Keywords.TypeKeyword.name] = .string(JSONType.integer.rawValue) 11 | } 12 | 13 | public func parse(_ value: JSONValue) -> Parsed { 14 | if case .integer(let int) = value { return .valid(int) } 15 | return .error(.typeMismatch(expected: .integer, actual: value)) 16 | } 17 | } 18 | 19 | /// A JSON number schema component for use in ``JSONSchemaBuilder``. 20 | public struct JSONNumber: JSONNumberType { 21 | public var schemaValue = SchemaValue.object([:]) 22 | 23 | public init() { 24 | schemaValue[Keywords.TypeKeyword.name] = .string(JSONType.number.rawValue) 25 | } 26 | 27 | public func parse(_ value: JSONValue) -> Parsed { 28 | if case .number(let double) = value { return .valid(double) } 29 | if case .integer(let int) = value { return .valid(Double(int)) } 30 | return .error(.typeMismatch(expected: .number, actual: value)) 31 | } 32 | } 33 | 34 | extension JSONNumberType { 35 | /// Restrictes value to a multiple of this number. 36 | /// - Parameter multipleOf: The number that the value must be a multiple of. 37 | /// - Returns: A new `JSONNumber` with the multiple of constraint set. 38 | public func multipleOf(_ multipleOf: Double) -> Self { 39 | var copy = self 40 | copy.schemaValue[Keywords.MultipleOf.name] = .number(multipleOf) 41 | return copy 42 | } 43 | 44 | /// Adds a minimum constraint to the schema. 45 | /// - Parameter minimum: The minimum value that the number must be greater than or equal to. 46 | /// - Returns: A new `JSONNumber` with the minimum constraint set. 47 | public func minimum(_ minimum: Double) -> Self { 48 | var copy = self 49 | copy.schemaValue[Keywords.Minimum.name] = .number(minimum) 50 | return copy 51 | } 52 | 53 | /// Adds an exclusive minimum constraint to the schema. 54 | /// - Parameter minimum: The minimum value that the number must be greater than. 55 | /// - Returns: A new `JSONNumber` with the exclusive minimum constraint set. 56 | public func exclusiveMinimum(_ minimum: Double) -> Self { 57 | var copy = self 58 | copy.schemaValue[Keywords.ExclusiveMinimum.name] = .number(minimum) 59 | return copy 60 | } 61 | 62 | /// Adds a maximum constraint to the schema. 63 | /// - Parameter maximum: The maximum value that the number must be less than or equal to. 64 | /// - Returns: A new `JSONNumber` with the maximum constraint set. 65 | public func maximum(_ maximum: Double) -> Self { 66 | var copy = self 67 | copy.schemaValue[Keywords.Maximum.name] = .number(maximum) 68 | return copy 69 | } 70 | 71 | /// Adds an exclusive maximum constraint to the schema. 72 | /// - Parameter maximum: The maximum value that the number must be less than. 73 | /// - Returns: A new `JSONNumber` with the exclusive maximum constraint set. 74 | public func exclusiveMaximum(_ maximum: Double) -> Self { 75 | var copy = self 76 | copy.schemaValue[Keywords.ExclusiveMaximum.name] = .number(maximum) 77 | return copy 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/JSONSchema/FormatValidators/BuiltinValidators.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Date and Time 4 | 5 | public struct DateTimeFormatValidator: FormatValidator { 6 | public let formatName = "date-time" 7 | private static let formatter: ISO8601DateFormatter = { 8 | let f = ISO8601DateFormatter() 9 | f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 10 | return f 11 | }() 12 | 13 | public func validate(_ value: String) -> Bool { 14 | DateTimeFormatValidator.formatter.date(from: value) != nil 15 | } 16 | } 17 | 18 | public struct DateFormatValidator: FormatValidator { 19 | public let formatName = "date" 20 | private static let formatter: DateFormatter = { 21 | let f = DateFormatter() 22 | f.locale = Locale(identifier: "en_US_POSIX") 23 | f.dateFormat = "yyyy-MM-dd" 24 | f.timeZone = TimeZone(secondsFromGMT: 0) 25 | return f 26 | }() 27 | 28 | public func validate(_ value: String) -> Bool { 29 | DateFormatValidator.formatter.date(from: value) != nil 30 | } 31 | } 32 | 33 | public struct TimeFormatValidator: FormatValidator { 34 | public let formatName = "time" 35 | private static let regex = try! Regex( 36 | #"^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)?$"# 37 | ) 38 | 39 | public func validate(_ value: String) -> Bool { 40 | value.firstMatch(of: Self.regex) != nil 41 | } 42 | } 43 | 44 | // MARK: - Network/IDs 45 | 46 | public struct EmailFormatValidator: FormatValidator { 47 | public let formatName = "email" 48 | private static let regex = try! Regex(#"^[^@\s]+@[^@\s]+\.[^@\s]+$"#) 49 | public func validate(_ value: String) -> Bool { value.firstMatch(of: Self.regex) != nil } 50 | } 51 | 52 | public struct HostnameFormatValidator: FormatValidator { 53 | public let formatName = "hostname" 54 | private static let regex = try! Regex( 55 | #"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$"# 56 | ) 57 | public func validate(_ value: String) -> Bool { value.firstMatch(of: Self.regex) != nil } 58 | } 59 | 60 | public struct IPv4FormatValidator: FormatValidator { 61 | public let formatName = "ipv4" 62 | private static let regex = try! Regex( 63 | #"^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$"# 64 | ) 65 | public func validate(_ value: String) -> Bool { value.firstMatch(of: Self.regex) != nil } 66 | } 67 | 68 | public struct IPv6FormatValidator: FormatValidator { 69 | public let formatName = "ipv6" 70 | private static let regex = try! Regex(#"^(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}$"#) 71 | public func validate(_ value: String) -> Bool { value.firstMatch(of: Self.regex) != nil } 72 | } 73 | 74 | public struct UUIDFormatValidator: FormatValidator { 75 | public let formatName = "uuid" 76 | public func validate(_ value: String) -> Bool { UUID(uuidString: value) != nil } 77 | } 78 | 79 | public struct URIFormatValidator: FormatValidator { 80 | public let formatName = "uri" 81 | public func validate(_ value: String) -> Bool { 82 | guard let url = URL(string: value) else { return false } 83 | return url.scheme != nil 84 | } 85 | } 86 | 87 | public struct URIReferenceFormatValidator: FormatValidator { 88 | public let formatName = "uri-reference" 89 | public func validate(_ value: String) -> Bool { 90 | URL(string: value) != nil 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent+Annotations.swift: -------------------------------------------------------------------------------- 1 | import JSONSchema 2 | 3 | extension JSONSchemaComponent { 4 | /// Sets the title of the schema. 5 | /// - Parameter value: A string representing the title of the schema. 6 | /// - Returns: A new instance of the schema with the title set. 7 | public func title(_ value: String) -> Self { 8 | var copy = self 9 | copy.schemaValue[Keywords.Title.name] = .string(value) 10 | return copy 11 | } 12 | 13 | /// Sets the description of the schema. 14 | /// - Parameter value: A string describing the schema. 15 | /// - Returns: A new instance of the schema with the description set. 16 | public func description(_ value: String) -> Self { 17 | var copy = self 18 | copy.schemaValue[Keywords.Description.name] = .string(value) 19 | return copy 20 | } 21 | 22 | public func `default`(_ value: JSONValue) -> Self { 23 | var copy = self 24 | copy.schemaValue[Keywords.Default.name] = value 25 | return copy 26 | } 27 | 28 | /// Sets the default value of the schema. 29 | /// - Parameter value: A closure that returns a JSON value representing the default value. 30 | /// - Returns: A new instance of the schema with the default value set. 31 | public func `default`(@JSONValueBuilder _ value: () -> JSONValueRepresentable) -> Self { 32 | self.default(value().value) 33 | } 34 | 35 | public func examples(_ values: JSONValue) -> Self { 36 | var copy = self 37 | copy.schemaValue[Keywords.Examples.name] = values 38 | return copy 39 | } 40 | 41 | /// Sets the examples of the schema. 42 | /// - Parameter examples: A closure that returns a JSON value representing the examples. 43 | /// - Returns: A new instance of the schema with the examples set. 44 | public func examples(@JSONValueBuilder _ examples: () -> JSONValueRepresentable) -> Self { 45 | self.examples(examples().value) 46 | } 47 | 48 | /// Sets the readOnly flag of the schema. 49 | /// - Parameter value: A boolean value indicating whether the schema is read-only. 50 | /// - Returns: A new instance of the schema with the `readOnly` flag set. 51 | public func readOnly(_ value: Bool) -> Self { 52 | var copy = self 53 | copy.schemaValue[Keywords.ReadOnly.name] = .boolean(value) 54 | return copy 55 | } 56 | 57 | /// Sets the writeOnly flag of the schema. 58 | /// - Parameter value: A boolean value indicating whether the schema is write-only. 59 | /// - Returns: A new instance of the schema with the `writeOnly` flag set. 60 | public func writeOnly(_ value: Bool) -> Self { 61 | var copy = self 62 | copy.schemaValue[Keywords.WriteOnly.name] = .boolean(value) 63 | return copy 64 | } 65 | 66 | /// Sets the deprecated flag of the schema. 67 | /// - Parameter value: A boolean value indicating whether the schema is deprecated. 68 | /// - Returns: A new instance of the schema with the `deprecated` flag set. 69 | public func deprecated(_ value: Bool) -> Self { 70 | var copy = self 71 | copy.schemaValue[Keywords.Deprecated.name] = .boolean(value) 72 | return copy 73 | } 74 | 75 | /// Sets the comment of the schema. 76 | /// - Parameter value: A string representing a comment for the schema. 77 | /// - Returns: A new instance of the schema with the comment set. 78 | public func comment(_ value: String) -> Self { 79 | var copy = self 80 | copy.schemaValue[Keywords.Comment.name] = .string(value) 81 | return copy 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/JSONSchema/Schema+Codable.swift: -------------------------------------------------------------------------------- 1 | extension Schema: Codable { 2 | public init(from decoder: any Decoder) throws { 3 | let container = try decoder.singleValueContainer() 4 | 5 | if let bool = try? container.decode(BooleanSchema.self) { 6 | self.init(schema: bool, location: .init(), context: Context(dialect: .draft2020_12)) 7 | } else if let schema = try? container.decode(ObjectSchema.self) { 8 | self.init(schema: schema, location: .init(), context: Context(dialect: .draft2020_12)) 9 | } else { 10 | throw DecodingError.dataCorruptedError( 11 | in: container, 12 | debugDescription: "Expected either a boolean or an object representing a schema." 13 | ) 14 | } 15 | } 16 | public func encode(to encoder: any Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | 19 | switch schema { 20 | case let boolSchema as BooleanSchema: try container.encode(boolSchema) 21 | case let objectSchema as ObjectSchema: try container.encode(objectSchema) 22 | default: 23 | throw EncodingError.invalidValue( 24 | schema, 25 | .init( 26 | codingPath: [], 27 | debugDescription: "Expected either a boolean or an object representing a schema." 28 | ) 29 | ) 30 | } 31 | } 32 | } 33 | 34 | extension BooleanSchema: Codable { 35 | public init(from decoder: any Decoder) throws { 36 | let container = try decoder.singleValueContainer() 37 | let bool = try container.decode(Bool.self) 38 | self.init(schemaValue: bool, location: .init(), context: Context(dialect: .draft2020_12)) 39 | } 40 | 41 | public func encode(to encoder: any Encoder) throws { 42 | var container = encoder.singleValueContainer() 43 | try container.encode(schemaValue) 44 | } 45 | } 46 | 47 | extension ObjectSchema: Codable { 48 | public init(from decoder: any Decoder) throws { 49 | let container = try decoder.container(keyedBy: DynamicCodingKey.self) 50 | 51 | var schemaValue = [String: JSONValue]() 52 | 53 | let dialect = Dialect.draft2020_12 54 | let context = Context(dialect: dialect) 55 | 56 | for keywordType in dialect.keywords { 57 | let key = keywordType.name 58 | let keyValue = DynamicCodingKey(stringValue: key)! 59 | 60 | if let value = try? container.decode(JSONValue.self, forKey: keyValue) { 61 | schemaValue[key] = value 62 | } else if container.contains(keyValue) { 63 | // Handle the case where the value is explicitly null 64 | schemaValue[key] = .null 65 | } 66 | } 67 | 68 | do { 69 | try self.init(schemaValue: schemaValue, location: .init(), context: context) 70 | } catch { 71 | throw DecodingError.dataCorrupted( 72 | .init( 73 | codingPath: decoder.codingPath, 74 | debugDescription: "Failed to initialize schema: \(error)" 75 | ) 76 | ) 77 | } 78 | } 79 | 80 | public func encode(to encoder: any Encoder) throws { 81 | var container = encoder.container(keyedBy: DynamicCodingKey.self) 82 | for keyword in keywords { 83 | let key = DynamicCodingKey(stringValue: type(of: keyword).name)! 84 | try container.encode(keyword.value, forKey: key) 85 | } 86 | } 87 | } 88 | 89 | struct DynamicCodingKey: CodingKey { 90 | var stringValue: String 91 | var intValue: Int? { nil } 92 | 93 | init?(stringValue: String) { self.stringValue = stringValue } 94 | 95 | init?(intValue: Int) { return nil } 96 | } 97 | -------------------------------------------------------------------------------- /ReferenceResolver.md: -------------------------------------------------------------------------------- 1 | # ReferenceResolver 2 | 3 | `ReferenceResolver` is responsible for turning the value of `$ref` or `$dynamicRef` keywords into an actual schema that can be used for validation. The resolver works relative to a base URI and relies on context information such as caches and registered identifiers. 4 | 5 | ## Resolution Flow 6 | 7 | 1. **Convert the reference** – The reference string is resolved relative to the base URI to form an absolute `URL`. If the reference cannot be parsed, a `ReferenceResolverError.invalidReferenceURI` is thrown. 8 | 2. **Load the base schema** – `fetchSchema(for:)` retrieves the schema referenced by the URL. The lookup order is: 9 | - If the URL matches the current dialect's identifier, the dialect's meta‑schema is loaded. This allows schemas to refer to their own meta‑schema (for example when validating other schemas). 10 | - A cached schema for the URL, if one exists. 11 | - A schema registered via `$id` in the identifier registry. 12 | - A schema stored in the remote schema storage. 13 | - A workaround for URN references that rely on the base URI. 14 | 3. **Resolve fragments or anchors** – If the reference contains a fragment or anchor, `resolveFragmentOrAnchor` locates the subschema within the loaded base schema. 15 | 4. **Return the resolved schema** – If none of the steps succeed, `ReferenceResolverError.unresolvedReference` is thrown. 16 | 17 | ## Why load the meta‑schema? 18 | 19 | JSON Schema dialects are themselves described by a schema—often called the *meta‑schema*. When a schema uses `$ref` to point directly to the dialect's meta‑schema (e.g. `https://json-schema.org/draft/2020-12/schema`), the resolver must return that meta‑schema so the reference can be validated. Loading it on demand keeps the meta‑schemas out of the normal cache while still supporting references to them. 20 | 21 | ## Opportunities for Improvement 22 | 23 | - **Cache management**: The resolver currently stores every loaded schema in a flat `schemaCache` dictionary. Introducing expiration policies, size limits, or tiered caches (e.g. in-memory versus on-disk) could reduce memory usage and repeated network fetches. A normalized cache key (such as the canonical URI of a schema) would help avoid duplicates. 24 | - **Asynchronous loading**: Reference resolution always blocks on loading remote schemas. Incorporating async/await would let the resolver fetch multiple references concurrently, improving throughput when validating large schemas with many remote references. 25 | - **Error reporting**: Exposing cache hits and misses in the error messages or diagnostics would make debugging reference issues easier. 26 | 27 | ## Measuring Resolver Efficiency 28 | 29 | To track performance improvements, we can add instrumentation around the resolver: 30 | 31 | 1. **Time critical paths** – Use `DispatchTime` or `ContinuousClock` to measure how long it takes to resolve a reference and fetch a remote schema. Recording these metrics will highlight slow spots. 32 | 2. **Count cache hits and misses** – Maintain counters for how often the cache returns a schema versus fetching or constructing it. These stats help determine whether cache strategies are effective. 33 | 3. **Log unresolved references** – Keep a list of references that required remote loading or failed to resolve. This can guide where to pre-populate caches or adjust identifier registration. 34 | 35 | Recording these statistics during unit tests or integration benchmarks will provide concrete data to guide future optimizations. 36 | --------------------------------------------------------------------------------