Type-safe GraphQL client generator for Gleam
1import gleam/dynamic/decode
2import gleam/http
3import gleam/http/request.{type Request}
4import gleam/json
5import gleam/list
6import gleam/result
7import gleam/string
8
9@target(erlang)
10import argv
11
12@target(erlang)
13import gleam/io
14
15@target(erlang)
16import gleam/httpc
17
18@target(erlang)
19import simplifile
20
21@target(erlang)
22import squall/internal/codegen
23
24@target(erlang)
25import squall/internal/discovery
26
27@target(erlang)
28import squall/internal/error
29
30@target(erlang)
31import squall/internal/graphql_ast
32
33@target(erlang)
34import squall/internal/schema
35
36@target(erlang)
37import squall/internal/query_extractor
38
39@target(erlang)
40import squall/internal/registry_codegen
41
42@target(erlang)
43import squall/internal/typename_injector
44
45/// A GraphQL client with endpoint and headers configuration.
46/// This client follows the sans-io pattern: it builds HTTP requests but doesn't send them.
47/// You must use your own HTTP client to send the requests.
48pub type Client {
49 Client(endpoint: String, headers: List(#(String, String)))
50}
51
52/// Create a new GraphQL client with custom headers.
53///
54/// ## Example
55///
56/// ```gleam
57/// let client = squall.new("https://api.example.com/graphql", [])
58/// ```
59pub fn new(endpoint: String, headers: List(#(String, String))) -> Client {
60 Client(endpoint: endpoint, headers: headers)
61}
62
63/// Create a new GraphQL client with bearer token authentication.
64///
65/// ## Example
66///
67/// ```gleam
68/// let client = squall.new_with_auth("https://api.example.com/graphql", "my-token")
69/// ```
70pub fn new_with_auth(endpoint: String, token: String) -> Client {
71 Client(endpoint: endpoint, headers: [#("Authorization", "Bearer " <> token)])
72}
73
74/// Prepare an HTTP request for a GraphQL query.
75/// This function builds the request but does not send it.
76/// You must send the request using your own HTTP client.
77///
78/// ## Example
79///
80/// ```gleam
81/// let client = squall.new("https://api.example.com/graphql", [])
82/// let request = squall.prepare_request(
83/// client,
84/// "query { users { id name } }",
85/// json.object([]),
86/// )
87///
88/// // Send with your HTTP client (Erlang example)
89/// let assert Ok(response) = httpc.send(request)
90///
91/// // Parse the response
92/// let assert Ok(data) = squall.parse_response(response.body, your_decoder)
93/// ```
94pub fn prepare_request(
95 client: Client,
96 query: String,
97 variables: json.Json,
98) -> Result(Request(String), String) {
99 let body =
100 json.object([#("query", json.string(query)), #("variables", variables)])
101
102 use req <- result.try(
103 request.to(client.endpoint)
104 |> result.map_error(fn(_) { "Invalid endpoint URL" }),
105 )
106
107 let req =
108 req
109 |> request.set_method(http.Post)
110 |> request.set_body(json.to_string(body))
111 |> request.set_header("content-type", "application/json")
112
113 let req =
114 list.fold(client.headers, req, fn(r, header) {
115 request.set_header(r, header.0, header.1)
116 })
117
118 Ok(req)
119}
120
121/// Parse a GraphQL response body using the provided decoder.
122/// This function decodes the JSON response and extracts the data field.
123///
124/// ## Example
125///
126/// ```gleam
127/// let decoder = decode.field("users", decode.list(user_decoder))
128///
129/// case squall.parse_response(response_body, decoder) {
130/// Ok(users) -> io.println("Got users!")
131/// Error(err) -> io.println("Parse error: " <> err)
132/// }
133/// ```
134pub fn parse_response(
135 body: String,
136 decoder: decode.Decoder(a),
137) -> Result(a, String) {
138 use json_value <- result.try(
139 json.parse(from: body, using: decode.dynamic)
140 |> result.map_error(fn(_) { "Failed to decode JSON response" }),
141 )
142
143 let data_decoder = {
144 use data <- decode.field("data", decoder)
145 decode.success(data)
146 }
147
148 decode.run(json_value, data_decoder)
149 |> result.map_error(fn(errors) {
150 "Failed to decode response data: "
151 <> string.inspect(errors)
152 <> ". Response body: "
153 <> body
154 })
155}
156
157@target(erlang)
158pub fn main() {
159 case argv.load().arguments {
160 ["generate", endpoint] -> generate(endpoint)
161 ["generate"] -> generate_with_env()
162 ["unstable-cache", endpoint] -> unstable_cache(endpoint)
163 ["unstable-cache"] -> {
164 io.println("Error: Endpoint required")
165 io.println("Usage: gleam run -m squall unstable-cache <endpoint>")
166 Nil
167 }
168 _ -> {
169 print_usage()
170 Nil
171 }
172 }
173}
174
175@target(erlang)
176fn print_usage() {
177 io.println(
178 "
179Squall - Type-safe GraphQL client generator for Gleam
180
181Usage:
182 gleam run -m squall generate <endpoint>
183 gleam run -m squall unstable-cache <endpoint>
184
185Commands:
186 generate <endpoint> Generate Gleam code from .gql files
187 unstable-cache <endpoint> Extract GraphQL queries from doc comments and generate types and cache registry
188
189The generate command will:
190 1. Find all .gql files in src/**/graphql/ directories
191 2. Introspect the GraphQL schema from the endpoint
192 3. Generate type-safe Gleam functions for each query/mutation/subscription
193
194The unstable-cache command will:
195 1. Scan all .gleam files in src/ for GraphQL query blocks in doc comments
196 2. Introspect the GraphQL schema from the endpoint
197 3. Automatically inject __typename into queries for cache normalization
198 4. Generate type-safe code for each query at src/generated/queries/
199 5. Generate a registry initialization module at src/generated/queries.gleam
200
201Examples:
202 gleam run -m squall generate https://rickandmortyapi.com/graphql
203 gleam run -m squall unstable-cache https://rickandmortyapi.com/graphql
204",
205 )
206}
207
208@target(erlang)
209fn generate_with_env() {
210 io.println("Usage: gleam run -m squall generate <endpoint>")
211 Nil
212}
213
214@target(erlang)
215fn generate(endpoint: String) {
216 io.println("🌊 Squall - GraphQL Code Generator")
217 io.println("================================\n")
218
219 io.println("📡 Introspecting GraphQL schema from: " <> endpoint)
220
221 // Introspect schema
222 case introspect_schema(endpoint) {
223 Ok(schema_data) -> {
224 io.println("✓ Schema introspected successfully\n")
225
226 // Discover .gql files
227 io.println("🔍 Discovering .gql files...")
228 case discovery.find_graphql_files("src") {
229 Ok(files) -> {
230 io.println(
231 "✓ Found " <> int_to_string(list.length(files)) <> " .gql file(s)\n",
232 )
233
234 // Process each file
235 list.each(files, fn(file) {
236 io.println("📝 Processing: " <> file.path)
237
238 case graphql_ast.parse_document(file.content) {
239 Ok(document) -> {
240 // Extract main operation and fragments
241 case graphql_ast.get_main_operation(document) {
242 Ok(operation) -> {
243 let fragments =
244 graphql_ast.get_fragment_definitions(document)
245
246 case
247 codegen.generate_operation_with_fragments(
248 file.operation_name,
249 file.content,
250 operation,
251 fragments,
252 schema_data,
253 endpoint,
254 )
255 {
256 Ok(code) -> {
257 // Write generated code
258 let output_path =
259 string.replace(file.path, ".gql", ".gleam")
260
261 case simplifile.write(output_path, code) {
262 Ok(_) -> {
263 io.println(" ✓ Generated: " <> output_path)
264 }
265 Error(_) -> {
266 io.println(" ✗ Failed to write: " <> output_path)
267 }
268 }
269 }
270 Error(err) -> {
271 io.println(
272 " ✗ Code generation failed: " <> error.to_string(err),
273 )
274 }
275 }
276 }
277 Error(err) -> {
278 io.println(" ✗ Parse failed: " <> error.to_string(err))
279 }
280 }
281 }
282 Error(err) -> {
283 io.println(" ✗ Parse failed: " <> error.to_string(err))
284 }
285 }
286 })
287
288 io.println("\n✨ Code generation complete!")
289 Nil
290 }
291 Error(err) -> {
292 io.println("✗ Failed to discover files: " <> error.to_string(err))
293 Nil
294 }
295 }
296 }
297 Error(err) -> {
298 io.println("✗ Schema introspection failed: " <> error.to_string(err))
299 Nil
300 }
301 }
302}
303
304@target(erlang)
305fn introspect_schema(endpoint: String) -> Result(schema.Schema, error.Error) {
306 // Build introspection query
307 let introspection_query =
308 "
309 query IntrospectionQuery {
310 __schema {
311 queryType { name }
312 mutationType { name }
313 subscriptionType { name }
314 types {
315 name
316 kind
317 description
318 fields {
319 name
320 description
321 type {
322 ...TypeRef
323 }
324 args {
325 name
326 type {
327 ...TypeRef
328 }
329 }
330 }
331 inputFields {
332 name
333 type {
334 ...TypeRef
335 }
336 }
337 enumValues {
338 name
339 }
340 possibleTypes {
341 name
342 }
343 }
344 }
345 }
346
347 fragment TypeRef on __Type {
348 kind
349 name
350 ofType {
351 kind
352 name
353 ofType {
354 kind
355 name
356 ofType {
357 kind
358 name
359 ofType {
360 kind
361 name
362 ofType {
363 kind
364 name
365 }
366 }
367 }
368 }
369 }
370 }
371 "
372
373 // Make HTTP request
374 use response <- result.try(
375 make_graphql_request(endpoint, introspection_query, "")
376 |> result.map_error(fn(err) { error.HttpRequestFailed(err) }),
377 )
378
379 // Parse schema
380 schema.parse_introspection_response(response)
381}
382
383@target(erlang)
384fn make_graphql_request(
385 endpoint: String,
386 query: String,
387 variables: String,
388) -> Result(String, String) {
389 // Build JSON body
390 let vars_value = case variables {
391 "" -> json.object([])
392 _ -> json.string(variables)
393 }
394
395 let body =
396 json.object([#("query", json.string(query)), #("variables", vars_value)])
397 |> json.to_string
398
399 // Create HTTP request
400 use req <- result.try(
401 request.to(endpoint)
402 |> result.map_error(fn(_) { "Invalid endpoint URL: " <> endpoint }),
403 )
404
405 let req =
406 req
407 |> request.set_method(http.Post)
408 |> request.set_body(body)
409 |> request.set_header("content-type", "application/json")
410 |> request.set_header("accept", "application/json")
411
412 // Send request using httpc (generator always runs on Erlang)
413 use resp <- result.try(
414 httpc.send(req)
415 |> result.map_error(fn(_) { "Failed to send HTTP request to " <> endpoint }),
416 )
417
418 // Check status code
419 case resp.status {
420 200 -> Ok(resp.body)
421 _ ->
422 Error(
423 "HTTP request failed with status "
424 <> int_to_string(resp.status)
425 <> ": "
426 <> resp.body,
427 )
428 }
429}
430
431@target(erlang)
432fn unstable_cache(endpoint: String) {
433 let queries_output_dir = "src/generated/queries"
434 let registry_output_path = "src/generated/queries.gleam"
435
436 io.println("🌊 Squall")
437 io.println("============================================\n")
438
439 io.println("🔍 Scanning for GraphQL queries in src/...")
440
441 // Scan for component files
442 case query_extractor.scan_component_files("src") {
443 Ok(files) -> {
444 io.println(
445 "✓ Found " <> int_to_string(list.length(files)) <> " .gleam file(s)\n",
446 )
447
448 // Extract queries from each file
449 io.println("📝 Extracting queries...")
450 let all_queries =
451 list.fold(files, [], fn(acc, file_path) {
452 case query_extractor.extract_from_file(file_path) {
453 Ok(queries) -> {
454 list.each(queries, fn(q) {
455 io.println(" ✓ Found: " <> q.name <> " in " <> file_path)
456 })
457 list.append(acc, queries)
458 }
459 Error(err) -> {
460 io.println(
461 " ✗ Failed to extract from " <> file_path <> ": " <> err,
462 )
463 acc
464 }
465 }
466 })
467
468 case list.length(all_queries) {
469 0 -> {
470 io.println("\n⚠ No GraphQL queries found")
471 io.println(
472 "Add GraphQL query blocks to your doc comments with named operations",
473 )
474 Nil
475 }
476 _ -> {
477 io.println(
478 "\n✓ Extracted "
479 <> int_to_string(list.length(all_queries))
480 <> " quer"
481 <> case list.length(all_queries) {
482 1 -> "y"
483 _ -> "ies"
484 },
485 )
486
487 // Introspect schema
488 io.println("\n📡 Introspecting GraphQL schema from: " <> endpoint)
489 case introspect_schema(endpoint) {
490 Ok(schema_data) -> {
491 io.println("✓ Schema introspected successfully\n")
492
493 // Generate type-safe code for each query
494 io.println("🔧 Generating type-safe code...")
495 list.each(all_queries, fn(query_def) {
496 io.println(" • " <> query_def.name)
497
498 // Parse the GraphQL query
499 case graphql_ast.parse_document(query_def.query) {
500 Ok(document) -> {
501 case graphql_ast.get_main_operation(document) {
502 Ok(operation) -> {
503 let fragments =
504 graphql_ast.get_fragment_definitions(document)
505
506 // Generate code - convert query name to snake_case for module name
507 let module_name = to_snake_case(query_def.name)
508
509 case
510 codegen.generate_operation_with_fragments(
511 module_name,
512 query_def.query,
513 operation,
514 fragments,
515 schema_data,
516 endpoint,
517 )
518 {
519 Ok(code) -> {
520 let file_name = module_name
521 let module_path =
522 queries_output_dir <> "/" <> file_name <> ".gleam"
523
524 // Create directory if needed
525 let _ =
526 simplifile.create_directory_all(
527 queries_output_dir,
528 )
529
530 case simplifile.write(module_path, code) {
531 Ok(_) -> io.println(" ✓ " <> module_path)
532 Error(_) ->
533 io.println(
534 " ✗ Failed to write " <> module_path,
535 )
536 }
537 }
538 Error(err) -> {
539 io.println(
540 " ✗ Codegen failed: " <> error.to_string(err),
541 )
542 }
543 }
544 }
545 Error(err) -> {
546 io.println(
547 " ✗ Parse failed: " <> error.to_string(err),
548 )
549 }
550 }
551 }
552 Error(err) -> {
553 io.println(" ✗ Parse failed: " <> error.to_string(err))
554 }
555 }
556 })
557
558 // Generate registry code with __typename injected
559 io.println("\n📦 Generating registry module...")
560 // Inject __typename into all query strings for the registry
561 let queries_with_typename =
562 list.map(all_queries, fn(query_def) {
563 case
564 typename_injector.inject_typename(
565 query_def.query,
566 schema_data,
567 )
568 {
569 Ok(injected_query) ->
570 query_extractor.QueryDefinition(
571 name: query_def.name,
572 query: injected_query,
573 file_path: query_def.file_path,
574 )
575 Error(_) -> query_def
576 }
577 })
578 let code =
579 registry_codegen.generate_registry_module(queries_with_typename)
580
581 // Write to output file
582 case simplifile.write(registry_output_path, code) {
583 Ok(_) -> {
584 io.println("✓ Generated: " <> registry_output_path)
585 io.println("\n✨ Code generation complete!")
586 Nil
587 }
588 Error(_) -> {
589 io.println("✗ Failed to write: " <> registry_output_path)
590 Nil
591 }
592 }
593 }
594 Error(err) -> {
595 io.println(
596 "✗ Schema introspection failed: " <> error.to_string(err),
597 )
598 Nil
599 }
600 }
601 }
602 }
603 }
604 Error(err) -> {
605 io.println("✗ Failed to scan files: " <> err)
606 Nil
607 }
608 }
609}
610
611@target(erlang)
612fn to_snake_case(s: String) -> String {
613 // Simple conversion: GetCharacters -> get_characters
614 s
615 |> string.to_graphemes
616 |> list.index_fold([], fn(acc, char, index) {
617 case is_uppercase(char) {
618 True ->
619 case index {
620 0 -> list.append(acc, [string.lowercase(char)])
621 _ -> list.append(acc, ["_", string.lowercase(char)])
622 }
623 False -> list.append(acc, [char])
624 }
625 })
626 |> string.join("")
627}
628
629@target(erlang)
630fn is_uppercase(s: String) -> Bool {
631 s == string.uppercase(s) && s != string.lowercase(s)
632}
633
634@target(erlang)
635fn int_to_string(i: Int) -> String {
636 case i {
637 0 -> "0"
638 1 -> "1"
639 2 -> "2"
640 3 -> "3"
641 4 -> "4"
642 5 -> "5"
643 6 -> "6"
644 7 -> "7"
645 8 -> "8"
646 9 -> "9"
647 _ -> {
648 // For larger numbers, convert via string representation
649 let s = i
650 case s >= 0 {
651 True -> int_to_string(s / 10) <> int_to_string(s % 10)
652 False -> "-" <> int_to_string(-s)
653 }
654 }
655 }
656}