Skip to main content

lib/typed_gql.ex

defmodule TypedGql do
  @moduledoc """
  Compile-time GraphQL client for Elixir.

  Validates GraphQL operations at compile time and generates typed
  Ecto embedded schemas for responses.

  ## Usage

      defmodule MyApp.GitHub do
        use TypedGql,
          otp_app: :my_app,
          source: "priv/schemas/github.json"

        defgql :get_user, ~GQL\"\"\"
          query($login: String!) {
            user(login: $login) {
              name
            }
          }
        \"\"\"
      end

  ## Options

    * `:otp_app` (required) — the OTP application for runtime config lookup
    * `:source` (required) — path to a schema JSON file (relative to the caller file), or an inline JSON string
    * `:scalars` — custom scalar type mappings (default: `%{}`)
    * `:generation_plugins` — `TypedGql.Generation.Plugin` modules that hook into
      response-type generation. TypedGql's built-in plugins (e.g. `@include`/`@skip`
      handling) always run first; these are appended after them (default: `[]`)
    * `:endpoint` — default GraphQL endpoint URL
    * `:req_options` — default Req options passed directly to `Req.new/1` (keyword list).
      Supports all Req options including middleware/plugins. Common examples:

      - Headers: `req_options: [headers: [authorization: "Bearer token"]]`
      - Timeouts: `req_options: [receive_timeout: 30_000]`
      - Plug (for testing): `req_options: [plug: {Req.Test, MyApp.GitHub}]`

      You can also attach Req plugins via the `:req_options` key. Plugins are
      attached by passing the plugin's `attach/1` options:

          # In config/runtime.exs
          config :my_app, MyApp.GitHub,
            req_options: [auth: {:bearer, System.fetch_env!("GITHUB_TOKEN")}]

          # In test setup
          config :my_app, MyApp.GitHub,
            req_options: [plug: {Req.Test, MyApp.GitHub}]
  """

  alias TypedGql.Query
  alias TypedGql.ResponseDecoder
  alias TypedGql.Result
  alias TypedGql.Schema.Loader

  @use_config_keys [:endpoint, :req_options]

  defmacro __using__(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)
    source = Keyword.fetch!(opts, :source)
    use_config = Keyword.take(opts, @use_config_keys)

    file_source? = is_binary(source) and not Loader.json_content?(source)

    if file_source? do
      absolute = Path.expand(source, Path.dirname(__CALLER__.file))

      unless File.exists?(absolute) do
        raise CompileError,
          description: "schema file not found: #{absolute} (resolved from #{source})"
      end
    end

    external_resource_ast =
      if file_source? do
        quote do
          @external_resource Path.expand(unquote(source), Path.dirname(__ENV__.file))
        end
      end

    # scalars from opts are already AST (may contain {:__aliases__, ...} nodes
    # for module references). Pass through without Macro.escape so they
    # evaluate correctly in the caller's compile context.
    scalars_ast = Keyword.get(opts, :scalars, Macro.escape(%{}))

    # generation_plugins are module reference AST too; pass through unescaped so
    # the {:__aliases__, ...} nodes resolve in the caller's compile context.
    generation_plugins_ast = Keyword.get(opts, :generation_plugins, [])

    quote do
      import TypedGql.Macros

      unquote(external_resource_ast)

      Module.register_attribute(__MODULE__, :typed_gql_fragments, accumulate: true)

      @typed_gql_otp_app unquote(otp_app)
      @typed_gql_scalars unquote(scalars_ast)
      @typed_gql_generation_plugins unquote(generation_plugins_ast)
      @typed_gql_use_config unquote(use_config)
      @typed_gql_schema TypedGql.__load_schema__(unquote(source), __ENV__.file)

      @doc false
      @spec __typed_gql_config__() :: {atom(), keyword()}
      def __typed_gql_config__, do: {@typed_gql_otp_app, @typed_gql_use_config}

      @doc """
      Customizes the `Req.Request` before it is sent.

      Override this callback to attach Req response steps, add headers,
      or apply any other request-level configuration.

      ## Example

          def prepare_req(req) do
            Req.Request.append_response_steps(req,
              my_step: fn {req, resp} ->
                {req, TypedGql.Result.put_resp_assign(resp, :extensions, resp.body["extensions"])}
              end
            )
          end

      ## Accessing the operation in request steps

      The full `%TypedGql.Query{}` being executed is stashed under
      `request.private[:typed_gql_query]`. Request steps (including those you
      attach here) can read it to inspect operation metadata such as
      `function_name`, `operation_name`, or `client_module`:

          def prepare_req(req) do
            Req.Request.append_request_steps(req,
              log_operation: fn req ->
                %TypedGql.Query{function_name: name} = req.private[:typed_gql_query]
                Logger.metadata(gql_operation: name)
                req
              end
            )
          end

      `TypedGql.OperationInfo` is a built-in step that uses this.
      """
      @spec prepare_req(Req.Request.t()) :: Req.Request.t()
      def prepare_req(req), do: req

      defoverridable prepare_req: 1
    end
  end

  @doc false
  @spec __load_schema__(String.t(), String.t()) :: TypedGql.Schema.t()
  def __load_schema__(source, caller_file) do
    resolved = resolve_source(source, caller_file)
    cache_key = schema_cache_key(resolved)

    case :persistent_term.get(cache_key, :not_cached) do
      :not_cached ->
        schema = Loader.load!(resolved)
        :persistent_term.put(cache_key, schema)
        schema

      schema ->
        schema
    end
  end

  @doc """
  Executes a compiled GraphQL query.

  Takes a `%TypedGql.Query{}` struct (produced by `defgql`/`defgqlp`),
  a variables struct (built by `Variables.build/1`), and optional keyword options.

  Options override runtime config which overrides compile-time defaults.
  """
  @spec execute(Query.t(), struct() | map(), keyword()) ::
          {:ok, Result.t()} | {:error, Req.Response.t() | Exception.t()}
  def execute(query, variables \\ %{}, opts \\ [])

  def execute(%Query{} = query, variables, opts) do
    variables_json = dump_variables(variables)

    body = %{query: query.document, variables: variables_json}

    body =
      if query.operation_name, do: Map.put(body, :operationName, query.operation_name), else: body

    request =
      query.client_module
      |> build_request(opts, json: body)
      # stash the full query so request steps (e.g. TypedGql.OperationInfo)
      # can read operation metadata off request.private
      |> Req.Request.put_private(:typed_gql_query, query)

    case Req.post(request) do
      {:ok, %{status: status} = response} when status >= 200 and status <= 299 ->
        decode_response(response, query.result_module)

      {:ok, response} ->
        {:error, response}

      {:error, exception} ->
        {:error, exception}
    end
  end

  defp dump_variables(%{__struct__: _module} = variables) do
    Ecto.embedded_dump(variables, :json)
  end

  defp dump_variables(variables) when is_map(variables), do: variables

  defp decode_response(%Req.Response{body: body} = response, result_module)
       when is_map(body) do
    data =
      case Map.get(body, "data") do
        data when is_map(data) -> ResponseDecoder.decode!(result_module, data)
        _nil_or_absent -> nil
      end

    errors =
      body
      |> Map.get("errors", [])
      |> Enum.map(&TypedGql.Error.from_json/1)

    assigns = Result.assigns_from_response(response)

    {:ok, %Result{data: data, errors: errors, assigns: assigns}}
  end

  defp decode_response(%Req.Response{body: body} = response, result_module)
       when is_binary(body) do
    case TypedGql.JSON.decode(body) do
      {:ok, decoded} ->
        decode_response(%{response | body: decoded}, result_module)

      {:error, reason} when is_exception(reason) ->
        {:error, reason}

      {:error, reason} ->
        {:error, RuntimeError.exception(inspect(reason))}
    end
  end

  @spec build_request(module(), keyword(), keyword()) :: Req.Request.t()
  defp build_request(client_module, execute_opts, base_opts) do
    {otp_app, use_config} = client_module.__typed_gql_config__()
    runtime_config = Application.get_env(otp_app, client_module, [])

    {use_req_opts, use_rest} = Keyword.pop(use_config, :req_options, [])
    {runtime_req_opts, runtime_rest} = Keyword.pop(runtime_config, :req_options, [])
    {exec_req_opts, exec_rest} = Keyword.pop(execute_opts, :req_options, [])

    config =
      [endpoint: nil]
      |> Keyword.merge(use_rest)
      |> Keyword.merge(runtime_rest)
      |> Keyword.merge(exec_rest)

    endpoint =
      config[:endpoint] ||
        raise ArgumentError, "TypedGql: :endpoint is required but was not configured"

    [use_req_opts, runtime_req_opts, exec_req_opts]
    |> Enum.reduce(Req.new([url: endpoint] ++ base_opts), &Req.merge(&2, &1))
    |> client_module.prepare_req()
  end

  defp resolve_source(source, caller_file) do
    if Loader.json_content?(source) do
      source
    else
      Path.expand(source, Path.dirname(caller_file))
    end
  end

  defp schema_cache_key(resolved_source) do
    if Loader.json_content?(resolved_source) do
      {__MODULE__, :schema, :erlang.phash2(resolved_source)}
    else
      {__MODULE__, :schema, resolved_source}
    end
  end
end