diff --git a/README.md b/README.md index d52a410..c04b0b6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A type-safe **sans-io** GraphQL client generator for Gleam. +![Squall](https://images.unsplash.com/photo-1698115989309-16f9d34c04e9?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2340) + > This project is in early development and may contain bugs/unsupported GraphQL queries. ## What is Sans-IO? @@ -131,15 +133,8 @@ gleam run -m squall generate https://rickandmortyapi.com/graphql See the [example/](./example/) directory for a complete working example. -## Inspiration - -- [bucket](https://github.com/lpil/bucket) - Gleam S3 client with sans-io pattern -- [squirrel](https://github.com/giacomocavalieri/squirrel) - Original GraphQL client for Gleam -- [Sans-IO pattern](https://sans-io.readthedocs.io/) - ## Roadmap -- [ ] Fragment support - [ ] Directive handling (`@include`, `@skip`) - [ ] Custom scalar type mapping - [ ] Schema caching diff --git a/birdie_snapshots/mutation_function_generation.accepted b/birdie_snapshots/mutation_function_generation.accepted index 1068f24..e99941a 100644 --- a/birdie_snapshots/mutation_function_generation.accepted +++ b/birdie_snapshots/mutation_function_generation.accepted @@ -43,7 +43,7 @@ pub fn create_user_response_to_json(input: CreateUserResponse) -> json.Json { pub fn create_user(client: squall.Client, name: String) -> Result(Request(String), String) { squall.prepare_request( client, - "mutation CreateUser($name: String!) { createUser(name: $name) { id name } }", + "\n mutation CreateUser($name: String!) {\n createUser(name: $name) {\n id\n name\n }\n }\n ", json.object([#("name", json.string(name))]), ) } diff --git a/birdie_snapshots/mutation_with_input_object_variable.accepted b/birdie_snapshots/mutation_with_input_object_variable.accepted index 2f96721..5ece72f 100644 --- a/birdie_snapshots/mutation_with_input_object_variable.accepted +++ b/birdie_snapshots/mutation_with_input_object_variable.accepted @@ -73,7 +73,7 @@ pub fn update_profile_response_to_json(input: UpdateProfileResponse) -> json.Jso pub fn update_profile(client: squall.Client, input: ProfileInput) -> Result(Request(String), String) { squall.prepare_request( client, - "mutation UpdateProfile($input: ProfileInput!) { updateProfile(input: $input) { id displayName } }", + "\n mutation UpdateProfile($input: ProfileInput!) {\n updateProfile(input: $input) {\n id\n displayName\n }\n }\n ", json.object([#("input", profile_input_to_json(input))]), ) } diff --git a/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted b/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted index 923e2c5..34757fb 100644 --- a/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted +++ b/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted @@ -74,7 +74,7 @@ pub fn update_settings_response_to_json(input: UpdateSettingsResponse) -> json.J pub fn update_settings(client: squall.Client, input: SettingsInput) -> Result(Request(String), String) { squall.prepare_request( client, - "mutation UpdateSettings($input: SettingsInput!) { updateSettings(input: $input) { id metadata } }", + "\n mutation UpdateSettings($input: SettingsInput!) {\n updateSettings(input: $input) {\n id\n metadata\n }\n }\n ", json.object([#("input", settings_input_to_json(input))]), ) } diff --git a/birdie_snapshots/mutation_with_nested_input_object_types.accepted b/birdie_snapshots/mutation_with_nested_input_object_types.accepted index d93f384..cdbe4a0 100644 --- a/birdie_snapshots/mutation_with_nested_input_object_types.accepted +++ b/birdie_snapshots/mutation_with_nested_input_object_types.accepted @@ -99,7 +99,7 @@ pub fn update_profile_response_to_json(input: UpdateProfileResponse) -> json.Jso pub fn update_profile(client: squall.Client, input: ProfileInput) -> Result(Request(String), String) { squall.prepare_request( client, - "mutation UpdateProfile($input: ProfileInput!) { updateProfile(input: $input) { id displayName } }", + "\n mutation UpdateProfile($input: ProfileInput!) {\n updateProfile(input: $input) {\n id\n displayName\n }\n }\n ", json.object([#("input", profile_input_to_json(input))]), ) } diff --git a/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted b/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted index 067ed4f..687b234 100644 --- a/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted +++ b/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted @@ -88,7 +88,7 @@ pub fn create_profile_response_to_json(input: CreateProfileResponse) -> json.Jso pub fn create_profile(client: squall.Client, input: ProfileInput) -> Result(Request(String), String) { squall.prepare_request( client, - "mutation CreateProfile($input: ProfileInput!) { createProfile(input: $input) { id displayName description } }", + "\n mutation CreateProfile($input: ProfileInput!) {\n createProfile(input: $input) {\n id\n displayName\n description\n }\n }\n ", json.object([#("input", profile_input_to_json(input))]), ) } diff --git a/birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted b/birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted index f195f49..44bd286 100644 --- a/birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted +++ b/birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted @@ -44,7 +44,7 @@ pub fn get_product_response_to_json(input: GetProductResponse) -> json.Json { pub fn get_product(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetProduct { product { id name price } }", + "\n query GetProduct {\n product {\n id\n name\n price\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_inline_array_arguments.accepted b/birdie_snapshots/query_with_inline_array_arguments.accepted index 1945851..9c63ef2 100644 --- a/birdie_snapshots/query_with_inline_array_arguments.accepted +++ b/birdie_snapshots/query_with_inline_array_arguments.accepted @@ -50,7 +50,7 @@ pub fn get_episodes_response_to_json(input: GetEpisodesResponse) -> json.Json { pub fn get_episodes(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetEpisodes { episodesByIds(ids: [1, 2, 3]) { id name } }", + "\n query GetEpisodes {\n episodesByIds(ids: [1, 2, 3]) {\n id\n name\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_inline_object_arguments.accepted b/birdie_snapshots/query_with_inline_object_arguments.accepted index 7ccc682..8ad7eb6 100644 --- a/birdie_snapshots/query_with_inline_object_arguments.accepted +++ b/birdie_snapshots/query_with_inline_object_arguments.accepted @@ -67,7 +67,7 @@ pub fn get_characters_response_to_json(input: GetCharactersResponse) -> json.Jso pub fn get_characters(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetCharacters { characters(filter: { name: \"rick\", status: \"alive\" }) { results { id name } } }", + "\n query GetCharacters {\n characters(filter: { name: \"rick\", status: \"alive\" }) {\n results {\n id\n name\n }\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_inline_scalar_arguments.accepted b/birdie_snapshots/query_with_inline_scalar_arguments.accepted index 2bb4af7..347d3c2 100644 --- a/birdie_snapshots/query_with_inline_scalar_arguments.accepted +++ b/birdie_snapshots/query_with_inline_scalar_arguments.accepted @@ -47,7 +47,7 @@ pub fn get_character_response_to_json(input: GetCharacterResponse) -> json.Json pub fn get_character(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetCharacter { character(id: 1) { id name } }", + "\n query GetCharacter {\n character(id: 1) {\n id\n name\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_json_scalar_field.accepted b/birdie_snapshots/query_with_json_scalar_field.accepted index dead930..5d7155f 100644 --- a/birdie_snapshots/query_with_json_scalar_field.accepted +++ b/birdie_snapshots/query_with_json_scalar_field.accepted @@ -46,7 +46,7 @@ pub fn get_profile_response_to_json(input: GetProfileResponse) -> json.Json { pub fn get_profile(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetProfile { profile { id displayName metadata } }", + "\n query GetProfile {\n profile {\n id\n displayName\n metadata\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted b/birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted index f794461..6cb6410 100644 --- a/birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted +++ b/birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted @@ -117,7 +117,7 @@ pub fn multi_query_response_to_json(input: MultiQueryResponse) -> json.Json { pub fn multi_query(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query MultiQuery { characters(page: 2, filter: { name: \"rick\" }) { info { count } results { name } } location(id: 1) { id } episodesByIds(ids: [1, 2]) { id } }", + "\n query MultiQuery {\n characters(page: 2, filter: { name: \"rick\" }) {\n info {\n count\n }\n results {\n name\n }\n }\n location(id: 1) {\n id\n }\n episodesByIds(ids: [1, 2]) {\n id\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_nested_types_generation.accepted b/birdie_snapshots/query_with_nested_types_generation.accepted index a30cb1e..e31ff7e 100644 --- a/birdie_snapshots/query_with_nested_types_generation.accepted +++ b/birdie_snapshots/query_with_nested_types_generation.accepted @@ -49,7 +49,7 @@ pub fn get_character_response_to_json(input: GetCharacterResponse) -> json.Json pub fn get_character(client: squall.Client, id: String) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetCharacter($id: ID!) { character(id: $id) { id name status } }", + "\n query GetCharacter($id: ID!) {\n character(id: $id) {\n id\n name\n status\n }\n }\n ", json.object([#("id", json.string(id))]), ) } diff --git a/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted b/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted index 4facb99..4468600 100644 --- a/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted +++ b/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted @@ -49,7 +49,7 @@ pub fn get_profile_response_to_json(input: GetProfileResponse) -> json.Json { pub fn get_profile(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetProfile { profile { id displayName description } }", + "\n query GetProfile {\n profile {\n id\n displayName\n description\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/query_with_simple_fragment_spread.accepted b/birdie_snapshots/query_with_simple_fragment_spread.accepted new file mode 100644 index 0000000..6120f50 --- /dev/null +++ b/birdie_snapshots/query_with_simple_fragment_spread.accepted @@ -0,0 +1,63 @@ +--- +version: 1.4.1 +title: Query with simple fragment spread +--- +import gleam/dynamic/decode +import gleam/http/request.{type Request} +import gleam/json +import squall +import gleam/option.{type Option} + +pub type User { + User( + id: String, + name: Option(String), + email: Option(String), + created_at: Option(String), + ) +} + +pub fn user_decoder() -> decode.Decoder(User) { + use id <- decode.field("id", decode.string) + use name <- decode.field("name", decode.optional(decode.string)) + use email <- decode.field("email", decode.optional(decode.string)) + use created_at <- decode.field("createdAt", decode.optional(decode.string)) + decode.success(User(id: id, name: name, email: email, created_at: created_at)) +} + +pub fn user_to_json(input: User) -> json.Json { + json.object( + [ + #("id", json.string(input.id)), + #("name", json.nullable(input.name, json.string)), + #("email", json.nullable(input.email, json.string)), + #("createdAt", json.nullable(input.created_at, json.string)), + ], + ) +} + +pub type GetUserResponse { + GetUserResponse(user: Option(User)) +} + +pub fn get_user_response_decoder() -> decode.Decoder(GetUserResponse) { + use user <- decode.field("user", decode.optional(user_decoder())) + decode.success(GetUserResponse(user: user)) +} + +pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { + json.object([#("user", json.nullable(input.user, user_to_json))]) +} + +pub fn get_user(client: squall.Client) -> Result(Request(String), String) { + squall.prepare_request( + client, + "\n fragment UserFields on User {\n id\n name\n email\n }\n\n query GetUser {\n user {\n ...UserFields\n createdAt\n }\n }\n ", + json.object([]), + ) +} + +pub fn parse_get_user_response(body: String) -> Result(GetUserResponse, String) { + squall.parse_response(body, get_user_response_decoder()) +} + diff --git a/birdie_snapshots/query_with_variables_function_generation.accepted b/birdie_snapshots/query_with_variables_function_generation.accepted index 61f8ce9..ff99cee 100644 --- a/birdie_snapshots/query_with_variables_function_generation.accepted +++ b/birdie_snapshots/query_with_variables_function_generation.accepted @@ -43,7 +43,7 @@ pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { pub fn get_user(client: squall.Client, id: String) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUser($id: ID!) { user(id: $id) { id name } }", + "\n query GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n }\n }\n ", json.object([#("id", json.string(id))]), ) } diff --git a/birdie_snapshots/response_serializer_for_simple_type.accepted b/birdie_snapshots/response_serializer_for_simple_type.accepted index cec3340..66be394 100644 --- a/birdie_snapshots/response_serializer_for_simple_type.accepted +++ b/birdie_snapshots/response_serializer_for_simple_type.accepted @@ -43,7 +43,7 @@ pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { pub fn get_user(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUser { user { id name } }", + "\n query GetUser {\n user {\n id\n name\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/response_serializer_with_all_scalar_types.accepted b/birdie_snapshots/response_serializer_with_all_scalar_types.accepted index 8950926..c046dd5 100644 --- a/birdie_snapshots/response_serializer_with_all_scalar_types.accepted +++ b/birdie_snapshots/response_serializer_with_all_scalar_types.accepted @@ -66,7 +66,7 @@ pub fn get_product_response_to_json(input: GetProductResponse) -> json.Json { pub fn get_product(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetProduct { product { id name price inStock rating metadata } }", + "\n query GetProduct {\n product {\n id\n name\n price\n inStock\n rating\n metadata\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/response_serializer_with_lists.accepted b/birdie_snapshots/response_serializer_with_lists.accepted index a999b06..677c1d4 100644 --- a/birdie_snapshots/response_serializer_with_lists.accepted +++ b/birdie_snapshots/response_serializer_with_lists.accepted @@ -50,7 +50,7 @@ pub fn get_users_response_to_json(input: GetUsersResponse) -> json.Json { pub fn get_users(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUsers { users { id name } }", + "\n query GetUsers {\n users {\n id\n name\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/response_serializer_with_nested_types.accepted b/birdie_snapshots/response_serializer_with_nested_types.accepted index 179eff9..9bd6ee7 100644 --- a/birdie_snapshots/response_serializer_with_nested_types.accepted +++ b/birdie_snapshots/response_serializer_with_nested_types.accepted @@ -64,7 +64,7 @@ pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { pub fn get_user(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUser { user { id name location { city country } } }", + "\n query GetUser {\n user {\n id\n name\n location {\n city\n country\n }\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/response_serializer_with_optional_fields.accepted b/birdie_snapshots/response_serializer_with_optional_fields.accepted index 4454a05..54dbc18 100644 --- a/birdie_snapshots/response_serializer_with_optional_fields.accepted +++ b/birdie_snapshots/response_serializer_with_optional_fields.accepted @@ -45,7 +45,7 @@ pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { pub fn get_user(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUser { user { id name email } }", + "\n query GetUser {\n user {\n id\n name\n email\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/simple_query_function_generation.accepted b/birdie_snapshots/simple_query_function_generation.accepted index 2d5ea21..f9498a0 100644 --- a/birdie_snapshots/simple_query_function_generation.accepted +++ b/birdie_snapshots/simple_query_function_generation.accepted @@ -43,7 +43,7 @@ pub fn get_user_response_to_json(input: GetUserResponse) -> json.Json { pub fn get_user(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetUser { user { id name } }", + "\n query GetUser {\n user {\n id\n name\n }\n }\n ", json.object([]), ) } diff --git a/birdie_snapshots/type_with_reserved_keywords.accepted b/birdie_snapshots/type_with_reserved_keywords.accepted index 0897502..c6759e6 100644 --- a/birdie_snapshots/type_with_reserved_keywords.accepted +++ b/birdie_snapshots/type_with_reserved_keywords.accepted @@ -52,7 +52,7 @@ pub fn get_item_response_to_json(input: GetItemResponse) -> json.Json { pub fn get_item(client: squall.Client) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetItem { item { id type case let } }", + "\n query GetItem {\n item {\n id\n type\n case\n let\n }\n }\n ", json.object([]), ) } diff --git a/example/src/example.gleam b/example/src/example.gleam index 5d546be..f267e86 100644 --- a/example/src/example.gleam +++ b/example/src/example.gleam @@ -6,6 +6,7 @@ import squall // Import the generated GraphQL code import graphql/get_character +import graphql/get_character_with_fragment // For Erlang target (default) import gleam/httpc @@ -16,9 +17,19 @@ import gleam/httpc pub fn main() { let client = squall.new("https://rickandmortyapi.com/graphql", []) + + // Example 1: Simple query + io.println("=== Simple Query ===") let assert Ok(request) = get_character.get_character(client, "1") let assert Ok(body) = send_erlang(request) io.println(body) + + // Example 2: Query with fragments + io.println("\n=== Query with Fragments ===") + let assert Ok(request) = + get_character_with_fragment.get_character_with_fragment(client, "1") + let assert Ok(body) = send_erlang(request) + io.println(body) } // ==================== ERLANG HTTP CLIENT ==================== diff --git a/example/src/graphql/get_character.gleam b/example/src/graphql/get_character.gleam index 5991a16..3e9f034 100644 --- a/example/src/graphql/get_character.gleam +++ b/example/src/graphql/get_character.gleam @@ -1,8 +1,8 @@ import gleam/dynamic/decode import gleam/http/request.{type Request} import gleam/json -import gleam/option.{type Option} import squall +import gleam/option.{type Option} pub type Character { Character( @@ -22,12 +22,14 @@ pub fn character_decoder() -> decode.Decoder(Character) { } pub fn character_to_json(input: Character) -> json.Json { - json.object([ - #("id", json.nullable(input.id, json.string)), - #("name", json.nullable(input.name, json.string)), - #("status", json.nullable(input.status, json.string)), - #("species", json.nullable(input.species, json.string)), - ]) + json.object( + [ + #("id", json.nullable(input.id, json.string)), + #("name", json.nullable(input.name, json.string)), + #("status", json.nullable(input.status, json.string)), + #("species", json.nullable(input.species, json.string)), + ], + ) } pub type GetCharacterResponse { @@ -35,32 +37,26 @@ pub type GetCharacterResponse { } pub fn get_character_response_decoder() -> decode.Decoder(GetCharacterResponse) { - use character <- decode.field( - "character", - decode.optional(character_decoder()), - ) + use character <- decode.field("character", decode.optional(character_decoder())) decode.success(GetCharacterResponse(character: character)) } pub fn get_character_response_to_json(input: GetCharacterResponse) -> json.Json { - json.object([ - #("character", json.nullable(input.character, character_to_json)), - ]) + json.object( + [ + #("character", json.nullable(input.character, character_to_json)), + ], + ) } -pub fn get_character( - client: squall.Client, - id: String, -) -> Result(Request(String), String) { +pub fn get_character(client: squall.Client, id: String) -> Result(Request(String), String) { squall.prepare_request( client, - "query GetCharacter($id: ID!) { character(id: $id) { id name status species } }", + "query GetCharacter($id: ID!) {\n character(id: $id) {\n id\n name\n status\n species\n }\n}\n", json.object([#("id", json.string(id))]), ) } -pub fn parse_get_character_response( - body: String, -) -> Result(GetCharacterResponse, String) { +pub fn parse_get_character_response(body: String) -> Result(GetCharacterResponse, String) { squall.parse_response(body, get_character_response_decoder()) } diff --git a/example/src/graphql/get_character_with_fragment.gleam b/example/src/graphql/get_character_with_fragment.gleam new file mode 100644 index 0000000..20da85a --- /dev/null +++ b/example/src/graphql/get_character_with_fragment.gleam @@ -0,0 +1,84 @@ +import gleam/dynamic/decode +import gleam/http/request.{type Request} +import gleam/json +import squall +import gleam/option.{type Option} + +pub type Character { + Character( + id: Option(String), + name: Option(String), + status: Option(String), + species: Option(String), + origin: Option(Location), + ) +} + +pub fn character_decoder() -> decode.Decoder(Character) { + use id <- decode.field("id", decode.optional(decode.string)) + use name <- decode.field("name", decode.optional(decode.string)) + use status <- decode.field("status", decode.optional(decode.string)) + use species <- decode.field("species", decode.optional(decode.string)) + use origin <- decode.field("origin", decode.optional(location_decoder())) + decode.success(Character( + id: id, + name: name, + status: status, + species: species, + origin: origin, + )) +} + +pub type Location { + Location(name: Option(String)) +} + +pub fn location_decoder() -> decode.Decoder(Location) { + use name <- decode.field("name", decode.optional(decode.string)) + decode.success(Location(name: name)) +} + +pub fn character_to_json(input: Character) -> json.Json { + json.object( + [ + #("id", json.nullable(input.id, json.string)), + #("name", json.nullable(input.name, json.string)), + #("status", json.nullable(input.status, json.string)), + #("species", json.nullable(input.species, json.string)), + #("origin", json.nullable(input.origin, location_to_json)), + ], + ) +} + +pub fn location_to_json(input: Location) -> json.Json { + json.object([#("name", json.nullable(input.name, json.string))]) +} + +pub type GetCharacterWithFragmentResponse { + GetCharacterWithFragmentResponse(character: Option(Character)) +} + +pub fn get_character_with_fragment_response_decoder() -> decode.Decoder(GetCharacterWithFragmentResponse) { + use character <- decode.field("character", decode.optional(character_decoder())) + decode.success(GetCharacterWithFragmentResponse(character: character)) +} + +pub fn get_character_with_fragment_response_to_json(input: GetCharacterWithFragmentResponse) -> json.Json { + json.object( + [ + #("character", json.nullable(input.character, character_to_json)), + ], + ) +} + +pub fn get_character_with_fragment(client: squall.Client, id: String) -> Result(Request(String), String) { + squall.prepare_request( + client, + "fragment CharacterInfo on Character {\n id\n name\n status\n species\n}\n\nquery GetCharacterWithFragment($id: ID!) {\n character(id: $id) {\n ...CharacterInfo\n origin {\n name\n }\n }\n}\n", + json.object([#("id", json.string(id))]), + ) +} + +pub fn parse_get_character_with_fragment_response(body: String) -> Result(GetCharacterWithFragmentResponse, String) { + squall.parse_response(body, get_character_with_fragment_response_decoder()) +} diff --git a/example/src/graphql/get_character_with_fragment.gql b/example/src/graphql/get_character_with_fragment.gql new file mode 100644 index 0000000..875fe6b --- /dev/null +++ b/example/src/graphql/get_character_with_fragment.gql @@ -0,0 +1,15 @@ +fragment CharacterInfo on Character { + id + name + status + species +} + +query GetCharacterWithFragment($id: ID!) { + character(id: $id) { + ...CharacterInfo + origin { + name + } + } +} diff --git a/src/squall.gleam b/src/squall.gleam index ea59486..bea418d 100644 --- a/src/squall.gleam +++ b/src/squall.gleam @@ -201,34 +201,46 @@ fn generate(endpoint: String) { list.each(files, fn(file) { io.println("📝 Processing: " <> file.path) - case graphql_ast.parse(file.content) { - Ok(operation) -> { - case - codegen.generate_operation( - file.operation_name, - operation, - schema_data, - endpoint, - ) - { - Ok(code) -> { - // Write generated code - let output_path = - string.replace(file.path, ".gql", ".gleam") - - case simplifile.write(output_path, code) { - Ok(_) -> { - io.println(" ✓ Generated: " <> output_path) + case graphql_ast.parse_document(file.content) { + Ok(document) -> { + // Extract main operation and fragments + case graphql_ast.get_main_operation(document) { + Ok(operation) -> { + let fragments = graphql_ast.get_fragment_definitions(document) + + case + codegen.generate_operation_with_fragments( + file.operation_name, + file.content, + operation, + fragments, + schema_data, + endpoint, + ) + { + Ok(code) -> { + // Write generated code + let output_path = + string.replace(file.path, ".gql", ".gleam") + + case simplifile.write(output_path, code) { + Ok(_) -> { + io.println(" ✓ Generated: " <> output_path) + } + Error(_) -> { + io.println(" ✗ Failed to write: " <> output_path) + } + } } - Error(_) -> { - io.println(" ✗ Failed to write: " <> output_path) + Error(err) -> { + io.println( + " ✗ Code generation failed: " <> error.to_string(err), + ) } } } Error(err) -> { - io.println( - " ✗ Code generation failed: " <> error.to_string(err), - ) + io.println(" ✗ Parse failed: " <> error.to_string(err)) } } } diff --git a/src/squall/internal/codegen.gleam b/src/squall/internal/codegen.gleam index 7ba06e8..48a5a19 100644 --- a/src/squall/internal/codegen.gleam +++ b/src/squall/internal/codegen.gleam @@ -1,7 +1,7 @@ import glam/doc.{type Document} import gleam/dict import gleam/list -import gleam/option.{None, Some} +import gleam/option import gleam/result import gleam/string import squall/internal/error.{type Error} @@ -244,13 +244,30 @@ fn imports_doc( // Generate code for an operation pub fn generate_operation( operation_name: String, + source: String, operation: graphql_ast.Operation, schema_data: schema.Schema, _graphql_endpoint: String, ) -> Result(String, Error) { - // Extract selections + generate_operation_with_fragments(operation_name, source, operation, [], schema_data, "") +} + +pub fn generate_operation_with_fragments( + operation_name: String, + source: String, + operation: graphql_ast.Operation, + fragments: List(graphql_ast.Operation), + schema_data: schema.Schema, + _graphql_endpoint: String, +) -> Result(String, Error) { + // Extract selections and expand any fragments let selections = graphql_ast.get_selections(operation) + use expanded_selections <- result.try(graphql_ast.expand_fragments( + selections, + fragments, + )) + // Determine root type based on operation type let root_type_name = case graphql_ast.get_operation_type(operation) { graphql_ast.Query -> schema_data.query_type @@ -275,12 +292,15 @@ pub fn generate_operation( // Generate response type let response_type_name = to_pascal_case(operation_name) <> "Response" - // Build field types from selections - use field_types <- result.try(collect_field_types(selections, root_type)) + // Build field types from expanded selections + use field_types <- result.try(collect_field_types( + expanded_selections, + root_type, + )) // Collect nested types that need to be generated use nested_types <- result.try(collect_nested_types( - selections, + expanded_selections, root_type, schema_data, )) @@ -348,13 +368,13 @@ pub fn generate_operation( }) |> list.flatten - // Generate function + // Use the original source string directly (includes fragment definitions) let function_def = generate_function( operation_name, response_type_name, variables, - build_query_string(operation), + source, schema_data.types, ) @@ -1413,68 +1433,6 @@ fn encode_variable_value( } } -fn build_query_string(operation: graphql_ast.Operation) -> String { - let op_type = case graphql_ast.get_operation_type(operation) { - graphql_ast.Query -> "query" - graphql_ast.Mutation -> "mutation" - graphql_ast.Subscription -> "subscription" - } - - let op_name = case graphql_ast.get_operation_name(operation) { - Some(name) -> " " <> name - None -> "" - } - - let variables = graphql_ast.get_variables(operation) - let var_defs = case variables { - [] -> "" - vars -> { - let defs = - vars - |> list.map(fn(var) { - "$" - <> graphql_ast.get_variable_name(var) - <> ": " - <> graphql_ast.get_variable_type_string(var) - }) - |> string.join(", ") - "(" <> defs <> ")" - } - } - - let selections = graphql_ast.get_selections(operation) - let selection_set = build_selection_set(selections) - - op_type <> op_name <> var_defs <> " " <> selection_set -} - -fn build_selection_set(selections: List(graphql_ast.Selection)) -> String { - let fields = - selections - |> list.filter(fn(selection) { - case selection { - parser.Field(_, _, _, _) -> True - parser.FragmentSpread(_) | parser.InlineFragment(_, _) -> False - } - }) - |> list.map(fn(selection) { - case selection { - parser.Field(name, _alias, args, nested) -> { - let args_str = format_arguments(args) - case nested { - [] -> name <> args_str - subs -> name <> args_str <> " " <> build_selection_set(subs) - } - } - _ -> "" - // This should never happen due to the filter above - } - }) - |> string.join(" ") - - "{ " <> fields <> " }" -} - // Helper functions fn capitalize(s: String) -> String { @@ -1491,52 +1449,6 @@ fn to_pascal_case(s: String) -> String { |> string.join("") } -fn format_value(value: parser.ArgumentValue) -> String { - case value { - parser.IntValue(i) -> i - parser.FloatValue(f) -> f - parser.StringValue(s) -> "\"" <> s <> "\"" - parser.BooleanValue(True) -> "true" - parser.BooleanValue(False) -> "false" - parser.NullValue -> "null" - parser.EnumValue(name) -> name - parser.VariableValue(name) -> "$" <> name - parser.ListValue(values) -> { - let formatted_values = - values - |> list.map(format_value) - |> string.join(", ") - "[" <> formatted_values <> "]" - } - parser.ObjectValue(fields) -> { - let formatted_fields = - fields - |> list.map(fn(field) { - let #(name, value) = field - name <> ": " <> format_value(value) - }) - |> string.join(", ") - "{ " <> formatted_fields <> " }" - } - } -} - -fn format_arguments(arguments: List(parser.Argument)) -> String { - case arguments { - [] -> "" - args -> { - let formatted_args = - args - |> list.map(fn(arg) { - let parser.Argument(name, value) = arg - name <> ": " <> format_value(value) - }) - |> string.join(", ") - "(" <> formatted_args <> ")" - } - } -} - fn snake_case(s: String) -> String { s |> string.to_graphemes diff --git a/src/squall/internal/graphql_ast.gleam b/src/squall/internal/graphql_ast.gleam index b3df0cb..392e55f 100644 --- a/src/squall/internal/graphql_ast.gleam +++ b/src/squall/internal/graphql_ast.gleam @@ -1,7 +1,9 @@ /// Adapter module that wraps swell's GraphQL parser and provides /// helper functions for working with the AST in Squall's code generation. /// This provides a clean interface that the rest of Squall can use. +import gleam/list import gleam/option.{type Option} +import gleam/result import squall/internal/error.{type Error} import swell/lexer import swell/parser @@ -35,6 +37,36 @@ pub type OperationType { Subscription } +/// Parse a GraphQL query string into a full Document +/// This includes all operations (queries, mutations, fragment definitions) +pub fn parse_document(source: String) -> Result(Document, Error) { + case parser.parse(source) { + Ok(document) -> Ok(document) + Error(parser.LexerError(lexer_error)) -> { + case lexer_error { + lexer.UnexpectedCharacter(char, pos) -> + Error(error.InvalidGraphQLSyntax( + "lexer", + pos, + "Unexpected character: " <> char, + )) + lexer.UnterminatedString(pos) -> + Error(error.InvalidGraphQLSyntax("lexer", pos, "Unterminated string")) + lexer.InvalidNumber(num, pos) -> + Error(error.InvalidGraphQLSyntax( + "lexer", + pos, + "Invalid number: " <> num, + )) + } + } + Error(parser.UnexpectedToken(_token, msg)) -> + Error(error.InvalidGraphQLSyntax("parser", 0, msg)) + Error(parser.UnexpectedEndOfInput(msg)) -> + Error(error.InvalidGraphQLSyntax("parser", 0, msg)) + } +} + /// Parse a GraphQL query string into an Operation /// Returns the first operation from the document pub fn parse(source: String) -> Result(Operation, Error) { @@ -133,3 +165,90 @@ pub fn get_variable_type_string(variable: Variable) -> String { let parser.Variable(_, type_str) = variable type_str } + +/// Get all fragment definitions from a document +pub fn get_fragment_definitions(document: Document) -> List(Operation) { + let parser.Document(operations) = document + operations + |> list.filter(fn(op) { + case op { + parser.FragmentDefinition(_, _, _) -> True + _ -> False + } + }) +} + +/// Get the first non-fragment operation from a document +/// This is typically the query or mutation to execute +pub fn get_main_operation(document: Document) -> Result(Operation, Error) { + let parser.Document(operations) = document + operations + |> list.find(fn(op) { + case op { + parser.FragmentDefinition(_, _, _) -> False + _ -> True + } + }) + |> result.replace_error(error.InvalidGraphQLSyntax( + "parse", + 0, + "No executable operation found in document", + )) +} + +/// Find a fragment definition by name +pub fn find_fragment( + fragments: List(Operation), + name: String, +) -> Option(Operation) { + fragments + |> list.find(fn(fragment) { + case fragment { + parser.FragmentDefinition(fragment_name, _, _) -> fragment_name == name + _ -> False + } + }) + |> option.from_result +} + +/// Expand all fragment spreads in a list of selections +/// This recursively expands fragments and handles nested fragments +pub fn expand_fragments( + selections: List(Selection), + fragments: List(Operation), +) -> Result(List(Selection), Error) { + selections + |> list.try_map(fn(selection) { + case selection { + parser.Field(name, alias, arguments, nested_selections) -> { + // Recursively expand fragments in nested selections + use expanded_nested <- result.try(expand_fragments( + nested_selections, + fragments, + )) + Ok([parser.Field(name, alias, arguments, expanded_nested)]) + } + parser.FragmentSpread(fragment_name) -> { + // Find the fragment and expand its selections + case find_fragment(fragments, fragment_name) { + option.Some(fragment) -> { + let fragment_selections = get_selections(fragment) + // Recursively expand in case fragment contains other fragments + expand_fragments(fragment_selections, fragments) + } + option.None -> + Error(error.InvalidGraphQLSyntax( + "fragment", + 0, + "Fragment '" <> fragment_name <> "' is not defined", + )) + } + } + parser.InlineFragment(_type_condition, inline_selections) -> { + // Expand fragments in the inline fragment's selections + expand_fragments(inline_selections, fragments) + } + } + }) + |> result.map(list.flatten) +} diff --git a/test/codegen_test.gleam b/test/codegen_test.gleam index d016d4f..1b54e87 100644 --- a/test/codegen_test.gleam +++ b/test/codegen_test.gleam @@ -56,7 +56,7 @@ pub fn generate_simple_query_test() { ) let result = - codegen.generate_operation("get_user", operation, mock_schema, "") + codegen.generate_operation("get_user", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -123,7 +123,7 @@ pub fn generate_query_with_variables_test() { ) let result = - codegen.generate_operation("get_user", operation, mock_schema, "") + codegen.generate_operation("get_user", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -202,7 +202,7 @@ pub fn generate_query_with_nested_types_test() { ) let result = - codegen.generate_operation("get_character", operation, mock_schema, "") + codegen.generate_operation("get_character", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -274,7 +274,7 @@ pub fn generate_mutation_test() { ) let result = - codegen.generate_operation("create_user", operation, mock_schema, "") + codegen.generate_operation("create_user", mutation_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -339,7 +339,7 @@ pub fn generate_with_reserved_keywords_test() { ) let result = - codegen.generate_operation("get_item", operation, mock_schema, "") + codegen.generate_operation("get_item", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -411,7 +411,7 @@ pub fn generate_inline_scalar_arguments_test() { ) let result = - codegen.generate_operation("get_character", operation, mock_schema, "") + codegen.generate_operation("get_character", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -498,7 +498,7 @@ pub fn generate_inline_object_arguments_test() { ) let result = - codegen.generate_operation("get_characters", operation, mock_schema, "") + codegen.generate_operation("get_characters", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -572,7 +572,7 @@ pub fn generate_inline_array_arguments_test() { ) let result = - codegen.generate_operation("get_episodes", operation, mock_schema, "") + codegen.generate_operation("get_episodes", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -703,7 +703,7 @@ pub fn generate_multiple_root_fields_test() { ) let result = - codegen.generate_operation("multi_query", operation, mock_schema, "") + codegen.generate_operation("multi_query", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -798,7 +798,7 @@ pub fn generate_mutation_with_input_object_test() { ) let result = - codegen.generate_operation("update_profile", operation, mock_schema, "") + codegen.generate_operation("update_profile", mutation_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -915,7 +915,7 @@ pub fn generate_mutation_with_nested_input_object_test() { ) let result = - codegen.generate_operation("update_profile", operation, mock_schema, "") + codegen.generate_operation("update_profile", mutation_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -989,7 +989,7 @@ pub fn generate_query_with_all_non_nullable_fields_test() { ) let result = - codegen.generate_operation("get_product", operation, mock_schema, "") + codegen.generate_operation("get_product", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1060,7 +1060,7 @@ pub fn generate_query_with_json_scalar_test() { ) let result = - codegen.generate_operation("get_profile", operation, mock_schema, "") + codegen.generate_operation("get_profile", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1144,7 +1144,7 @@ pub fn generate_mutation_with_json_input_field_test() { ) let result = - codegen.generate_operation("update_settings", operation, mock_schema, "") + codegen.generate_operation("update_settings", mutation_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1245,7 +1245,7 @@ pub fn generate_mutation_with_optional_input_fields_test() { ) let result = - codegen.generate_operation("create_profile", operation, mock_schema, "") + codegen.generate_operation("create_profile", mutation_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1321,7 +1321,7 @@ pub fn generate_query_with_optional_response_fields_test() { ) let result = - codegen.generate_operation("get_profile", operation, mock_schema, "") + codegen.generate_operation("get_profile", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1390,7 +1390,7 @@ pub fn generate_response_serializer_simple_test() { ) let result = - codegen.generate_operation("get_user", operation, mock_schema, "") + codegen.generate_operation("get_user", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1454,7 +1454,7 @@ pub fn generate_response_serializer_with_optional_fields_test() { ) let result = - codegen.generate_operation("get_user", operation, mock_schema, "") + codegen.generate_operation("get_user", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1537,7 +1537,7 @@ pub fn generate_response_serializer_with_nested_types_test() { ) let result = - codegen.generate_operation("get_user", operation, mock_schema, "") + codegen.generate_operation("get_user", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1604,7 +1604,7 @@ pub fn generate_response_serializer_with_lists_test() { ) let result = - codegen.generate_operation("get_users", operation, mock_schema, "") + codegen.generate_operation("get_users", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1695,7 +1695,7 @@ pub fn generate_response_serializer_with_all_scalars_test() { ) let result = - codegen.generate_operation("get_product", operation, mock_schema, "") + codegen.generate_operation("get_product", query_source, operation, mock_schema, "") case result { Ok(code) -> { @@ -1705,3 +1705,87 @@ pub fn generate_response_serializer_with_all_scalars_test() { Error(_) -> Nil } } + +// Test: Generate query with simple fragment spread +pub fn generate_query_with_fragment_spread_test() { + let query_source = + " + fragment UserFields on User { + id + name + email + } + + query GetUser { + user { + ...UserFields + createdAt + } + } + " + + let assert Ok(document) = graphql_ast.parse_document(query_source) + let assert Ok(operation) = graphql_ast.get_main_operation(document) + let fragments = graphql_ast.get_fragment_definitions(document) + + // Create mock schema + let user_fields = [ + schema.Field( + "id", + schema.NonNullType(schema.NamedType("ID", schema.Scalar)), + [], + None, + ), + schema.Field("name", schema.NamedType("String", schema.Scalar), [], None), + schema.Field("email", schema.NamedType("String", schema.Scalar), [], None), + schema.Field( + "createdAt", + schema.NamedType("String", schema.Scalar), + [], + None, + ), + ] + + let mock_schema = + schema.Schema( + Some("Query"), + None, + None, + dict.from_list([ + #("User", schema.ObjectType("User", user_fields, None)), + #( + "Query", + schema.ObjectType( + "Query", + [ + schema.Field( + "user", + schema.NamedType("User", schema.Object), + [], + None, + ), + ], + None, + ), + ), + ]), + ) + + let result = + codegen.generate_operation_with_fragments( + "get_user", + query_source, + operation, + fragments, + mock_schema, + "", + ) + + case result { + Ok(code) -> { + code + |> birdie.snap(title: "Query with simple fragment spread") + } + Error(_) -> Nil + } +}