Type-safe GraphQL client generator for Gleam
at main 19 kB view raw
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}