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