Type-safe GraphQL client generator for Gleam

add client abstraction with headers, update readme

+50 -37
README.md
··· 4 4 5 5 Squall parses `.gql` files in your project, introspects your GraphQL endpoint, and generates fully type-safe Gleam code with decoders, eliminating runtime errors and keeping your GraphQL queries in sync with your schema. 6 6 7 + > **⚠️ Warning**: This project is in early development and may contain bugs. Use at your own risk. 8 + 7 9 ## Features 8 10 9 - - ✅ **Type-Safe**: Generated code is fully typed based on your GraphQL schema 10 - - ✅ **Convention-based**: Drop `.gql` files in `src/**/graphql/` directories 11 - - ✅ **Zero Configuration**: No config files needed 12 - - ✅ **Schema Introspection**: Automatically fetches types from your GraphQL endpoint 13 - - ✅ **Full GraphQL Support**: Queries, Mutations, and Subscriptions 14 - - ✅ **Test-Driven**: Built with TDD, 38+ passing tests 15 - - ✅ **Gleam-First**: Generates idiomatic Gleam code 11 + - Type-safe code generation from GraphQL schema 12 + - Convention over configuration - `.gql` files in `src/**/graphql/` directories 13 + - Schema introspection from GraphQL endpoints 14 + - Supports queries, mutations, and subscriptions 15 + - Client abstraction with authentication and custom headers 16 + - Multiple endpoint support 16 17 17 18 ## Installation 18 19 ··· 20 21 21 22 ```toml 22 23 [dependencies] 23 - squall = "1.0.0" 24 + squall = "0.1.0" 24 25 ``` 25 26 26 27 ## Quick Start ··· 49 50 ### 3. Use the generated code 50 51 51 52 ```gleam 53 + import squall 52 54 import my_app/graphql/get_character 53 55 54 56 pub fn main() { 55 - let endpoint = "https://rickandmortyapi.com/graphql" 57 + // Create a client 58 + let client = squall.new_client( 59 + endpoint: "https://rickandmortyapi.com/graphql", 60 + headers: [] 61 + ) 56 62 57 - case get_character.get_character(endpoint, "1") { 63 + // Use the generated function 64 + case get_character.get_character(client: client, id: "1") { 58 65 Ok(response) -> { 59 - io.println("Character: " <> response.name) 66 + io.println("Character: " <> response.character.name) 60 67 } 61 68 Error(err) -> { 62 69 io.println("Error: " <> err) ··· 65 72 } 66 73 ``` 67 74 75 + ## Creating a Client 76 + 77 + Squall uses a client abstraction to manage your GraphQL endpoint and headers (including authentication): 78 + 79 + ```gleam 80 + import squall 81 + 82 + // Create a client with custom headers 83 + let client = squall.new_client( 84 + endpoint: "https://api.example.com/graphql", 85 + headers: [ 86 + #("Authorization", "Bearer your-api-token-here"), 87 + #("X-Custom-Header", "value") 88 + ] 89 + ) 90 + 91 + // Or use the convenience function for bearer token auth 92 + let client = squall.new_client_with_auth( 93 + endpoint: "https://api.example.com/graphql", 94 + token: "your-api-token-here" 95 + ) 96 + 97 + // For public APIs with no authentication 98 + let client = squall.new_client( 99 + endpoint: "https://rickandmortyapi.com/graphql", 100 + headers: [] 101 + ) 102 + ``` 103 + 68 104 ## How It Works 69 105 70 106 Squall follows a simple workflow: ··· 116 152 } 117 153 118 154 pub fn get_user( 119 - endpoint: String, 155 + client: squall.Client, 120 156 id: String, 121 157 ) -> Result(GetUserResponse, String) { 122 158 // HTTP request + JSON decoding implementation ··· 161 197 - **`codegen`**: Generates Gleam code 162 198 - **`error`**: Comprehensive error handling 163 199 164 - ## Comparison with Squirrel 165 - 166 - Squall is inspired by [squirrel](https://github.com/giacomocavalieri/squirrel) but for GraphQL instead of SQL: 167 - 168 - | Feature | Squirrel (SQL) | Squall (GraphQL) | 169 - |---------|----------------|------------------| 170 - | Query Language | SQL | GraphQL | 171 - | Type Source | PostgreSQL | GraphQL Schema | 172 - | File Pattern | `sql/*.sql` | `graphql/*.gql` | 173 - | Type Safety | ✅ | ✅ | 174 - | Zero Config | ✅ | ✅ | 175 - | Convention-based | ✅ | ✅ | 176 - 177 200 ## Development 178 201 179 202 ### Running Tests ··· 212 235 - [x] Subscription support 213 236 - [x] Type-safe code generation 214 237 - [x] Schema introspection 215 - - [ ] HTTP client implementation 238 + - [x] Client abstraction with headers/authentication 239 + - [x] Multiple endpoint support 216 240 - [ ] Fragment support 217 241 - [ ] Directive handling 218 242 - [ ] Custom scalar mapping 219 - - [ ] Multiple endpoint support 220 243 221 244 ## Contributing 222 245 ··· 230 253 ## License 231 254 232 255 Apache-2.0 233 - 234 - ## Acknowledgments 235 - 236 - - Inspired by [squirrel](https://github.com/giacomocavalieri/squirrel) by Giacomo Cavalieri 237 - - Built with [Gleam](https://gleam.run/) 238 - - Tested with the [Rick and Morty API](https://rickandmortyapi.com/graphql) 239 - 240 - --- 241 - 242 - Made with 🌊 and Gleam
+8 -4
birdie_snapshots/mutation_function_generation.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Mutation function generation 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_mutation_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type User { 16 16 User(id: String, name: String) ··· 41 41 } 42 42 } 43 43 44 - pub fn create_user(endpoint: String, name: String) -> Result(CreateUserResponse, String) { 44 + pub fn create_user(client: squall.Client, name: String) -> Result(CreateUserResponse, String) { 45 45 let query = 46 46 "mutation CreateUser($name: String!) { createUser(name: $name) { id name } }" 47 47 let variables = ··· 49 49 let body = 50 50 json.object([#("query", json.string(query)), #("variables", variables)]) 51 51 use req <- result.try( 52 - request.to(endpoint) 52 + request.to(client.endpoint) 53 53 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 54 54 ) 55 55 let req = ··· 57 57 |> request.set_method(http.Post) 58 58 |> request.set_body(json.to_string(body)) 59 59 |> request.set_header("content-type", "application/json") 60 + let req = 61 + list.fold(client.headers, req, fn(r, header) { 62 + request.set_header(r, header.0, header.1) 63 + }) 60 64 use resp <- result.try( 61 65 httpc.send(req) 62 66 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_inline_array_arguments.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with inline array arguments 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_inline_array_arguments_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type Episode { 16 16 Episode(id: String, name: String) ··· 41 41 } 42 42 } 43 43 44 - pub fn get_episodes(endpoint: String) -> Result(GetEpisodesResponse, String) { 44 + pub fn get_episodes(client: squall.Client) -> Result(GetEpisodesResponse, String) { 45 45 let query = 46 46 "query GetEpisodes { episodesByIds(ids: [1, 2, 3]) { id name } }" 47 47 let variables = ··· 49 49 let body = 50 50 json.object([#("query", json.string(query)), #("variables", variables)]) 51 51 use req <- result.try( 52 - request.to(endpoint) 52 + request.to(client.endpoint) 53 53 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 54 54 ) 55 55 let req = ··· 57 57 |> request.set_method(http.Post) 58 58 |> request.set_body(json.to_string(body)) 59 59 |> request.set_header("content-type", "application/json") 60 + let req = 61 + list.fold(client.headers, req, fn(r, header) { 62 + request.set_header(r, header.0, header.1) 63 + }) 60 64 use resp <- result.try( 61 65 httpc.send(req) 62 66 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_inline_object_arguments.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with inline object arguments 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_inline_object_arguments_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type CharactersResult { 16 16 CharactersResult(results: Option(List(Character))) ··· 58 58 } 59 59 } 60 60 61 - pub fn get_characters(endpoint: String) -> Result(GetCharactersResponse, String) { 61 + pub fn get_characters(client: squall.Client) -> Result(GetCharactersResponse, String) { 62 62 let query = 63 63 "query GetCharacters { characters(filter: { name: \"rick\", status: \"alive\" }) { results { id name } } }" 64 64 let variables = ··· 66 66 let body = 67 67 json.object([#("query", json.string(query)), #("variables", variables)]) 68 68 use req <- result.try( 69 - request.to(endpoint) 69 + request.to(client.endpoint) 70 70 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 71 71 ) 72 72 let req = ··· 74 74 |> request.set_method(http.Post) 75 75 |> request.set_body(json.to_string(body)) 76 76 |> request.set_header("content-type", "application/json") 77 + let req = 78 + list.fold(client.headers, req, fn(r, header) { 79 + request.set_header(r, header.0, header.1) 80 + }) 77 81 use resp <- result.try( 78 82 httpc.send(req) 79 83 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_inline_scalar_arguments.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with inline scalar arguments 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_inline_scalar_arguments_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type Character { 16 16 Character(id: String, name: String) ··· 41 41 } 42 42 } 43 43 44 - pub fn get_character(endpoint: String) -> Result(GetCharacterResponse, String) { 44 + pub fn get_character(client: squall.Client) -> Result(GetCharacterResponse, String) { 45 45 let query = 46 46 "query GetCharacter { character(id: 1) { id name } }" 47 47 let variables = ··· 49 49 let body = 50 50 json.object([#("query", json.string(query)), #("variables", variables)]) 51 51 use req <- result.try( 52 - request.to(endpoint) 52 + request.to(client.endpoint) 53 53 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 54 54 ) 55 55 let req = ··· 57 57 |> request.set_method(http.Post) 58 58 |> request.set_body(json.to_string(body)) 59 59 |> request.set_header("content-type", "application/json") 60 + let req = 61 + list.fold(client.headers, req, fn(r, header) { 62 + request.set_header(r, header.0, header.1) 63 + }) 60 64 use resp <- result.try( 61 65 httpc.send(req) 62 66 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with multiple root fields and mixed arguments 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_multiple_root_fields_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type CharactersResult { 16 16 CharactersResult(info: Option(Info), results: Option(List(Character))) ··· 116 116 } 117 117 } 118 118 119 - pub fn multi_query(endpoint: String) -> Result(MultiQueryResponse, String) { 119 + pub fn multi_query(client: squall.Client) -> Result(MultiQueryResponse, String) { 120 120 let query = 121 121 "query MultiQuery { characters(page: 2, filter: { name: \"rick\" }) { info { count } results { name } } location(id: 1) { id } episodesByIds(ids: [1, 2]) { id } }" 122 122 let variables = ··· 124 124 let body = 125 125 json.object([#("query", json.string(query)), #("variables", variables)]) 126 126 use req <- result.try( 127 - request.to(endpoint) 127 + request.to(client.endpoint) 128 128 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 129 129 ) 130 130 let req = ··· 132 132 |> request.set_method(http.Post) 133 133 |> request.set_body(json.to_string(body)) 134 134 |> request.set_header("content-type", "application/json") 135 + let req = 136 + list.fold(client.headers, req, fn(r, header) { 137 + request.set_header(r, header.0, header.1) 138 + }) 135 139 use resp <- result.try( 136 140 httpc.send(req) 137 141 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_nested_types_generation.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with nested types generation 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_query_with_nested_types_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type Character { 16 16 Character(id: String, name: String, status: String) ··· 42 42 } 43 43 } 44 44 45 - pub fn get_character(endpoint: String, id: String) -> Result(GetCharacterResponse, String) { 45 + pub fn get_character(client: squall.Client, id: String) -> Result(GetCharacterResponse, String) { 46 46 let query = 47 47 "query GetCharacter($id: ID!) { character(id: $id) { id name status } }" 48 48 let variables = ··· 50 50 let body = 51 51 json.object([#("query", json.string(query)), #("variables", variables)]) 52 52 use req <- result.try( 53 - request.to(endpoint) 53 + request.to(client.endpoint) 54 54 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 55 55 ) 56 56 let req = ··· 58 58 |> request.set_method(http.Post) 59 59 |> request.set_body(json.to_string(body)) 60 60 |> request.set_header("content-type", "application/json") 61 + let req = 62 + list.fold(client.headers, req, fn(r, header) { 63 + request.set_header(r, header.0, header.1) 64 + }) 61 65 use resp <- result.try( 62 66 httpc.send(req) 63 67 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/query_with_variables_function_generation.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Query with variables function generation 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_query_with_variables_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type User { 16 16 User(id: String, name: Option(String)) ··· 44 44 } 45 45 } 46 46 47 - pub fn get_user(endpoint: String, id: String) -> Result(GetUserResponse, String) { 47 + pub fn get_user(client: squall.Client, id: String) -> Result(GetUserResponse, String) { 48 48 let query = 49 49 "query GetUser($id: ID!) { user(id: $id) { id name } }" 50 50 let variables = ··· 52 52 let body = 53 53 json.object([#("query", json.string(query)), #("variables", variables)]) 54 54 use req <- result.try( 55 - request.to(endpoint) 55 + request.to(client.endpoint) 56 56 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 57 57 ) 58 58 let req = ··· 60 60 |> request.set_method(http.Post) 61 61 |> request.set_body(json.to_string(body)) 62 62 |> request.set_header("content-type", "application/json") 63 + let req = 64 + list.fold(client.headers, req, fn(r, header) { 65 + request.set_header(r, header.0, header.1) 66 + }) 63 67 use resp <- result.try( 64 68 httpc.send(req) 65 69 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/simple_query_function_generation.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Simple query function generation 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_simple_query_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type User { 16 16 User(id: String, name: Option(String)) ··· 44 44 } 45 45 } 46 46 47 - pub fn get_user(endpoint: String) -> Result(GetUserResponse, String) { 47 + pub fn get_user(client: squall.Client) -> Result(GetUserResponse, String) { 48 48 let query = 49 49 "query GetUser { user { id name } }" 50 50 let variables = ··· 52 52 let body = 53 53 json.object([#("query", json.string(query)), #("variables", variables)]) 54 54 use req <- result.try( 55 - request.to(endpoint) 55 + request.to(client.endpoint) 56 56 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 57 57 ) 58 58 let req = ··· 60 60 |> request.set_method(http.Post) 61 61 |> request.set_body(json.to_string(body)) 62 62 |> request.set_header("content-type", "application/json") 63 + let req = 64 + list.fold(client.headers, req, fn(r, header) { 65 + request.set_header(r, header.0, header.1) 66 + }) 63 67 use resp <- result.try( 64 68 httpc.send(req) 65 69 |> result.map_error(fn(_) { "HTTP request failed" }),
+8 -4
birdie_snapshots/type_with_reserved_keywords.accepted
··· 1 1 --- 2 2 version: 1.2.0 3 3 title: Type with reserved keywords 4 - file: ./test/codegen_test.gleam 5 - test_name: generate_with_reserved_keywords_test 6 4 --- 7 5 import gleam/dynamic 8 6 import gleam/http 9 7 import gleam/http/request 10 8 import gleam/httpc 11 9 import gleam/json 10 + import gleam/list 12 11 import gleam/option.{type Option} 13 12 import gleam/result 13 + import squall 14 14 15 15 pub type Item { 16 16 Item( ··· 57 57 } 58 58 } 59 59 60 - pub fn get_item(endpoint: String) -> Result(GetItemResponse, String) { 60 + pub fn get_item(client: squall.Client) -> Result(GetItemResponse, String) { 61 61 let query = 62 62 "query GetItem { item { id type case let } }" 63 63 let variables = ··· 65 65 let body = 66 66 json.object([#("query", json.string(query)), #("variables", variables)]) 67 67 use req <- result.try( 68 - request.to(endpoint) 68 + request.to(client.endpoint) 69 69 |> result.map_error(fn(_) { "Invalid endpoint URL" }), 70 70 ) 71 71 let req = ··· 73 73 |> request.set_method(http.Post) 74 74 |> request.set_body(json.to_string(body)) 75 75 |> request.set_header("content-type", "application/json") 76 + let req = 77 + list.fold(client.headers, req, fn(r, header) { 78 + request.set_header(r, header.0, header.1) 79 + }) 76 80 use resp <- result.try( 77 81 httpc.send(req) 78 82 |> result.map_error(fn(_) { "HTTP request failed" }),
+15
src/squall.gleam
··· 14 14 import squall/internal/parser 15 15 import squall/internal/schema 16 16 17 + /// A GraphQL client with endpoint and headers configuration 18 + pub type Client { 19 + Client(endpoint: String, headers: List(#(String, String))) 20 + } 21 + 22 + /// Create a new GraphQL client with custom headers 23 + pub fn new_client(endpoint: String, headers: List(#(String, String))) -> Client { 24 + Client(endpoint: endpoint, headers: headers) 25 + } 26 + 27 + /// Create a new GraphQL client with bearer token authentication 28 + pub fn new_client_with_auth(endpoint: String, token: String) -> Client { 29 + Client(endpoint: endpoint, headers: [#("Authorization", "Bearer " <> token)]) 30 + } 31 + 17 32 pub fn main() { 18 33 case argv.load().arguments { 19 34 ["generate", endpoint] -> generate(endpoint)
+15 -3
src/squall/internal/codegen.gleam
··· 119 119 "import gleam/http/request", 120 120 "import gleam/httpc", 121 121 "import gleam/json", 122 + "import gleam/list", 122 123 "import gleam/option.{type Option}", 123 124 "import gleam/result", 125 + "import squall", 124 126 ] 125 127 126 128 import_lines ··· 517 519 518 520 // Build parameter list as documents 519 521 let param_docs = case variables { 520 - [] -> [doc.from_string("endpoint: String")] 522 + [] -> [doc.from_string("client: squall.Client")] 521 523 vars -> { 522 524 let var_param_docs = 523 525 vars ··· 535 537 }) 536 538 |> list.filter_map(fn(r) { r }) 537 539 538 - [doc.from_string("endpoint: String"), ..var_param_docs] 540 + [doc.from_string("client: squall.Client"), ..var_param_docs] 539 541 } 540 542 } 541 543 ··· 585 587 doc.from_string("use req <- result.try("), 586 588 doc.concat([ 587 589 doc.line, 588 - call_doc("request.to", [doc.from_string("endpoint")]), 590 + call_doc("request.to", [doc.from_string("client.endpoint")]), 589 591 doc.line, 590 592 doc.from_string("|> result.map_error(fn(_) { \"Invalid endpoint URL\" }),"), 591 593 ]) ··· 603 605 doc.from_string("|> request.set_body(json.to_string(body))"), 604 606 doc.line, 605 607 doc.from_string("|> request.set_header(\"content-type\", \"application/json\")"), 608 + ]), 609 + ), 610 + let_var( 611 + "req", 612 + doc.concat([ 613 + doc.from_string("list.fold(client.headers, req, fn(r, header) {"), 614 + doc.line, 615 + doc.from_string(" request.set_header(r, header.0, header.1)"), 616 + doc.line, 617 + doc.from_string("})"), 606 618 ]), 607 619 ), 608 620 doc.concat([