Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -275,7 +279,7 @@ enum TemperatureType: String {
will expand to:

```swift
enum TemperatureType: String {
enum TemperatureType {
case fahrenheit
case celsius
case kelvin
Expand All @@ -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<Size> {
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
Expand Down
37 changes: 29 additions & 8 deletions Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

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
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
Expand All @@ -23,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 }
Expand Down Expand Up @@ -73,19 +91,22 @@ 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 (without backticks)
let matchValue = enumCase.rawValue ?? enumCase.identifier.text.trimmingBackticks()
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)
}
Expand Down
25 changes: 23 additions & 2 deletions Sources/JSONSchemaMacro/Schemable/SchemableEnumCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,37 @@ import SwiftSyntax
struct SchemableEnumCase {
let identifier: TokenSyntax
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 {
return stringSegment.content.text
}
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
}
}

func generateSchema() -> CodeBlockItemSyntax? {
guard let associatedValues else {
// Use raw value if present, otherwise use case name (without backticks)
let enumValue = rawValue ?? identifier.text.trimmingBackticks()
return """
"\(identifier)"
"\(raw: enumValue)"
"""
}
let properties: [CodeBlockItemSyntax] = associatedValues.enumerated()
Expand Down
7 changes: 5 additions & 2 deletions Sources/JSONSchemaMacro/Schemable/SwiftSyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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\"")
}
}
}
2 changes: 1 addition & 1 deletion Tests/JSONSchemaIntegrationTests/PollExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ struct PollExampleTests {
"entertainment": {
"_0": {
"genre": "movies",
"ageRating": "pg13"
"ageRating": "Parental Guidance Suggested 13+"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
"properties" : {
"ageRating" : {
"enum" : [
"g",
"pg",
"pg13",
"r"
"General Audience",
"Parental Guidance Suggested",
"Parental Guidance Suggested 13+",
"Restricted"
],
"type" : "string"
},
Expand Down
Loading