defmodule GraphQLDocument do @moduledoc """ A utility for building GraphQL documents. (Strings that contain a GraphQL query/mutation.) ## Syntax `GraphQLDocument.to_string` converts nested lists/keyword lists into the analogous GraphQL syntax. Simply write lists and keyword lists "as they look in GraphQL". Let's take a look at some examples. ### Object Fields To request a list of fields in an object, include them in a list: ```elixir [query: [ human: [:name, :height] ]] ``` `GraphQLDocument.to_string/1` will take that Elixir structure and return ```elixir \"\"\" query { human { name height } } \"\"\" ``` ### Arguments When a field includes arguments, wrap the arguments and child fields in a tuple, like this: ```elixir {args, fields} ``` For example, `GraphQLDocument.to_string/1` will take this Elixir structure ```elixir [query: [ human: { [id: "1000"], [:name, :height] } ]] ``` and return this GraphQL document: ```elixir \"\"\" query { human(id: "1000") { name height } } \"\"\" ``` #### Argument types and Enums `GraphQLDocument.to_string/1` will translate Elixir primitives into the analogous GraphQL primitive type for arguments. GraphQL enums can be expressed as an atom (e.g. `FOOT`) or in a tuple syntax, `{:enum, "FOOT"}`. For example: ```elixir [query: [ human: { [id: "1000"], [:name, height: {[unit: FOOT], []}] } ]] ``` becomes ```elixir query { human(id: "1000") { name height(unit: FOOT) } } ``` We can specify `[unit: FOOT]` as `[unit: {:enum, "FOOT"}]`, which is useful for interpolating dynamic values into the query. > #### Expressing arguments without sub-fields {: .tip} > > Notice the slightly complicated syntax above: `height: {[unit: FOOT], []}` > > The way to include arguments is in an `{args, fields}` tuple. So if a > field has arguments but no sub-fields, put `[]` where the sub-fields go. ### Nesting Fields Since GraphQL supports a theoretically infinite amount of nesting, you can also nest as much as needed in the Elixir structure. Furthermore, we can take advantage of Elixir's syntax feature that allows a regular list to be "mixed" with a keyword list: ```elixir # Elixir allows lists with a Keyword List as the final members [:name, :height, friends: [:name, :age]] ``` Using this syntax, we can build a nested structure like this: ```elixir [query: [ human: { [id: "1000"], [ :name, :height, friends: { [olderThan: 30], [:name, :height] } ] } ]] ``` ```elixir query { human(id: "1000") { name height friends(olderThan: 30) { name height } } } ``` ### Aliases In order to name a field with an alias, follow the syntax below, where `me` is the alias and `user` is the field: ```elixir [query: [ me: { :user [id: 100], [:name, :email] } ]] ``` Which will emit this GraphQL document: ```elixir query { me: user(id: 100) { name email } } ``` """ @doc """ Wraps an enum string value (such as user input from a form) into a `GraphQLDocument`-friendly tuple. ### Example iex> GraphQLDocument.enum("soundex") {:enum, "soundex"} """ def enum(str) when is_binary(str), do: {:enum, str} @doc """ Generates GraphQL syntax from a nested Elixir keyword list. ### Example iex> GraphQLDocument.to_string(query: [user: {[id: 3], [:name, :age, :height, documents: [:filename, :url]]}]) \"\"\" query { user(id: 3) { name age height documents { filename url } } }\\ \"\"\" """ def to_string(params, indent_level \\ 0) def to_string(params, indent_level) when is_list(params) or is_map(params) do indent = String.duplicate(" ", indent_level) params |> Enum.map_join("\n", fn field when is_binary(field) or is_atom(field) -> "#{indent}#{field}" {field, sub_fields} when is_list(sub_fields) -> "#{indent}#{field}#{sub_fields(sub_fields, indent, indent_level)}" {field, {args, sub_fields}} -> "#{indent}#{field}#{args(args)}#{sub_fields(sub_fields, indent, indent_level)}" {field_alias, {field, args, sub_fields}} when is_map(args) or is_list(args) -> "#{indent}#{field_alias}: #{field}#{args(args)}#{sub_fields(sub_fields, indent, indent_level)}" end) end def to_string(params, _indent_level) do raise ArgumentError, message: """ [GraphQLDocument] Expected a list of fields. Received: `#{inspect(params)}` Did you forget to enclose it in a list? """ end defp args(args) do unless is_map(args) or is_list(args) do raise "Expected a keyword list or map for args, received: #{inspect(args)}" end if Enum.any?(args) do args_string = Enum.map_join(args, ", ", fn {key, value} -> "#{key}: #{argument(value)}" end) "(#{args_string})" else "" end end defp sub_fields(sub_fields, indent, indent_level) do if Enum.any?(sub_fields) do " {\n#{to_string(sub_fields, indent_level + 1)}\n#{indent}}" else "" end end defp argument(%Date{} = date), do: inspect(Date.to_iso8601(date)) defp argument(%DateTime{} = date_time), do: inspect(DateTime.to_iso8601(date_time)) defp argument(%Time{} = time), do: inspect(Time.to_iso8601(time)) defp argument(%NaiveDateTime{} = naive_date_time), do: inspect(NaiveDateTime.to_iso8601(naive_date_time)) if Code.ensure_loaded?(Decimal) do defp argument(%Decimal{} = decimal), do: Decimal.to_string(decimal) end defp argument({:enum, enum}) when is_binary(enum) do if valid_name?(enum) do enum else raise ArgumentError, message: "[GraphQLDocument] Enums must be a valid GraphQL name, matching this regex: /[_A-Za-z][_0-9A-Za-z]*/" end end defp argument([]), do: "[]" defp argument(enum) when is_list(enum) or is_map(enum) do if is_map(enum) || Keyword.keyword?(enum) do nested_arguments = enum |> Enum.map_join(", ", fn {key, value} -> "#{key}: #{argument(value)}" end) "{#{nested_arguments}}" else nested_arguments = enum |> Enum.map_join(", ", &"#{argument(&1)}") "[#{nested_arguments}]" end end defp argument(value) when is_binary(value) do inspect(value, printable_limit: :infinity) end defp argument(value) when is_number(value) do inspect(value, printable_limit: :infinity) end defp argument(value) when is_boolean(value) do inspect(value) end defp argument(value) when is_atom(value) do raise ArgumentError, message: "[GraphQLDocument] Cannot pass an atom as an argument; received `#{value}`" end # A GraphQL "Name" matches the following regex. # See: http://spec.graphql.org/June2018/#sec-Names defp valid_name?(name) when is_binary(name) do String.match?(name, ~r/^[_A-Za-z][_0-9A-Za-z]*$/) end end