From 044d8e33da6716c3670588b2773dcea393716de2 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 00:01:13 +0100 Subject: [PATCH 1/9] Add CodingKeys support to @Schemable macro The @Schemable macro now respects Swift's CodingKeys enum when generating JSON schemas. When a type defines a CodingKeys enum with custom string raw values, those values are used as property names in the generated schema. Key features: - Automatic extraction of CodingKeys mapping from enum definitions - Support for partial CodingKeys (missing entries fall back to property names) - Proper priority order: @SchemaOptions(.key) > CodingKeys > keyStrategy > property name - Works with both struct and class types - Handles nested types with their own CodingKeys Implementation: - Added extractCodingKeys() method to parse CodingKeys enum via SwiftSyntax - Updated schema generation to check CodingKeys mapping before other strategies - Maintains backward compatibility (no breaking changes) --- .../Schemable/SchemaGenerator.swift | 3 +- .../Schemable/SchemableMember.swift | 12 +- .../Schemable/SwiftSyntaxExtensions.swift | 46 ++++- .../CodingKeysIntegrationTests.swift | 120 +++++++++++++ .../SchemableExpansionTests.swift | 164 +++++++++++++++--- 5 files changed, 315 insertions(+), 30 deletions(-) create mode 100644 Tests/JSONSchemaIntegrationTests/CodingKeysIntegrationTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index debea332..6f9693b4 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -142,9 +142,10 @@ struct SchemaGenerator { func makeSchema() -> DeclSyntax { let schemableMembers = members.schemableMembers() + let codingKeys = members.extractCodingKeys() let statements = schemableMembers.compactMap { - $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text) + $0.generateSchema(keyStrategy: keyStrategy, typeName: name.text, codingKeys: codingKeys) } var codeBlockItem: CodeBlockItemSyntax = diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index 67bcf2ec..efca2650 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -47,7 +47,11 @@ struct SchemableMember { ) } - func generateSchema(keyStrategy: ExprSyntax?, typeName: String) -> CodeBlockItemSyntax? { + func generateSchema( + keyStrategy: ExprSyntax?, + typeName: String, + codingKeys: [String: String]? = nil + ) -> CodeBlockItemSyntax? { var codeBlock: CodeBlockItemSyntax switch type.typeInformation() { case .primitive(_, let code): @@ -112,10 +116,16 @@ struct SchemableMember { let keyExpr: ExprSyntax if let customKey { + // Custom key from @SchemaOptions(.key(...)) takes highest priority keyExpr = customKey + } else if let codingKeys, let codingKey = codingKeys[identifier.text] { + // CodingKeys takes priority over keyStrategy + keyExpr = "\"\(raw: codingKey)\"" } else if keyStrategy != nil { + // keyStrategy is used if no CodingKeys or custom key keyExpr = "\(raw: typeName).keyEncodingStrategy.encode(\"\(raw: identifier.text)\")" } else { + // Default: use property name as-is keyExpr = "\"\(raw: identifier.text)\"" } diff --git a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift index eede909d..74344f01 100644 --- a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift +++ b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift @@ -237,18 +237,11 @@ extension VariableDeclSyntax { } .contains(where: { $0 == "ExcludeFromSchema" }) } - - var isStatic: Bool { - modifiers.contains { modifier in - modifier.name.tokenKind == .keyword(.static) - } - } } extension MemberBlockItemListSyntax { func schemableMembers() -> [SchemableMember] { self.compactMap { $0.decl.as(VariableDeclSyntax.self) } - .filter { !$0.isStatic } .flatMap { variableDecl in variableDecl.bindings.map { (variableDecl, $0) } } .filter { $0.0.shouldExcludeFromSchema }.filter { $0.1.isStoredProperty } .compactMap(SchemableMember.init) @@ -258,6 +251,45 @@ extension MemberBlockItemListSyntax { self.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } .flatMap { caseDecl in caseDecl.elements.map { (caseDecl, $0) } }.map(SchemableEnumCase.init) } + + /// Extracts CodingKeys mapping from a CodingKeys enum if present + func extractCodingKeys() -> [String: String]? { + // Look for an enum named "CodingKeys" + guard + let codingKeysEnum = self.compactMap({ $0.decl.as(EnumDeclSyntax.self) }) + .first(where: { $0.name.text == "CodingKeys" }) + else { + return nil + } + + var mapping: [String: String] = [:] + + // Iterate through enum cases to extract the mapping + for member in codingKeysEnum.memberBlock.members { + guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { continue } + + for element in caseDecl.elements { + let caseName = element.name.text + + // Check if there's a raw value (string literal) + if let rawValue = element.rawValue?.value.as(StringLiteralExprSyntax.self) { + // Extract the string content from the string literal + let stringValue = rawValue.segments.compactMap { segment -> String? in + if case .stringSegment(let stringSegment) = segment { + return stringSegment.content.text + } + return nil + }.joined() + mapping[caseName] = stringValue + } else { + // If no raw value is specified, the case name is the coding key + mapping[caseName] = caseName + } + } + } + + return mapping.isEmpty ? nil : mapping + } } extension CodeBlockItemSyntax { diff --git a/Tests/JSONSchemaIntegrationTests/CodingKeysIntegrationTests.swift b/Tests/JSONSchemaIntegrationTests/CodingKeysIntegrationTests.swift new file mode 100644 index 00000000..f8f4bc88 --- /dev/null +++ b/Tests/JSONSchemaIntegrationTests/CodingKeysIntegrationTests.swift @@ -0,0 +1,120 @@ +import InlineSnapshotTesting +import JSONSchemaBuilder +import Testing + +@Schemable +struct PersonWithCodingKeys { + let firstName: String + let lastName: String + let emailAddress: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + case emailAddress = "email" + } +} + +@Schemable +struct PersonWithPartialCodingKeys { + let firstName: String + let middleName: String + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "given_name" + case middleName + case lastName = "family_name" + } +} + +@Schemable +struct PersonWithCodingKeysAndOverride { + let firstName: String + @SchemaOptions(.key("surname")) + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + } +} + +struct CodingKeysIntegrationTests { + @Test(.snapshots(record: false)) func codingKeys() { + let schema = PersonWithCodingKeys.schema.schemaValue + assertInlineSnapshot(of: schema, as: .json) { + #""" + { + "properties" : { + "email" : { + "type" : "string" + }, + "first_name" : { + "type" : "string" + }, + "last_name" : { + "type" : "string" + } + }, + "required" : [ + "first_name", + "last_name", + "email" + ], + "type" : "object" + } + """# + } + } + + @Test(.snapshots(record: false)) func partialCodingKeys() { + let schema = PersonWithPartialCodingKeys.schema.schemaValue + assertInlineSnapshot(of: schema, as: .json) { + #""" + { + "properties" : { + "family_name" : { + "type" : "string" + }, + "given_name" : { + "type" : "string" + }, + "middleName" : { + "type" : "string" + } + }, + "required" : [ + "given_name", + "middleName", + "family_name" + ], + "type" : "object" + } + """# + } + } + + @Test(.snapshots(record: false)) func codingKeysWithOverride() { + let schema = PersonWithCodingKeysAndOverride.schema.schemaValue + assertInlineSnapshot(of: schema, as: .json) { + #""" + { + "properties" : { + "first_name" : { + "type" : "string" + }, + "surname" : { + "type" : "string" + } + }, + "required" : [ + "first_name", + "surname" + ], + "type" : "object" + } + """# + } + } +} diff --git a/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift b/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift index 4870e2ed..67db8292 100644 --- a/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift +++ b/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift @@ -1157,45 +1157,167 @@ struct SchemableExpansionTests { ) } - @Test(arguments: ["struct", "class"]) func staticPropertiesExcluded(declarationType: String) { + @Test(arguments: ["struct", "class"]) func customCodingKeys(declarationType: String) { assertMacroExpansion( """ @Schemable - \(declarationType) Config { - static let version = "1.0.0" - static var defaultTimeout: Int = 30 - static var maxRetries: Int { 5 } - static let defaultConfig: Config = Config(apiKey: "default", endpoint: "https://api.example.com") - let apiKey: String - let endpoint: String + \(declarationType) Person { + let firstName: String + let lastName: String + let emailAddress: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + case emailAddress = "email" + } + } + """, + expandedSource: """ + \(declarationType) Person { + let firstName: String + let lastName: String + let emailAddress: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + case emailAddress = "email" + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "first_name") { + JSONString() + } + .required() + JSONProperty(key: "last_name") { + JSONString() + } + .required() + JSONProperty(key: "email") { + JSONString() + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + macros: testMacros + ) + } + + @Test(arguments: ["struct", "class"]) func customCodingKeysWithSchemaOptionsOverride( + declarationType: String + ) { + assertMacroExpansion( + """ + @Schemable + \(declarationType) Person { + let firstName: String + @SchemaOptions(.key("surname")) + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + } + } + """, + expandedSource: """ + \(declarationType) Person { + let firstName: String + @SchemaOptions(.key("surname")) + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { + JSONObject { + JSONProperty(key: "first_name") { + JSONString() + } + .required() + JSONProperty(key: "surname") { + JSONString() + } + .required() + } + } + } + } + + extension Person: Schemable { + } + """, + macros: testMacros + ) + } + + @Test(arguments: ["struct", "class"]) func customCodingKeysWithKeyStrategy( + declarationType: String + ) { + assertMacroExpansion( + """ + @Schemable(keyStrategy: .snakeCase) + \(declarationType) Person { + let firstName: String + let middleName: String + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "given_name" + case middleName + case lastName = "family_name" + } } """, expandedSource: """ - \(declarationType) Config { - static let version = "1.0.0" - static var defaultTimeout: Int = 30 - static var maxRetries: Int { 5 } - static let defaultConfig: Config = Config(apiKey: "default", endpoint: "https://api.example.com") - let apiKey: String - let endpoint: String - - static var schema: some JSONSchemaComponent { - JSONSchema(Config.init) { + \(declarationType) Person { + let firstName: String + let middleName: String + let lastName: String + + enum CodingKeys: String, CodingKey { + case firstName = "given_name" + case middleName + case lastName = "family_name" + } + + static var schema: some JSONSchemaComponent { + JSONSchema(Person.init) { JSONObject { - JSONProperty(key: "apiKey") { + JSONProperty(key: "given_name") { + JSONString() + } + .required() + JSONProperty(key: "middleName") { JSONString() } .required() - JSONProperty(key: "endpoint") { + JSONProperty(key: "family_name") { JSONString() } .required() } } } + + static var keyEncodingStrategy: KeyEncodingStrategies { + .snakeCase + } } - extension Config: Schemable { + extension Person: Schemable { } """, macros: testMacros From 2624d338deb7b39b95d7c7dad397931c3c949aa0 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 00:02:59 +0100 Subject: [PATCH 2/9] lint --- .../Schemable/SwiftSyntaxExtensions.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift index 74344f01..e60fd035 100644 --- a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift +++ b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift @@ -274,12 +274,14 @@ extension MemberBlockItemListSyntax { // Check if there's a raw value (string literal) if let rawValue = element.rawValue?.value.as(StringLiteralExprSyntax.self) { // Extract the string content from the string literal - let stringValue = rawValue.segments.compactMap { segment -> String? in - if case .stringSegment(let stringSegment) = segment { - return stringSegment.content.text + let stringValue = rawValue.segments + .compactMap { segment -> String? in + if case .stringSegment(let stringSegment) = segment { + return stringSegment.content.text + } + return nil } - return nil - }.joined() + .joined() mapping[caseName] = stringValue } else { // If no raw value is specified, the case name is the coding key From 49266fdaf23bb5bbda272579d290d38161033f7c Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 11:41:38 +0100 Subject: [PATCH 3/9] update macro docc with key encoding strategies --- .../Documentation.docc/Articles/Macros.md | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md index f8d89c3c..7e96cd60 100644 --- a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md +++ b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md @@ -429,17 +429,84 @@ struct Weather { #### Key encoding strategies -You can override a property's JSON key using ``SchemaOptions/key(_:)`` or apply a -type-wide strategy by providing ``@Schemable(keyStrategy:)``. Strategies are -represented by ``KeyEncodingStrategies`` which offers built-in ``.identity`` and -``.snakeCase`` options but can also wrap custom types conforming to -``KeyEncodingStrategy``. +You can customize JSON property names in three ways: using a `CodingKeys` enum, +overriding individual properties with ``SchemaOptions/key(_:)``, or applying a +type-wide strategy with ``@Schemable(keyStrategy:)``. + +##### CodingKeys Support + +If your type defines a `CodingKeys` enum (typically for `Codable` conformance), the +macro will automatically use those custom names in the generated schema: ```swift -@Schemable(keyStrategy: .snakeCase) -struct Person { +@Schemable +struct User { let firstName: String - @SchemaOptions(.key("last-name")) let lastName: String + let emailAddress: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + case emailAddress = "email" + } +} +``` + +This generates a schema with properties: `"first_name"`, `"last_name"`, and `"email"`. + +Properties without corresponding `CodingKeys` entries fall back to using their property +name as-is: + +```swift +@Schemable +struct Product { + let name: String // Uses "name" (no CodingKeys entry) + let productId: Int // Uses "product_id" (from CodingKeys) + + enum CodingKeys: String, CodingKey { + case productId = "product_id" + } +} +``` + +##### Key Strategies + +Alternatively, you can apply a type-wide key encoding strategy using +``@Schemable(keyStrategy:)``. Strategies are represented by ``KeyEncodingStrategies`` +which offers built-in ``.identity`` and ``.snakeCase`` options but can also wrap custom +types conforming to ``KeyEncodingStrategy``. + +```swift +@Schemable(keyStrategy: .snakeCase) +struct Person { + let firstName: String // Becomes "first_name" + let lastName: String // Becomes "last_name" +} +``` + +##### Priority Order + +When multiple key customization methods are used, they follow this priority order (highest to lowest): + +1. ``SchemaOptions/key(_:)`` - Explicit per-property override +2. `CodingKeys` enum - Custom coding keys +3. `keyStrategy` - Type-wide transformation strategy +4. Property name - Default (no transformation) + +Example showing priority: + +```swift +@Schemable(keyStrategy: .snakeCase) +struct Customer { + let firstName: String // Uses "first_name" from CodingKeys + @SchemaOptions(.key("surname")) + let lastName: String // Uses "surname" (highest priority) + let middleName: String // Uses "middle_name" from keyStrategy + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" // Overridden by @SchemaOptions + } } ``` From 6b850f7249a3c38b2c91a1f85d350a49e08f9b64 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 11:54:57 +0100 Subject: [PATCH 4/9] Add tests for CodingKeys documentation examples --- .../DocumentationExampleTests.swift | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift b/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift index b48b624a..31546c4b 100644 --- a/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift +++ b/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift @@ -202,4 +202,83 @@ struct DocumentationExampleTests { Issue.record("Parsing failed with errors: \(errors)") } } + + // MARK: - CodingKeys Examples from Macros.md docc + + @Schemable + struct User { + let firstName: String + let lastName: String + let emailAddress: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + case emailAddress = "email" + } + } + + @Test func doccExampleCodingKeys() { + let schema = User.schema.schemaValue + let properties = schema["properties"]?.object + + // Verify CodingKeys are used for property names + #expect(properties?["first_name"] != nil) + #expect(properties?["last_name"] != nil) + #expect(properties?["email"] != nil) + + // Verify original property names are not used + #expect(properties?["firstName"] == nil) + #expect(properties?["lastName"] == nil) + #expect(properties?["emailAddress"] == nil) + } + + @Schemable + struct Product { + let name: String + let productId: Int + + enum CodingKeys: String, CodingKey { + case productId = "product_id" + } + } + + @Test func doccExamplePartialCodingKeys() { + let schema = Product.schema.schemaValue + let properties = schema["properties"]?.object + + // Property with CodingKeys entry uses custom name + #expect(properties?["product_id"] != nil) + + // Property without CodingKeys entry uses property name + #expect(properties?["name"] != nil) + } + + @Schemable(keyStrategy: .snakeCase) + struct Customer { + let firstName: String + @SchemaOptions(.key("surname")) + let lastName: String + let middleName: String + + enum CodingKeys: String, CodingKey { + case firstName = "first_name" + case lastName = "last_name" + } + } + + @Test func doccExampleKeyPriority() { + let schema = Customer.schema.schemaValue + let properties = schema["properties"]?.object + + // CodingKeys takes priority over keyStrategy + #expect(properties?["first_name"] != nil) + + // @SchemaOptions takes priority over CodingKeys + #expect(properties?["surname"] != nil) + #expect(properties?["last_name"] == nil) + + // No CodingKeys entry, falls back to keyStrategy + #expect(properties?["middle_name"] != nil) + } } From 48d8172920e06f7d2d4efa2f594de81c22cfbe69 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 22:03:52 +0100 Subject: [PATCH 5/9] Fix: Use raw values for String-backed enums in generated schemas This fix ensures String-backed enums (enums conforming to RawRepresentable) generate schemas using their raw values instead of case names, making them compatible with Swift's Codable behavior. **Changes:** - Updated SchemableEnumCase to extract and store raw values from enum cases - Modified SchemaGenerator to use raw values in both enum value lists and switch statements - Updated PollExampleTests to use raw values in test data - Regenerated test snapshots to reflect correct schema output - Enhanced documentation to clarify behavior for simple vs String-backed enums **Example:** ```swift enum Size: String { case small = "sm" case medium = "md" } ``` Previously generated schema (incorrect): ```json {"type": "string", "enum": ["small", "medium"]} ``` Now generates schema (correct): ```json {"type": "string", "enum": ["sm", "md"]} ``` This ensures generated schemas validate JSON that Swift's Codable can decode. --- .../Documentation.docc/Articles/Macros.md | 53 ++++++++++++++++++- .../Schemable/SchemaGenerator.swift | 18 ++++--- .../Schemable/SchemableEnumCase.swift | 19 ++++++- .../PollExampleTests.swift | 2 +- .../PollExampleTests/defintion.1.json | 8 +-- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md index 7e96cd60..3ffb09af 100644 --- a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md +++ b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md @@ -263,9 +263,13 @@ Computed properties are not included in generated schemas. The ``Schemable()`` macro can also be applied to Swift enums. The enum cases will be expanded as string literals in the schema. Only strings are supported in macro generation currently. To support other types, you must manually implement the schema. +#### Simple Enums + +For enums without raw values, the schema uses the case names: + ```swift @Schemable -enum TemperatureType: String { +enum TemperatureType { case fahrenheit case celsius case kelvin @@ -275,7 +279,7 @@ enum TemperatureType: String { will expand to: ```swift -enum TemperatureType: String { +enum TemperatureType { case fahrenheit case celsius case kelvin @@ -300,6 +304,51 @@ enum TemperatureType: String { } ``` +#### String-Backed Enums + +For enums with String raw values, the schema uses the **raw values**, ensuring compatibility with Swift's `Codable`: + +```swift +@Schemable +enum Size: String { + case small = "sm" + case medium = "md" + case large = "lg" +} +``` + +will expand to: + +```swift +enum Size: String { + case small = "sm" + case medium = "md" + case large = "lg" + + // Auto-generated schema ↴ + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "sm" + "md" + "lg" + } + .compactMap { string in + switch string { + case "sm": return Size.small + case "md": return Size.medium + case "lg": return Size.large + default: return nil + } + } + } +} +``` + +This behavior matches Swift's `Codable`, which encodes and decodes using the raw values. The generated schema will correctly validate JSON data that can be decoded with Swift's standard `JSONDecoder`. + +#### Enums with Associated Values + If any of the enum cases have an associated value, the macro will instead expand using the `OneOf` ``JSONComposition/OneOf/init(_:)`` builder. ```swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 6f9693b4..f23fa234 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -70,19 +70,21 @@ struct EnumSchemaGenerator { let statements = casesWithoutAssociatedValues.compactMap { $0.generateSchema() } let statementList = CodeBlockItemListSyntax(statements, separator: .newline) - var switchCases = casesWithoutAssociatedValues.map(\.identifier) - .map { identifier -> SwitchCaseSyntax in - """ - case \"\(identifier)\": - return Self.\(identifier) - - """ + var switchCases = casesWithoutAssociatedValues + .map { enumCase -> SwitchCaseSyntax in + // Use raw value if present, otherwise use case name + let matchValue = enumCase.rawValue ?? enumCase.identifier.text + return """ + case "\(raw: matchValue)": + return Self.\(enumCase.identifier) + + """ } switchCases.append("default: return nil") let switchCaseList = SwitchCaseListSyntax(switchCases.map { .switchCase($0) }) return """ - JSONString() + JSONString() .enumValues { \(statementList) } diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift index af55318b..18a88493 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift @@ -3,16 +3,33 @@ import SwiftSyntax struct SchemableEnumCase { let identifier: TokenSyntax let associatedValues: EnumCaseParameterListSyntax? + let rawValue: String? init(enumCaseDecl: EnumCaseDeclSyntax, caseElement: EnumCaseElementSyntax) { identifier = caseElement.name.trimmed associatedValues = caseElement.parameterClause?.parameters + + // Extract raw value if present (for String-backed enums) + if let rawValueExpr = caseElement.rawValue?.value.as(StringLiteralExprSyntax.self) { + rawValue = rawValueExpr.segments + .compactMap { segment -> String? in + if case .stringSegment(let stringSegment) = segment { + return stringSegment.content.text + } + return nil + } + .joined() + } else { + rawValue = nil + } } func generateSchema() -> CodeBlockItemSyntax? { guard let associatedValues else { + // Use raw value if present, otherwise use case name + let enumValue = rawValue ?? identifier.text return """ - "\(identifier)" + "\(raw: enumValue)" """ } let properties: [CodeBlockItemSyntax] = associatedValues.enumerated() diff --git a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift index 3272e8c4..c81b171d 100644 --- a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift +++ b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift @@ -182,7 +182,7 @@ struct PollExampleTests { "entertainment": { "_0": { "genre": "movies", - "ageRating": "pg13" + "ageRating": "Parental Guidance Suggested 13+" } } } diff --git a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json index e12dd13d..d3924308 100644 --- a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json +++ b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json @@ -43,10 +43,10 @@ "properties" : { "ageRating" : { "enum" : [ - "g", - "pg", - "pg13", - "r" + "General Audience", + "Parental Guidance Suggested", + "Parental Guidance Suggested 13+", + "Restricted" ], "type" : "string" }, From 337c6d206f95fdaeed97a8bef00ad1410ff99ab4 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 22:09:49 +0100 Subject: [PATCH 6/9] Fix: Strip backticks from enum case names in generated schemas Backticked Swift identifiers (e.g., case \`default\`) were incorrectly including the backticks in generated JSON schemas, causing schemas to use "\`default\`" instead of "default". **Changes:** - Added String.trimmingBackticks() extension to remove backticks - Updated SchemableEnumCase to strip backticks from case names - Updated SchemaGenerator to strip backticks when matching values - Added comprehensive tests for backticked enum cases **Test Coverage:** - BacktickEnumTests: Macro expansion tests for backticked cases - BacktickEnumIntegrationTests: Schema generation and parsing tests This ensures schemas match Swift's Codable behavior, which also strips backticks from identifiers during encoding/decoding. --- .../Schemable/SchemaGenerator.swift | 14 ++- .../Schemable/SchemableEnumCase.swift | 4 +- .../BacktickEnumIntegrationTests.swift | 86 ++++++++++++++++ .../BacktickEnumTests.swift | 99 +++++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift create mode 100644 Tests/JSONSchemaMacroTests/BacktickEnumTests.swift diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index f23fa234..9593c7c0 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -1,6 +1,16 @@ import SwiftSyntax import SwiftSyntaxBuilder +extension String { + /// Removes backticks from Swift identifiers (e.g., "`default`" → "default") + func trimmingBackticks() -> String { + if self.hasPrefix("`") && self.hasSuffix("`") { + return String(self.dropFirst().dropLast()) + } + return self + } +} + struct EnumSchemaGenerator { let declModifier: DeclModifierSyntax? let name: TokenSyntax @@ -72,8 +82,8 @@ struct EnumSchemaGenerator { var switchCases = casesWithoutAssociatedValues .map { enumCase -> SwitchCaseSyntax in - // Use raw value if present, otherwise use case name - let matchValue = enumCase.rawValue ?? enumCase.identifier.text + // Use raw value if present, otherwise use case name (without backticks) + let matchValue = enumCase.rawValue ?? enumCase.identifier.text.trimmingBackticks() return """ case "\(raw: matchValue)": return Self.\(enumCase.identifier) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift index 18a88493..c798582a 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift @@ -26,8 +26,8 @@ struct SchemableEnumCase { func generateSchema() -> CodeBlockItemSyntax? { guard let associatedValues else { - // Use raw value if present, otherwise use case name - let enumValue = rawValue ?? identifier.text + // Use raw value if present, otherwise use case name (without backticks) + let enumValue = rawValue ?? identifier.text.trimmingBackticks() return """ "\(raw: enumValue)" """ diff --git a/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift new file mode 100644 index 00000000..abb7d209 --- /dev/null +++ b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift @@ -0,0 +1,86 @@ +import Foundation +import JSONSchema +import JSONSchemaBuilder +import Testing + +@Suite struct BacktickEnumIntegrationTests { + + @Schemable + enum Keywords { + case `default` + case `public` + case normal + } + + @Schemable + enum KeywordsWithRawValues: String { + case `default` = "default_value" + case `public` + case normal + } + + @Test + func backtickCasesWithoutRawValuesSchema() throws { + let schema = Keywords.schema.definition() + let jsonData = try JSONEncoder().encode(schema) + let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + + // Should have enum values without backticks + let enumValues = try #require(jsonValue["enum"] as? [String]) + #expect(enumValues.contains("default")) + #expect(enumValues.contains("public")) + #expect(enumValues.contains("normal")) + + // Should NOT contain backticks + #expect(!enumValues.contains("`default`")) + #expect(!enumValues.contains("`public`")) + } + + @Test + func backtickCasesWithRawValuesSchema() throws { + let schema = KeywordsWithRawValues.schema.definition() + let jsonData = try JSONEncoder().encode(schema) + let jsonValue = try #require(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + + // Should have enum values using raw values + let enumValues = try #require(jsonValue["enum"] as? [String]) + #expect(enumValues.contains("default_value")) // Custom raw value + #expect(enumValues.contains("public")) // Implicit raw value + #expect(enumValues.contains("normal")) // Implicit raw value + + // Should NOT contain backticks or case names for custom raw value + #expect(!enumValues.contains("`default`")) + #expect(!enumValues.contains("default")) + } + + @Test + func backtickCasesParsingWithoutRawValues() throws { + // Test that parsing works correctly + let jsonDefault = "\"default\"" + let jsonPublic = "\"public\"" + + let parsedDefault = try Keywords.schema.parseAndValidate(instance: jsonDefault) + let parsedPublic = try Keywords.schema.parseAndValidate(instance: jsonPublic) + + #expect(parsedDefault == .default) + #expect(parsedPublic == .public) + } + + @Test + func backtickCasesParsingWithRawValues() throws { + // Test that parsing works correctly with raw values + let jsonDefault = "\"default_value\"" + let jsonPublic = "\"public\"" + + let parsedDefault = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonDefault) + let parsedPublic = try KeywordsWithRawValues.schema.parseAndValidate(instance: jsonPublic) + + #expect(parsedDefault == .default) + #expect(parsedPublic == .public) + + // Should NOT parse case names when raw values exist + #expect(throws: Error.self) { + _ = try KeywordsWithRawValues.schema.parseAndValidate(instance: "\"default\"") + } + } +} diff --git a/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift new file mode 100644 index 00000000..86c960a0 --- /dev/null +++ b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift @@ -0,0 +1,99 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import Testing + +@Suite struct BacktickEnumTests { + let testMacros: [String: Macro.Type] = ["Schemable": SchemableMacro.self] + + @Test func backtickCasesWithoutRawValues() { + assertMacroExpansion( + """ + @Schemable + enum Keywords { + case `default` + case `public` + case normal + } + """, + expandedSource: """ + enum Keywords { + case `default` + case `public` + case normal + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "default" + "public" + "normal" + } + .compactMap { + switch $0 { + case "default": + return Self.default + case "public": + return Self.public + case "normal": + return Self.normal + default: + return nil + } + } + } + } + + extension Keywords: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func backtickCasesWithRawValues() { + assertMacroExpansion( + """ + @Schemable + enum Keywords: String { + case `default` = "default_value" + case `public` + case normal + } + """, + expandedSource: """ + enum Keywords: String { + case `default` = "default_value" + case `public` + case normal + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONString() + .enumValues { + "default_value" + "public" + "normal" + } + .compactMap { + switch $0 { + case "default_value": + return Self.default + case "public": + return Self.public + case "normal": + return Self.normal + default: + return nil + } + } + } + } + + extension Keywords: Schemable { + } + """, + macros: testMacros + ) + } +} From bd0512c212efb92fe9bc33c974677080e18bd943 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Tue, 4 Nov 2025 22:17:57 +0100 Subject: [PATCH 7/9] lint --- Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift | 3 ++- .../BacktickEnumIntegrationTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 9593c7c0..78dd85a5 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -80,7 +80,8 @@ struct EnumSchemaGenerator { let statements = casesWithoutAssociatedValues.compactMap { $0.generateSchema() } let statementList = CodeBlockItemListSyntax(statements, separator: .newline) - var switchCases = casesWithoutAssociatedValues + var switchCases = + casesWithoutAssociatedValues .map { enumCase -> SwitchCaseSyntax in // Use raw value if present, otherwise use case name (without backticks) let matchValue = enumCase.rawValue ?? enumCase.identifier.text.trimmingBackticks() diff --git a/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift index abb7d209..49f39e29 100644 --- a/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift +++ b/Tests/JSONSchemaIntegrationTests/BacktickEnumIntegrationTests.swift @@ -45,8 +45,8 @@ import Testing // Should have enum values using raw values let enumValues = try #require(jsonValue["enum"] as? [String]) #expect(enumValues.contains("default_value")) // Custom raw value - #expect(enumValues.contains("public")) // Implicit raw value - #expect(enumValues.contains("normal")) // Implicit raw value + #expect(enumValues.contains("public")) // Implicit raw value + #expect(enumValues.contains("normal")) // Implicit raw value // Should NOT contain backticks or case names for custom raw value #expect(!enumValues.contains("`default`")) From 33059680f86b8ed92018efbfbac1e6c67122fe22 Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Wed, 5 Nov 2025 14:51:58 +0100 Subject: [PATCH 8/9] actually fix backtick macro expansion --- .../JSONSchemaMacro/Schemable/SchemaGenerator.swift | 10 +++++++++- .../JSONSchemaMacro/Schemable/SchemableEnumCase.swift | 6 +++++- .../Schemable/SwiftSyntaxExtensions.swift | 7 +++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index ed7d1016..e37836b3 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -18,6 +18,7 @@ struct EnumSchemaGenerator { let name: TokenSyntax let members: MemberBlockItemListSyntax let attributes: AttributeListSyntax + let isStringBacked: Bool init(fromEnum enumDecl: EnumDeclSyntax, accessLevel: String? = nil) { // Use provided access level if available, otherwise use the declaration's modifier @@ -33,10 +34,17 @@ struct EnumSchemaGenerator { name = enumDecl.name.trimmed members = enumDecl.memberBlock.members attributes = enumDecl.attributes + + // Check if enum inherits from String + isStringBacked = + enumDecl.inheritanceClause?.inheritedTypes + .contains { type in + type.type.as(IdentifierTypeSyntax.self)?.name.text == "String" + } ?? false } func makeSchema() -> DeclSyntax { - let schemableCases = members.schemableEnumCases() + let schemableCases = members.schemableEnumCases(isStringBacked: isStringBacked) let casesWithoutAssociatedValues = schemableCases.filter { $0.associatedValues == nil } let casesWithAssociatedValues = schemableCases.filter { $0.associatedValues != nil } diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift index c798582a..32527d75 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift @@ -5,12 +5,13 @@ struct SchemableEnumCase { let associatedValues: EnumCaseParameterListSyntax? let rawValue: String? - init(enumCaseDecl: EnumCaseDeclSyntax, caseElement: EnumCaseElementSyntax) { + init(enumCaseDecl: EnumCaseDeclSyntax, caseElement: EnumCaseElementSyntax, isStringBacked: Bool) { identifier = caseElement.name.trimmed associatedValues = caseElement.parameterClause?.parameters // Extract raw value if present (for String-backed enums) if let rawValueExpr = caseElement.rawValue?.value.as(StringLiteralExprSyntax.self) { + // Explicit raw value rawValue = rawValueExpr.segments .compactMap { segment -> String? in if case .stringSegment(let stringSegment) = segment { @@ -19,6 +20,9 @@ struct SchemableEnumCase { return nil } .joined() + } else if isStringBacked { + // Implicit raw value: use the case name (without backticks) for String-backed enums + rawValue = identifier.text.trimmingBackticks() } else { rawValue = nil } diff --git a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift index 843c0352..6342a5dc 100644 --- a/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift +++ b/Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift @@ -261,9 +261,12 @@ extension MemberBlockItemListSyntax { .compactMap(SchemableMember.init) } - func schemableEnumCases() -> [SchemableEnumCase] { + func schemableEnumCases(isStringBacked: Bool) -> [SchemableEnumCase] { self.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } - .flatMap { caseDecl in caseDecl.elements.map { (caseDecl, $0) } }.map(SchemableEnumCase.init) + .flatMap { caseDecl in caseDecl.elements.map { (caseDecl, $0) } } + .map { + SchemableEnumCase(enumCaseDecl: $0.0, caseElement: $0.1, isStringBacked: isStringBacked) + } } /// Extracts CodingKeys mapping from a CodingKeys enum if present From b04b2f8547634d2809fa56a925befe072b5e815a Mon Sep 17 00:00:00 2001 From: Andre Navarro Date: Wed, 5 Nov 2025 15:00:05 +0100 Subject: [PATCH 9/9] of course generated schema needs back ticks since its swift code --- Tests/JSONSchemaMacroTests/BacktickEnumTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift index 86c960a0..301fdafe 100644 --- a/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift +++ b/Tests/JSONSchemaMacroTests/BacktickEnumTests.swift @@ -32,9 +32,9 @@ import Testing .compactMap { switch $0 { case "default": - return Self.default + return Self.`default` case "public": - return Self.public + return Self.`public` case "normal": return Self.normal default: @@ -78,9 +78,9 @@ import Testing .compactMap { switch $0 { case "default_value": - return Self.default + return Self.`default` case "public": - return Self.public + return Self.`public` case "normal": return Self.normal default: