Skip to content

Commit ed9f6b5

Browse files
feat: Add LightWeight GraphQL Client (#12)
feat: Add LightWeight GraphQL Client - It uses query strings and filename to specify query. - Encodable structures to pass variables
2 parents 242149f + 5e52365 commit ed9f6b5

File tree

9 files changed

+507
-1
lines changed

9 files changed

+507
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ DerivedData/
66
.swiftpm/configuration/registries.json
77
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
88
.netrc
9+
**/Package.resolved

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ let package = Package(
2323
.product(name: "ResourceExtension", package: "opentelemetry-swift"),
2424
]
2525
),
26+
.testTarget(
27+
name: "CommonTests",
28+
dependencies: [
29+
"Common"
30+
],
31+
resources: [.process("GraphQL/Queries")]
32+
),
2633
.target(name: "CrashReporter"),
2734
.target(
2835
name: "CrashReporterLive",

Sources/Common/AtomicDictionary.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ where Key: Hashable, Key: Sendable {
2020
}
2121

2222
public var debugDescription: String {
23-
return storage.debugDescription
23+
return queue.sync { storage.debugDescription }
2424
}
2525
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Foundation
2+
3+
public final class GraphQLClient {
4+
private let endpoint: URL
5+
private let network: NetworkClient
6+
private let decoder: JSONDecoder
7+
private let defaultHeaders: [String: String]
8+
9+
public init(endpoint: URL,
10+
network: NetworkClient = URLSessionNetworkClient(),
11+
decoder: JSONDecoder = JSONDecoder(),
12+
defaultHeaders: [String: String] = [
13+
"Content-Type": "application/json",
14+
"Accept": "application/json"
15+
]) {
16+
self.endpoint = endpoint
17+
self.network = network
18+
self.decoder = decoder
19+
self.defaultHeaders = defaultHeaders
20+
}
21+
22+
/// Execute a GraphQL operation from stirng query
23+
/// - Parameters:
24+
/// - query: Query in graphql format
25+
/// - variables: Codable variables (optional)
26+
/// - operationName: Operation name (optional)
27+
/// - headers: Extra headers (merged over defaultHeaders)
28+
public func execute<Variables: Encodable, Output: Decodable>(
29+
query: String,
30+
variables: Variables? = nil,
31+
operationName: String? = nil,
32+
headers: [String: String] = [:]
33+
) async throws -> Output {
34+
let gqlRequest = GraphQLRequest(query: query, variables: variables, operationName: operationName)
35+
var request = URLRequest(url: endpoint)
36+
request.httpMethod = "POST"
37+
request.httpBody = try gqlRequest.httpBody()
38+
39+
let combinedHeaders = defaultHeaders.merging(headers) { _, new in new }
40+
combinedHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
41+
42+
let data = try await network.send(request)
43+
44+
do {
45+
let envelope = try decoder.decode(GraphQLResponse<Output>.self, from: data)
46+
if let errors = envelope.errors, !errors.isEmpty {
47+
throw GraphQLClientError.graphQLErrors(errors)
48+
}
49+
guard let value = envelope.data else {
50+
throw GraphQLClientError.missingData
51+
}
52+
return value
53+
} catch {
54+
throw GraphQLClientError.decoding(error)
55+
}
56+
}
57+
58+
/// Execute a GraphQL operation where the query is loaded from a .graphql file in a bundle.
59+
/// - Parameters:
60+
/// - resource: Filename without extension (e.g., "GetUser")
61+
/// - ext: File extension (defaults to "graphql")
62+
/// - bundle: Bundle to search (defaults to .main)
63+
/// - variables: Codable variables (optional)
64+
/// - operationName: Operation name (optional)
65+
/// - headers: Extra headers (merged over defaultHeaders)
66+
public func executeFromFile<Variables: Encodable, Output: Decodable>(
67+
resource: String,
68+
ext: String = "graphql",
69+
bundle: Bundle = Bundle.main,
70+
variables: Variables? = nil,
71+
operationName: String? = nil,
72+
headers: [String: String] = [:]
73+
) async throws -> Output {
74+
guard let url = bundle.url(forResource: resource, withExtension: ext) else {
75+
throw GraphQLClientError.queryFileNotFound("\(resource).\(ext)")
76+
}
77+
78+
let query: String
79+
do {
80+
query = try String(contentsOf: url, encoding: .utf8)
81+
} catch {
82+
throw GraphQLClientError.unreadableQueryFile(url, error)
83+
}
84+
85+
return try await execute(
86+
query: query,
87+
variables: variables,
88+
operationName: operationName,
89+
headers: headers
90+
)
91+
}
92+
}
93+
94+
/// Standard GraphQL envelope: { data, errors }
95+
private struct GraphQLResponse<DataType: Decodable>: Decodable {
96+
let data: DataType?
97+
let errors: [GraphQLError]?
98+
}
99+
100+
public enum GraphQLClientError: Error, CustomStringConvertible {
101+
case graphQLErrors([GraphQLError])
102+
case missingData
103+
case decoding(Error)
104+
case queryFileNotFound(String)
105+
case unreadableQueryFile(URL, Error?)
106+
107+
public var description: String {
108+
switch self {
109+
case .graphQLErrors(let errors):
110+
return "GraphQL errors: \(errors.map(\.message).joined(separator: " | "))"
111+
case .missingData:
112+
return "Missing `data` in GraphQL response"
113+
case .decoding(let error):
114+
return "Decoding error: \(error)"
115+
case .queryFileNotFound(let name):
116+
return "GraphQL file '\(name)' not found in bundle"
117+
case .unreadableQueryFile(let url, let err):
118+
return "Could not read GraphQL file at \(url). \(err?.localizedDescription ?? "")"
119+
}
120+
}
121+
}
122+
123+
public struct GraphQLError: Decodable {
124+
public let message: String
125+
}
126+
127+
public struct GraphQLEmptyData: Decodable {}
128+
129+
extension GraphQLClient {
130+
public func executeIgnoringData<Variables: Encodable>(
131+
query: String,
132+
variables: Variables? = nil,
133+
operationName: String? = nil,
134+
headers: [String: String] = [:]
135+
) async throws {
136+
// Reuse EmptyData so decoding still works with `{ "data": null }` or `{ "data": {} }`
137+
_ = try await execute(
138+
query: query,
139+
variables: variables,
140+
operationName: operationName,
141+
headers: headers
142+
) as GraphQLEmptyData
143+
}
144+
145+
public func executeFromFileIgnoringData<Variables: Encodable>(
146+
resource: String,
147+
ext: String = "graphql",
148+
bundle: Bundle = .main,
149+
variables: Variables? = nil,
150+
operationName: String? = nil,
151+
headers: [String: String] = [:]
152+
) async throws {
153+
_ = try await executeFromFile(
154+
resource: resource,
155+
ext: ext,
156+
bundle: bundle,
157+
variables: variables,
158+
operationName: operationName,
159+
headers: headers
160+
) as GraphQLEmptyData
161+
}
162+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// Encodes the GraphQL POST body: { query, variables, operationName }
4+
public struct GraphQLRequest<Variables: Encodable>: Encodable {
5+
public let query: String
6+
public let variables: Variables?
7+
public let operationName: String?
8+
9+
public init(query: String,
10+
variables: Variables? = nil,
11+
operationName: String? = nil) {
12+
self.query = query
13+
self.variables = variables
14+
self.operationName = operationName
15+
}
16+
17+
/// Create the HTTP body data for this request.
18+
/// You can reuse this anywhere you need to build a GraphQL body.
19+
public func httpBody(encoder: JSONEncoder = JSONEncoder()) throws -> Data {
20+
try encoder.encode(self)
21+
}
22+
23+
private enum CodingKeys: String, CodingKey {
24+
case query
25+
case variables
26+
case operationName
27+
}
28+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
3+
public enum NetworkError: Error, CustomStringConvertible {
4+
case invalidResponse
5+
case httpStatus(Int, data: Data?)
6+
case transport(Error)
7+
8+
public var description: String {
9+
switch self {
10+
case .invalidResponse: return "Invalid response type"
11+
case .httpStatus(let code, _): return "HTTP status \(code)"
12+
case .transport(let error): return "Transport error: \(error)"
13+
}
14+
}
15+
}
16+
17+
public protocol NetworkClient {
18+
func send(_ request: URLRequest) async throws -> Data
19+
}
20+
21+
public final class URLSessionNetworkClient: NetworkClient {
22+
private let session: URLSession
23+
24+
public init(session: URLSession = .shared) {
25+
self.session = session
26+
}
27+
28+
public func send(_ request: URLRequest) async throws -> Data {
29+
do {
30+
let (data, response) = try await session.data(for: request)
31+
guard let http = response as? HTTPURLResponse else {
32+
throw NetworkError.invalidResponse
33+
}
34+
guard (200...299).contains(http.statusCode) else {
35+
throw NetworkError.httpStatus(http.statusCode, data: data)
36+
}
37+
return data
38+
} catch {
39+
throw NetworkError.transport(error)
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)