From 3e99f45cbaff639418a9ad56125573a44daa93f9 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 11:56:54 -0700 Subject: [PATCH 1/3] feat: Adds custom encoder/decoder support --- .../GraphQLHummingbird/GraphQLConfig.swift | 57 +++++++++++++++++++ .../HTTP/GraphQLHandler+HTTP.swift | 12 ++-- .../HTTPStatusCodeGraphQLJSONTests.swift | 4 +- .../HTTPStatusCodeJSONTests.swift | 4 +- Tests/GraphQLHummingbirdTests/HTTPTests.swift | 28 ++++----- .../WebSocketTests.swift | 4 +- 6 files changed, 83 insertions(+), 26 deletions(-) diff --git a/Sources/GraphQLHummingbird/GraphQLConfig.swift b/Sources/GraphQLHummingbird/GraphQLConfig.swift index 080ae78..665fdb1 100644 --- a/Sources/GraphQLHummingbird/GraphQLConfig.swift +++ b/Sources/GraphQLHummingbird/GraphQLConfig.swift @@ -1,9 +1,12 @@ +import Foundation import GraphQL +import Hummingbird /// Configuration options for GraphQLHummingbird public struct GraphQLConfig: Sendable { let allowGet: Bool let allowMissingAcceptHeader: Bool + let coders: Coders let ide: IDE let subscriptionProtocols: Set let websocket: WebSocket @@ -20,6 +23,7 @@ public struct GraphQLConfig: Send public init( allowGet: Bool = true, allowMissingAcceptHeader: Bool = false, + coders: Coders = .init(), ide: IDE = .graphiql, subscriptionProtocols: Set = [.websocket], websocket: WebSocket = .init( @@ -31,11 +35,34 @@ public struct GraphQLConfig: Send self.allowGet = allowGet self.allowMissingAcceptHeader = allowMissingAcceptHeader self.additionalValidationRules = additionalValidationRules + self.coders = coders self.ide = ide self.subscriptionProtocols = subscriptionProtocols self.websocket = websocket } + public struct Coders: Sendable { + public let graphQLJSONEncoder: GraphQLJSONEncoder + public let jsonDecoder: JSONDecoder + public let jsonEncoder: JSONEncoder + public let urlEncodedFormDecoder: URLEncodedFormDecoder + public let urlEncodedFormEncoder: URLEncodedFormEncoder + + public init( + graphQLJSONEncoder: GraphQLJSONEncoder? = nil, + jsonDecoder: JSONDecoder? = nil, + jsonEncoder: JSONEncoder? = nil, + urlEncodedFormDecoder: URLEncodedFormDecoder? = nil, + urlEncodedFormEncoder: URLEncodedFormEncoder? = nil + ) { + self.graphQLJSONEncoder = graphQLJSONEncoder ?? defaultGraphQLJSONEncoder + self.jsonDecoder = jsonDecoder ?? defaultJSONDecoder + self.jsonEncoder = jsonEncoder ?? defaultJSONEncoder + self.urlEncodedFormDecoder = urlEncodedFormDecoder ?? defaultURLEncodedFormDecoder + self.urlEncodedFormEncoder = urlEncodedFormEncoder ?? defaultURLEncodedFormEncoder + } + } + public struct IDE: Sendable, Equatable { /// GraphiQL: https://github.com/graphql/graphiql public static var graphiql: Self { @@ -80,3 +107,33 @@ public struct GraphQLConfig: Send } } } + +let defaultGraphQLJSONEncoder: GraphQLJSONEncoder = { + let encoder = GraphQLJSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder +}() + +let defaultJSONDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder +}() + +let defaultJSONEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder +}() + +let defaultURLEncodedFormDecoder: URLEncodedFormDecoder = { + var decoder = URLEncodedFormDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder +}() + +let defaultURLEncodedFormEncoder: URLEncodedFormEncoder = { + var encoder = URLEncodedFormEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder +}() diff --git a/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift b/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift index 07f47ce..79ac056 100644 --- a/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift +++ b/Sources/GraphQLHummingbird/HTTP/GraphQLHandler+HTTP.swift @@ -44,13 +44,13 @@ extension GraphQLHandler { switch mediaType { case .applicationJson, .applicationJsonGraphQL: do { - graphQLRequest = try await JSONDecoder().decode(GraphQLRequest.self, from: request, context: context) + graphQLRequest = try await config.coders.jsonDecoder.decode(GraphQLRequest.self, from: request, context: context) } catch { throw HTTPError(.badRequest, message: error.localizedDescription) } case .applicationUrlEncoded: do { - graphQLRequest = try await URLEncodedFormDecoder().decode(GraphQLRequest.self, from: request, context: context) + graphQLRequest = try await config.coders.urlEncodedFormDecoder.decode(GraphQLRequest.self, from: request, context: context) } catch { throw HTTPError(.badRequest, message: error.localizedDescription) } @@ -109,19 +109,19 @@ extension GraphQLHandler { // Try to respond with the best matching media type, in order for mediaType in acceptedTypes { if MediaType.applicationJsonGraphQL.isType(mediaType) { - return try GraphQLJSONEncoder().encode(result, from: request, context: context) + return try config.coders.graphQLJSONEncoder.encode(result, from: request, context: context) } if MediaType.applicationJson.isType(mediaType) { - return try JSONEncoder().encode(result, from: request, context: context) + return try config.coders.jsonEncoder.encode(result, from: request, context: context) } if MediaType.applicationUrlEncoded.isType(mediaType) { - return try URLEncodedFormEncoder().encode(result, from: request, context: context) + return try config.coders.urlEncodedFormEncoder.encode(result, from: request, context: context) } } // Use the default if configured to do so if config.allowMissingAcceptHeader { - return try GraphQLJSONEncoder().encode(result, from: request, context: context) + return try config.coders.graphQLJSONEncoder.encode(result, from: request, context: context) } // Fail diff --git a/Tests/GraphQLHummingbirdTests/HTTPStatusCodeGraphQLJSONTests.swift b/Tests/GraphQLHummingbirdTests/HTTPStatusCodeGraphQLJSONTests.swift index c2d756c..a70322c 100644 --- a/Tests/GraphQLHummingbirdTests/HTTPStatusCodeGraphQLJSONTests.swift +++ b/Tests/GraphQLHummingbirdTests/HTTPStatusCodeGraphQLJSONTests.swift @@ -181,12 +181,12 @@ struct HTTPStatusCodeGraphQLJSONTests { uri: "/graphql", method: .post, headers: jsonGraphQLHeaders, - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ error }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ error }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(!result.errors.isEmpty) #expect(result.errors.first?.message == "Something went wrong") } diff --git a/Tests/GraphQLHummingbirdTests/HTTPStatusCodeJSONTests.swift b/Tests/GraphQLHummingbirdTests/HTTPStatusCodeJSONTests.swift index 7ef2384..4ac15f1 100644 --- a/Tests/GraphQLHummingbirdTests/HTTPStatusCodeJSONTests.swift +++ b/Tests/GraphQLHummingbirdTests/HTTPStatusCodeJSONTests.swift @@ -181,12 +181,12 @@ struct HTTPStatusCodeJSONTests { uri: "/graphql", method: .post, headers: jsonGraphQLHeaders, - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ error }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ error }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(!result.errors.isEmpty) #expect(result.errors.first?.message == "Something went wrong") } diff --git a/Tests/GraphQLHummingbirdTests/HTTPTests.swift b/Tests/GraphQLHummingbirdTests/HTTPTests.swift index 2fe59a7..d6a26af 100644 --- a/Tests/GraphQLHummingbirdTests/HTTPTests.swift +++ b/Tests/GraphQLHummingbirdTests/HTTPTests.swift @@ -22,12 +22,12 @@ struct HTTPTests { uri: "/graphql", method: .post, headers: jsonGraphQLHeaders, - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ hello }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["hello"] == "World") #expect(result.errors.isEmpty) } @@ -66,7 +66,7 @@ struct HTTPTests { uri: "/graphql", method: .post, headers: jsonGraphQLHeaders, - body: .init(data: JSONEncoder().encode(GraphQLRequest( + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest( query: "query Greet($name: String) { greet(name: $name) }", variables: ["name": "Alice"] ))) @@ -74,7 +74,7 @@ struct HTTPTests { #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["greet"] == "Hello, Alice") #expect(result.errors.isEmpty) } @@ -114,12 +114,12 @@ struct HTTPTests { uri: "/graphql", method: .post, headers: jsonGraphQLHeaders, - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ contextMessage }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ contextMessage }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["contextMessage"] == "Hello from context!") #expect(result.errors.isEmpty) } @@ -141,12 +141,12 @@ struct HTTPTests { .accept: MediaType.applicationJson.description, .contentType: MediaType.applicationJsonGraphQL.description, ], - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ hello }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["hello"] == "World") #expect(result.errors.isEmpty) } @@ -168,12 +168,12 @@ struct HTTPTests { .accept: MediaType.applicationJsonGraphQL.description, .contentType: MediaType.applicationJson.description, ], - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ hello }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["hello"] == "World") #expect(result.errors.isEmpty) } @@ -194,7 +194,7 @@ struct HTTPTests { headers: [ .contentType: MediaType.applicationJsonGraphQL.description, ], - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ hello }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) ) { response in #expect(response.status == .notAcceptable) } @@ -215,12 +215,12 @@ struct HTTPTests { headers: [ .contentType: MediaType.applicationJsonGraphQL.description, ], - body: .init(data: JSONEncoder().encode(GraphQLRequest(query: "{ hello }"))) + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) ) { response in #expect(response.status == .ok) #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["hello"] == "World") #expect(result.errors.isEmpty) } @@ -242,7 +242,7 @@ struct HTTPTests { ) { response in #expect(response.status == .ok) - let result = try JSONDecoder().decode(GraphQLResult.self, from: response.body) + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) #expect(result.data?["hello"] == "World") #expect(result.errors.isEmpty) } diff --git a/Tests/GraphQLHummingbirdTests/WebSocketTests.swift b/Tests/GraphQLHummingbirdTests/WebSocketTests.swift index 656fabf..52c73fb 100644 --- a/Tests/GraphQLHummingbirdTests/WebSocketTests.swift +++ b/Tests/GraphQLHummingbirdTests/WebSocketTests.swift @@ -11,8 +11,8 @@ import Testing @Suite struct WebSocketTests { - let decoder = JSONDecoder() - let encoder = GraphQLJSONEncoder() + let decoder = defaultJSONDecoder + let encoder = defaultGraphQLJSONEncoder @Test func subscription() async throws { let pubsub = SimplePubSub() From 8e86c39990af2fc98d61be60e3e1a7c0ff90d073 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 11:57:00 -0700 Subject: [PATCH 2/3] test: Adds simple test --- Tests/GraphQLHummingbirdTests/HTTPTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Tests/GraphQLHummingbirdTests/HTTPTests.swift b/Tests/GraphQLHummingbirdTests/HTTPTests.swift index d6a26af..2755361 100644 --- a/Tests/GraphQLHummingbirdTests/HTTPTests.swift +++ b/Tests/GraphQLHummingbirdTests/HTTPTests.swift @@ -311,4 +311,35 @@ struct HTTPTests { } } } + + @Test func customEncoder() async throws { + let graphQLJSONEncoder = GraphQLJSONEncoder() + graphQLJSONEncoder.dateEncodingStrategy = .secondsSince1970 + let router = Router() + router.graphql( + schema: helloWorldSchema, + config: .init( + coders: .init(graphQLJSONEncoder: graphQLJSONEncoder) + ) + ) { _, _ in + EmptyContext() + } + let app = Application(router: router) + + try await app.test(.router) { client in + try await client.execute( + uri: "/graphql", + method: .post, + headers: jsonGraphQLHeaders, + body: .init(data: defaultJSONEncoder.encode(GraphQLRequest(query: "{ hello }"))) + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "application/graphql-response+json; charset=utf-8") + + let result = try defaultJSONDecoder.decode(GraphQLResult.self, from: response.body) + #expect(result.data?["hello"] == "World") + #expect(result.errors.isEmpty) + } + } + } } From 22de539c712e3b5a1a1e18becda7f88868eaf9ed Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 8 Feb 2026 11:57:29 -0700 Subject: [PATCH 3/3] docs: Adds readme section about coders --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index fd0f3df..3f4594f 100644 --- a/README.md +++ b/README.md @@ -127,3 +127,25 @@ let app = Application( ``` The example above follows Hummingbird best practices when it uses a separate router for HTTP and WebSocket requests. For more details, see the [Hummingbird WebSocket documentation](https://docs.hummingbird.codes/2.0/documentation/hummingbird/websocketserverupgrade#Overview). + +### Custom Encoding/Decoding + +You can set custom encoders and decoders using the `Config.coders` argument: + +```swift +// Configure custom JSON encoder +let graphQLJSONEncoder = GraphQLJSONEncoder() +graphQLJSONEncoder.dateEncodingStrategy = .millisecondsSince1970 + +// Inject it using the `config.coders` argument +router.graphql( + schema: schema, + config: .init( + coders: .init(graphQLJSONEncoder: graphQLJSONEncoder) + ) +) { _, _ in + GraphQLContext() +} +``` + +Like [Hummingbird](https://docs.hummingbird.codes/2.0/documentation/hummingbird/responseencoding#Date-encoding), all default GraphQL encoders and decoders use the standard settings with ISO8601 date formatting.