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