Skip to content

Commit 09a0777

Browse files
feat: Revise Scalar and remove default impl
The default is very unperformant, as it requires a full encode/decode step.
1 parent c6d872a commit 09a0777

4 files changed

Lines changed: 121 additions & 90 deletions

File tree

Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,43 @@ public class Context: @unchecked Sendable {
1717
}
1818
}
1919
// Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol
20-
struct DateTime: Scalar {}
20+
public struct EmailAddress: Scalar {
21+
let email: String
22+
23+
init(email: String) {
24+
self.email = email
25+
}
26+
27+
// Codability conformance. Required for usage in InputObject
28+
public init(from decoder: any Decoder) throws {
29+
self.email = try decoder.singleValueContainer().decode(String.self)
30+
}
31+
public func encode(to encoder: any Encoder) throws {
32+
try self.email.encode(to: encoder)
33+
}
34+
35+
// Scalar conformance. Not necessary, but default methods are very inefficient.
36+
public static func serialize(this: Self) throws -> Map {
37+
return .string(this.email)
38+
}
39+
public static func parseValue(map: Map) throws -> Map {
40+
switch map {
41+
case .string:
42+
return map
43+
default:
44+
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
45+
}
46+
}
47+
public static func parseLiteral(value: any Value) throws -> Map {
48+
guard let ast = value as? StringValue else {
49+
throw GraphQLError(
50+
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
51+
nodes: [value]
52+
)
53+
}
54+
return .string(ast.value)
55+
}
56+
}
2157

2258
// Now create types that conform to the expected protocols
2359
struct Resolvers: ResolversProtocol {
@@ -39,8 +75,8 @@ struct User: UserProtocol {
3975
func name(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String {
4076
return name
4177
}
42-
func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String {
43-
return email
78+
func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress {
79+
return EmailAddress(email: email)
4480
}
4581
func age(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> Int? {
4682
return age
@@ -54,8 +90,8 @@ struct Contact: ContactProtocol {
5490
let email: String
5591

5692
// Required implementations
57-
func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String {
58-
return email
93+
func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress {
94+
return EmailAddress(email: email)
5995
}
6096
}
6197
struct Post: PostProtocol {
@@ -105,7 +141,7 @@ struct Mutation: MutationProtocol {
105141
let user = User(
106142
id: userInfo.id,
107143
name: userInfo.name,
108-
email: userInfo.email,
144+
email: userInfo.email.email,
109145
age: userInfo.age,
110146
role: userInfo.role
111147
)

Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
scalar EmailAddress
2+
13
interface HasEmail {
24
"""
35
An email address
46
"""
5-
email: String!
7+
email: EmailAddress!
68
}
79

810
union UserOrPost = User | Post
911

1012
input UserInfo {
1113
id: ID!
1214
name: String!
13-
email: String!
15+
email: EmailAddress!
1416
age: Int
1517
role: Role
1618
}
@@ -32,7 +34,7 @@ type User implements HasEmail {
3234
"""
3335
The user's email address
3436
"""
35-
email: String!
37+
email: EmailAddress!
3638

3739
"""
3840
The user's age
@@ -46,7 +48,7 @@ type User implements HasEmail {
4648
}
4749

4850
type Contact implements HasEmail {
49-
email: String!
51+
email: EmailAddress!
5052
}
5153

5254
"""

README.md

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A Swift package plugin that generates server-side GraphQL API code from GraphQL schema files, inspired by [GraphQL Tools' makeExecutableSchema](https://the-guild.dev/graphql/tools/docs/generate-schema).
44

5-
This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while maintaining full control over your business logic.
5+
This tool uses [GraphQL Swift](https://github.com/GraphQLSwift/GraphQL) to generate type-safe Swift code and protocol stubs from your GraphQL schema files, eliminating boilerplate while allowing you full control over your business logic.
66

77
## Features
88

@@ -44,14 +44,12 @@ Create a `.graphql` file in your target's `Sources` directory:
4444
**Sources/YourTarget/schema.graphql**:
4545
```graphql
4646
type User {
47-
id: ID!
4847
name: String!
49-
email: String!
48+
email: EmailAddress!
5049
}
5150

5251
type Query {
53-
user(id: ID!): User
54-
users: [User!]!
52+
user: User
5553
}
5654
```
5755

@@ -71,11 +69,7 @@ public actor Context {
7169
}
7270
```
7371

74-
Create any scalar types (with names matching GraphQL), and conform them to `Scalar`:
75-
76-
```swift
77-
struct DateTime: Scalar {}
78-
```
72+
Create any scalar types (with names matching GraphQL), and conform them to `Scalar`. See the `Scalars` usage section below for details.
7973

8074
Create a resolvers struct with the required typealiases:
8175
```swift
@@ -85,8 +79,7 @@ struct Resolvers: ResolversProtocol {
8579
}
8680
```
8781

88-
As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol.
89-
Here's a small example of a schema that allows querying for the current user, who is only identified by an email address:
82+
As you build the `Query` and `Mutation` types and their resolution logic, you will be forced to define a concrete type for every reachable GraphQL result, according to its generated protocol:
9083

9184
```swift
9285
struct Query: QueryProtocol {
@@ -99,12 +92,16 @@ struct Query: QueryProtocol {
9992

10093
struct User: UserProtocol {
10194
// You can define the type internals however you like
95+
let name: String
10296
let email: String
10397

104-
// This is required by `UserProtocol`, and used by GraphQL field resolution.
105-
func email(context: Context, info: GraphQL.GraphQLResolveInfo) async throws -> String {
98+
// These are required by `UserProtocol`, and used by GraphQL field resolution.
99+
func name(context: Context, info: GraphQLResolveInfo) async throws -> String {
100+
return name
101+
}
102+
func email(context: Context, info: GraphQLResolveInfo) async throws -> EmailAddress {
106103
// You can implement resolution logic however you like.
107-
return email
104+
return EmailAddress(email: self.email)
108105
}
109106
}
110107
```
@@ -121,6 +118,54 @@ let result = try await graphql(schema: schema, request: "{ users { name email }
121118
print(result)
122119
```
123120

121+
## Detailed Usage
122+
123+
### Scalars
124+
125+
Scalar types must be provided for each GraphQL scalar. Since GraphQL uses a different serialization system than Swift, you must conform the type to Swift's `Codable` and GraphQL's `Scalar`, and have them agree on a representation.
126+
127+
Below is an example that represents a scalar struct as a raw String:
128+
129+
```swift
130+
public struct EmailAddress: Scalar {
131+
let email: String
132+
133+
init(email: String) {
134+
self.email = email
135+
}
136+
137+
// Codability conformance. Represent simply as `email` string.
138+
public init(from decoder: any Decoder) throws {
139+
self.email = try decoder.singleValueContainer().decode(String.self)
140+
}
141+
public func encode(to encoder: any Encoder) throws {
142+
try self.email.encode(to: encoder)
143+
}
144+
145+
// Scalar conformance. Parse & serialize simply as `email` string.
146+
public static func serialize(this: Self) throws -> Map {
147+
return .string(this.email)
148+
}
149+
public static func parseValue(map: Map) throws -> Map {
150+
switch map {
151+
case .string:
152+
return map
153+
default:
154+
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
155+
}
156+
}
157+
public static func parseLiteral(value: any Value) throws -> Map {
158+
guard let ast = value as? StringValue else {
159+
throw GraphQLError(
160+
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
161+
nodes: [value]
162+
)
163+
}
164+
return .string(ast.value)
165+
}
166+
}
167+
```
168+
124169
## Development Roadmap
125170

126171
1. Default values: Default values are currently ignored
Lines changed: 13 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,25 @@
11
import GraphQL
22
import OrderedCollections
33

4-
public protocol Scalar: Sendable {
5-
static func serialize(any: Any) throws -> Map
4+
public protocol Scalar: Sendable, Codable {
5+
static func serialize(this: Self) throws -> Map
66
static func parseValue(map: Map) throws -> Map
77
static func parseLiteral(value: any Value) throws -> Map
88
}
99

10+
// Graphiti provides default serializations that the underlying type's Codability requirements, but they are very
11+
// inefficient. They typically pass through a full serialize/deserialize step on each call.
12+
// Because of this, we have chosen not to vend defaults to force the user to implement more performant versions.
13+
1014
public extension Scalar {
15+
/// This wraps the GraphQLScalar definition in a type-safe one
1116
static func serialize(any: Any) throws -> Map {
12-
return try Map(any: any)
13-
}
14-
static func parseValue(map: Map) throws -> Map {
15-
return map
16-
}
17-
static func parseLiteral(value: any Value) throws -> Map {
18-
return value.map
19-
}
20-
}
21-
22-
23-
extension GraphQL.Value {
24-
var map: Map {
25-
if
26-
let value = self as? BooleanValue
27-
{
28-
return .bool(value.value)
29-
}
30-
31-
if
32-
let value = self as? IntValue,
33-
let int = Int(value.value)
34-
{
35-
return .int(int)
36-
}
37-
38-
if
39-
let value = self as? FloatValue,
40-
let double = Double(value.value)
41-
{
42-
return .double(double)
17+
// We should always get a value of `Self` for custom scalars.
18+
guard let scalar = any as? Self else {
19+
throw GraphQLError(
20+
message: "Serialize expected type \(Self.self) but got \(type(of: any))"
21+
)
4322
}
44-
45-
if
46-
let value = self as? StringValue
47-
{
48-
return .string(value.value)
49-
}
50-
51-
if
52-
let value = self as? EnumValue
53-
{
54-
return .string(value.value)
55-
}
56-
57-
if
58-
let value = self as? ListValue
59-
{
60-
let array = value.values.map { $0.map }
61-
return .array(array)
62-
}
63-
64-
if
65-
let value = self as? ObjectValue
66-
{
67-
let dictionary: OrderedDictionary<String, Map> = value.fields
68-
.reduce(into: [:]) { result, field in
69-
result[field.name.value] = field.value.map
70-
}
71-
72-
return .dictionary(dictionary)
73-
}
74-
75-
return .null
23+
return try Self.serialize(this: scalar)
7624
}
7725
}

0 commit comments

Comments
 (0)