diff --git a/CHANGELOG.md b/CHANGELOG.md index 9897e0d..96b6b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 0.3.0 - 2024-06-25 +## 0.3.1 - 2024-07-01 + +### Fixed + +* Fix problems with generating example queries and mutations when the return types are unions, interfaces or lists: [#49](https://github.com/podium/graphql_markdown/pull/49) + +## 0.3.0 - 2024-06-25 [RETIRED] ### Added diff --git a/lib/graphql_markdown/markdown_helpers.ex b/lib/graphql_markdown/markdown_helpers.ex index 5fb044e..8fc6a99 100644 --- a/lib/graphql_markdown/markdown_helpers.ex +++ b/lib/graphql_markdown/markdown_helpers.ex @@ -175,7 +175,7 @@ defmodule GraphqlMarkdown.MarkdownHelpers do @spec returned_fields(OperationDetailsHelpers.return_type()) :: String.t() defp returned_fields(%{kind: "SCALAR"}), do: "" - defp returned_fields(%{kind: "OBJECT"} = return_type) do + defp returned_fields(%{kind: kind} = return_type) when kind in ~w(OBJECT LIST) do fields = Map.get(return_type, :fields, []) return_values = @@ -192,4 +192,57 @@ defmodule GraphqlMarkdown.MarkdownHelpers do _ -> "" end end + + defp returned_fields(%{kind: "UNION"} = return_type) do + possible_types = Map.get(return_type, :possible_types, []) + + return_values = + for possible_type <- possible_types do + "... on #{possible_type.name} {\n }" + end + + case return_values do + [_ | _] -> + "\n __typename\n " <> Enum.join(return_values, "\n ") + + _ -> + "" + end + end + + defp returned_fields(%{kind: "INTERFACE"} = return_type) do + fields = Map.get(return_type, :fields, []) + possible_types = Map.get(return_type, :possible_types, []) + + interface_fields = + for field <- fields do + if field.type == "OBJECT" do + "#{field.name} {\n }" + else + field.name + end + end + + shared_fields_string = + case interface_fields do + [_ | _] -> "\n " <> Enum.join(interface_fields, "\n ") + _ -> "" + end + + specific_types = + for possible_type <- possible_types do + "... on #{possible_type.name} {\n }" + end + + specific_types_string = + case specific_types do + [_ | _] -> + "\n " <> Enum.join(specific_types, "\n ") + + _ -> + "" + end + + shared_fields_string <> specific_types_string + end end diff --git a/lib/graphql_markdown/operation_details_helpers.ex b/lib/graphql_markdown/operation_details_helpers.ex index b0eee36..90ed786 100644 --- a/lib/graphql_markdown/operation_details_helpers.ex +++ b/lib/graphql_markdown/operation_details_helpers.ex @@ -19,7 +19,8 @@ defmodule GraphqlMarkdown.OperationDetailsHelpers do @type return_type :: %{ name: String.t(), kind: String.t(), - fields: [field()] + fields: [field()], + possible_types: [field()] } @type graphql_operation_details :: %{ @@ -89,6 +90,106 @@ defmodule GraphqlMarkdown.OperationDetailsHelpers do } end + defp return_fields(%{"name" => name, "kind" => "UNION"}, schema_details) do + possible_types = + schema_details + |> Map.get(:unions, []) + |> Enum.find(fn union -> union["name"] == name end) + |> Map.get("possibleTypes", []) + |> Enum.map(fn field -> + name = field["name"] + %{name: name, type: "OBJECT"} + end) + + %{ + name: name, + kind: "UNION", + possible_types: possible_types + } + end + + defp return_fields(%{"name" => name, "kind" => "INTERFACE"}, schema_details) do + interface_fields = + schema_details + |> Map.get(:interfaces, []) + |> Enum.find(fn x -> x["name"] == name end) + |> Map.get("fields", []) + |> Enum.map(fn field -> + field_name = field["name"] + type = return_field_type(field) + %{name: field_name, type: type} + end) + + possible_types = + schema_details + |> Map.get(:interfaces, []) + |> Enum.find(fn interface -> interface["name"] == name end) + |> Map.get("possibleTypes", []) + |> Enum.map(fn field -> + name = field["name"] + %{name: name, type: "OBJECT"} + end) + + %{ + name: name, + kind: "INTERFACE", + fields: interface_fields, + possible_types: possible_types + } + end + + defp return_fields( + %{"name" => name, "kind" => "LIST", "ofType" => %{"kind" => "OBJECT"}} = return_field, + schema_details + ) do + name_of_list_type = get_in(return_field, ["ofType", "name"]) + + fields = + schema_details + |> Map.get(:objects, []) + |> Enum.find(fn object -> object["name"] == name_of_list_type end) + |> Map.get("fields", []) + |> Enum.map(fn field -> + field_name = field["name"] + type = return_field_type(field) + %{name: field_name, type: type} + end) + + %{ + name: name, + kind: "LIST", + fields: fields + } + end + + defp return_fields( + %{ + "name" => name, + "kind" => "NON_NULL", + "ofType" => %{"kind" => "LIST", "ofType" => %{"kind" => "OBJECT"}} + } = return_field, + schema_details + ) do + name_of_list_type = get_in(return_field, ["ofType", "ofType", "name"]) + + fields = + schema_details + |> Map.get(:objects, []) + |> Enum.find(fn object -> object["name"] == name_of_list_type end) + |> Map.get("fields", []) + |> Enum.map(fn field -> + field_name = field["name"] + type = return_field_type(field) + %{name: field_name, type: type} + end) + + %{ + name: name, + kind: "LIST", + fields: fields + } + end + defp return_fields(return_type, _schema_details) do %{ name: return_type["name"], diff --git a/mix.exs b/mix.exs index 6e9007e..713fd02 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule GraphqlMarkdown.MixProject do use Mix.Project @project_url "https://github.com/podium/graphql_markdown" - @version "0.3.0" + @version "0.3.1" def project do [ diff --git a/test/fixtures/schema.json b/test/fixtures/schema.json index 37477e1..1b4316f 100644 --- a/test/fixtures/schema.json +++ b/test/fixtures/schema.json @@ -930,6 +930,158 @@ "name": "Boolean", "possibleTypes": null }, + { + "description": "Information about a character", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The name", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "INTERFACE", + "name": "Character", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Human", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + } + ] + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "age", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "kind": "OBJECT", + "name": "Human", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "primaryFunction", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "kind": "OBJECT", + "name": "Droid", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -1000,6 +1152,70 @@ "name": "LoginResponse", "possibleTypes": null }, + { + "description": "Unacceptable input from user", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The type of violation", + "isDeprecated": false, + "name": "key", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "An explanation on how the user can proceed", + "isDeprecated": false, + "name": "message", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "UnexpectedRequest", + "possibleTypes": null + }, + { + "description": "The response for password login", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "UNION", + "name": "LoginResponseV2", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "LoginResponse", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "UnexpectedRequest", + "ofType": null + } + ] + }, { "description": null, "enumValues": null, @@ -1133,6 +1349,33 @@ "ofType": null } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PasswordLoginInput", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Version 2 of password login", + "isDeprecated": false, + "name": "passwordLoginV2", + "type": { + "kind": "UNION", + "name": "LoginResponseV2", + "ofType": null + } + }, { "args": [ { @@ -1302,6 +1545,99 @@ "name": "UserSsoDetails", "ofType": null } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "episode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "heroForEpisode", + "type": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "episode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Get droids for episode", + "isDeprecated": false, + "name": "droidsInEpisode", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "episode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Get the humans in an episode", + "isDeprecated": false, + "name": "humansInEpisode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Human", + "ofType": null + } + } + } } ], "inputFields": null, diff --git a/test/graphql_markdown/operation_details_helpers_test.exs b/test/graphql_markdown/operation_details_helpers_test.exs index fc2ea1e..e3b69d0 100644 --- a/test/graphql_markdown/operation_details_helpers_test.exs +++ b/test/graphql_markdown/operation_details_helpers_test.exs @@ -56,6 +56,116 @@ defmodule GraphqlMarkdown.OperationDetailsHelpersTest do } } + @login_response %{ + "description" => nil, + "enumValues" => nil, + "fields" => [ + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "idToken", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "refreshToken", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + } + ], + "inputFields" => nil, + "interfaces" => [], + "kind" => "OBJECT", + "name" => "LoginResponse", + "possibleTypes" => nil + } + + @unexpected_request %{ + "description" => "Unacceptable input from user", + "enumValues" => nil, + "fields" => [ + %{ + "args" => [], + "deprecationReason" => nil, + "description" => "The type of violation", + "isDeprecated" => false, + "name" => "key", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => "An explanation on how the user can proceed", + "isDeprecated" => false, + "name" => "message", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + } + ], + "inputFields" => nil, + "interfaces" => [], + "kind" => "OBJECT", + "name" => "UnexpectedRequest", + "possibleTypes" => nil + } + + @login_response_v2 %{ + "description" => "The response for password login", + "enumValues" => nil, + "fields" => nil, + "inputFields" => nil, + "interfaces" => nil, + "kind" => "UNION", + "name" => "LoginResponseV2", + "possibleTypes" => [ + %{"kind" => "OBJECT", "name" => "LoginResponse", "ofType" => nil}, + %{"kind" => "OBJECT", "name" => "UnexpectedRequest", "ofType" => nil} + ] + } + + @password_login_v2_mutation %{ + "args" => [ + %{ + "defaultValue" => nil, + "description" => nil, + "name" => "input", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{ + "kind" => "INPUT_OBJECT", + "name" => "PasswordLoginInput", + "ofType" => nil + } + } + } + ], + "deprecationReason" => nil, + "description" => "Version 2 of password login", + "isDeprecated" => false, + "name" => "passwordLoginV2", + "type" => %{"kind" => "UNION", "name" => "LoginResponseV2", "ofType" => nil} + } + @user_sso_details_object %{ "description" => nil, "enumValues" => nil, @@ -122,9 +232,195 @@ defmodule GraphqlMarkdown.OperationDetailsHelpersTest do "type" => %{"kind" => "OBJECT", "name" => "UserSsoDetails", "ofType" => nil} } + @character_interface %{ + "description" => "Information about a character", + "enumValues" => nil, + "fields" => [ + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "id", + "type" => %{"kind" => "SCALAR", "name" => "ID", "ofType" => nil} + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => "The name", + "isDeprecated" => false, + "name" => "name", + "type" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + ], + "inputFields" => nil, + "interfaces" => [], + "kind" => "INTERFACE", + "name" => "Character", + "possibleTypes" => [ + %{"kind" => "OBJECT", "name" => "Human", "ofType" => nil}, + %{"kind" => "OBJECT", "name" => "Droid", "ofType" => nil} + ] + } + + @human_object %{ + "description" => nil, + "enumValues" => nil, + "fields" => [ + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "id", + "type" => %{"kind" => "SCALAR", "name" => "ID", "ofType" => nil} + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "name", + "type" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "age", + "type" => %{"kind" => "SCALAR", "name" => "Int", "ofType" => nil} + } + ], + "inputFields" => nil, + "interfaces" => [ + %{"kind" => "INTERFACE", "name" => "Character", "ofType" => nil} + ], + "kind" => "OBJECT", + "name" => "Human", + "possibleTypes" => nil + } + + @droid_object %{ + "description" => nil, + "enumValues" => nil, + "fields" => [ + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "id", + "type" => %{"kind" => "SCALAR", "name" => "ID", "ofType" => nil} + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "name", + "type" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + }, + %{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "primaryFunction", + "type" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + ], + "inputFields" => nil, + "interfaces" => [ + %{"kind" => "INTERFACE", "name" => "Character", "ofType" => nil} + ], + "kind" => "OBJECT", + "name" => "Droid", + "possibleTypes" => nil + } + + @hero_for_episode_query %{ + "args" => [ + %{ + "defaultValue" => nil, + "description" => nil, + "name" => "episode", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{ + "kind" => "SCALAR", + "name" => "String", + "ofType" => nil + } + } + } + ], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "heroForEpisode", + "type" => %{"kind" => "INTERFACE", "name" => "Character", "ofType" => nil} + } + + @droids_in_episode_query %{ + "args" => [ + %{ + "defaultValue" => nil, + "description" => nil, + "name" => "episode", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + } + ], + "deprecationReason" => nil, + "description" => "Get disputes", + "isDeprecated" => false, + "name" => "droidsInEpisode", + "type" => %{ + "kind" => "LIST", + "name" => nil, + "ofType" => %{"kind" => "OBJECT", "name" => "Droid", "ofType" => nil} + } + } + + @humans_in_episode_query %{ + "args" => [ + %{ + "defaultValue" => nil, + "description" => nil, + "name" => "episode", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{"kind" => "SCALAR", "name" => "String", "ofType" => nil} + } + } + ], + "deprecationReason" => nil, + "description" => "Get the humans in an episode", + "isDeprecated" => false, + "name" => "humansInEpisode", + "type" => %{ + "kind" => "NON_NULL", + "name" => nil, + "ofType" => %{ + "kind" => "LIST", + "name" => nil, + "ofType" => %{"kind" => "OBJECT", "name" => "Human", "ofType" => nil} + } + } + } + @root_query %{ "fields" => [ - @user_sso_details_query + @user_sso_details_query, + @hero_for_episode_query, + @droids_in_episode_query, + @humans_in_episode_query ], "inputFields" => nil, "interfaces" => [], @@ -135,7 +431,8 @@ defmodule GraphqlMarkdown.OperationDetailsHelpersTest do @root_mutation %{ "fields" => [ - @generate_login_code_mutation + @generate_login_code_mutation, + @password_login_v2_mutation ], "inputFields" => nil, "interfaces" => [], @@ -150,7 +447,17 @@ defmodule GraphqlMarkdown.OperationDetailsHelpersTest do inputs: [], objects: [ @generate_login_code_response_object, - @user_sso_details_object + @user_sso_details_object, + @login_response, + @unexpected_request, + @human_object, + @droid_object + ], + unions: [ + @login_response_v2 + ], + interfaces: [ + @character_interface ] } @@ -223,5 +530,91 @@ defmodule GraphqlMarkdown.OperationDetailsHelpersTest do assert operation_details.return_type == expected_return_type end + + test "returns the return type for an operation that has a union in its return type" do + expected_return_type = %{ + possible_types: [ + %{name: "LoginResponse", type: "OBJECT"}, + %{name: "UnexpectedRequest", type: "OBJECT"} + ], + kind: "UNION", + name: "LoginResponseV2" + } + + operation_details = + OperationDetailsHelpers.generate_operation_details( + "mutations", + @password_login_v2_mutation, + @schema_details + ) + + assert operation_details.return_type == expected_return_type + end + + test "returns the return type for an operation that has an interface in its return type" do + expected_return_type = %{ + fields: [ + %{name: "id", type: "SCALAR"}, + %{name: "name", type: "SCALAR"} + ], + possible_types: [ + %{name: "Human", type: "OBJECT"}, + %{name: "Droid", type: "OBJECT"} + ], + kind: "INTERFACE", + name: "Character" + } + + operation_details = + OperationDetailsHelpers.generate_operation_details( + "queries", + @hero_for_episode_query, + @schema_details + ) + + assert operation_details.return_type == expected_return_type + end + + test "returns the return type for an operation that has a list as its return type" do + expected_return_type = %{ + fields: [ + %{name: "id", type: "SCALAR"}, + %{name: "name", type: "SCALAR"}, + %{name: "primaryFunction", type: "SCALAR"} + ], + kind: "LIST", + name: nil + } + + operation_details = + OperationDetailsHelpers.generate_operation_details( + "queries", + @droids_in_episode_query, + @schema_details + ) + + assert operation_details.return_type == expected_return_type + end + + test "returns the return type for an operation that has a non-null list as its return type" do + expected_return_type = %{ + fields: [ + %{name: "id", type: "SCALAR"}, + %{name: "name", type: "SCALAR"}, + %{name: "age", type: "SCALAR"} + ], + kind: "LIST", + name: nil + } + + operation_details = + OperationDetailsHelpers.generate_operation_details( + "queries", + @humans_in_episode_query, + @schema_details + ) + + assert operation_details.return_type == expected_return_type + end end end