Current section
Files
Jump to
Current section
Files
lib/graphql/client.ex
defmodule BridgeEx.Graphql.Client do
@moduledoc """
Graphql client for BridgeEx.
"""
require Logger
require OpenTelemetry.Tracer, as: Tracer
alias BridgeEx.Graphql.Utils
alias BridgeEx.Graphql.Retry
alias BridgeEx.Graphql.Formatter.CamelCase
alias OpenTelemetry.SemConv.Incubating.GraphqlAttributes
alias OpenTelemetry.SemConv.Incubating.RPCAttributes
alias OpenTelemetry.SemConv.NetworkAttributes
alias OpenTelemetry.SemConv.ServerAttributes
alias OpenTelemetry.SemConv.URLAttributes
@type bridge_response ::
{:ok, term()}
| {:error, {:bad_response, integer()}}
| {:error, {:http_error, String.t()}}
| {:error, list()}
@http_options timeout: 1_000, recv_timeout: 16_000
@http_headers %{
"Content-type" => "application/json"
}
@doc """
Calls a GraphQL endpoint
## Parameters
* `url`: URL of the endpoint.
* `query`: Graphql query or mutation.
* `variables`: variables for Graphql query or mutation.
* `opts`: various options.
## Options
* `options`: extra HTTP options to be passed to Telepoison.
* `headers`: extra HTTP headers.
* `encode_variables`: whether to JSON encode variables or not.
* `decode_keys`: how JSON keys in GraphQL responses are decoded. Can be set to `:strings` (recommended), `:atoms` (discouraged due to [security concerns](https://hexdocs.pm/jason/Jason.html#decode/2-decoding-keys-to-atoms) - currently the default, but will be changed to :strings in a future version) or `:existing_atoms` (safer, but may crash the application if an unexpected key is received)
* `retry_options`: configures retry attempts. Takes the form of `[max_retries: 1, timing: :exponential]`
* `log_options`: configures logging on errors. Takes the form of `[log_query_on_error: false, log_response_on_error: false]`.
"""
@spec call(
url :: String.t(),
query :: String.t(),
variables :: map(),
opts :: Keyword.t()
) :: bridge_response()
def call(
url,
query,
variables,
opts
) do
query = String.trim(query)
uri = URI.parse(url)
encode_variables = Keyword.get(opts, :encode_variables, false)
http_options = Keyword.merge(@http_options, Keyword.get(opts, :options, []))
http_headers = Map.merge(@http_headers, Keyword.get(opts, :headers, %{}))
log_options = Keyword.merge(log_options(), Keyword.get(opts, :log_options, []))
format_variables = Keyword.get(opts, :format_variables, false)
decode_keys = Keyword.get(opts, :decode_keys, :atoms)
{operation_type, operation_name} = Utils.parse_operation_metadata(query)
span_name = "#{operation_type} #{operation_name}"
unless Keyword.has_key?(opts, :decode_keys),
do:
Logger.warning(
"BridgeEx.Client.call will decode keys using atoms. This is discouraged and will be changed in a future version. To silence this warning, pass `decode_keys: :atoms` to this function or migrate to the safer `decode_keys: :strings` option."
)
retry_options =
opts
|> Keyword.get(:retry_options, [])
|> then(
&Keyword.merge(
[
delay: 100,
max_retries: 0,
policy: fn _ -> true end,
timing: :exponential
],
&1
)
)
variables =
variables
|> do_format_variables(format_variables)
|> do_encode_variables(encode_variables)
span_attributes =
[
{RPCAttributes.rpc_system(), "graphql"},
{GraphqlAttributes.graphql_operation_name(), operation_name},
{GraphqlAttributes.graphql_operation_type(), operation_type}
] ++ uri_attributes(uri)
Tracer.with_span span_name, %{
kind: :client,
attributes: span_attributes
} do
result =
%{query: query, variables: variables}
|> Jason.encode()
|> Noether.Either.bind(
&Retry.retry(
&1,
fn encoded_query ->
url
|> Telepoison.post(encoded_query, http_headers, http_options)
|> Utils.decode_http_response(encoded_query, decode_keys, log_options)
|> Utils.parse_response()
end,
retry_options
)
)
case result do
{:ok, _response} ->
result
{:error, reason} ->
err_msg = error_message(reason)
Tracer.set_status(:error, err_msg)
Tracer.set_attributes([{:"error.message", err_msg}])
result
end
end
end
defp error_message(reason) do
cond do
is_exception(reason) -> Exception.message(reason)
is_binary(reason) -> reason
is_atom(reason) -> Atom.to_string(reason)
true -> inspect(reason)
end
end
defp uri_attributes(%URI{host: nil}), do: []
defp uri_attributes(%URI{host: host, port: port, scheme: scheme} = uri),
do: [
{NetworkAttributes.network_protocol_name(), scheme},
{ServerAttributes.server_port(), port},
{URLAttributes.url_full(), URI.to_string(uri)},
{ServerAttributes.server_address(), host}
]
defp log_options do
global_log_options = Application.get_env(:bridge_ex, :log_options, [])
if not Enum.empty?(global_log_options) do
Logger.warning(
"Global log_options is deprecated and will be removed in the future, please use the local ones"
)
end
Keyword.merge(
[log_query_on_error: false, log_response_on_error: false],
global_log_options
)
end
@spec do_format_variables(any(), bool()) :: any
defp do_format_variables(variables, true), do: CamelCase.format(variables)
defp do_format_variables(variables, false), do: variables
@spec do_encode_variables(any(), bool()) :: any()
defp do_encode_variables(variables, true), do: Jason.encode!(variables)
defp do_encode_variables(variables, false), do: variables
end