Packages

Elixir implementation of the Universal Tool Calling Protocol (UTCP)

Current section

Files

Jump to
ex_utcp lib ex_utcp transports graphql.ex
Raw

lib/ex_utcp/transports/graphql.ex

defmodule ExUtcp.Transports.Graphql do
@moduledoc """
Production-ready GraphQL transport implementation for UTCP.
This transport handles GraphQL-based tool providers with:
- Real GraphQL queries, mutations, and subscriptions
- Connection management and pooling
- Authentication support (API Key, Basic, OAuth2)
- Error recovery with retry logic
- Real-time subscription support
- Schema introspection and validation
"""
use ExUtcp.Transports.Behaviour
use GenServer
require Logger
alias ExUtcp.Transports.Graphql.{Connection, Pool, Schema}
defstruct [
:logger,
:connection_timeout,
:pool_opts,
:retry_config,
:max_retries,
:retry_delay
]
@doc """
Creates a new GraphQL transport.
"""
@spec new(keyword()) :: %__MODULE__{}
def new(opts \\ []) do
%__MODULE__{
logger: Keyword.get(opts, :logger, &Logger.info/1),
connection_timeout: Keyword.get(opts, :connection_timeout, 30_000),
pool_opts: Keyword.get(opts, :pool_opts, []),
retry_config: %{
max_retries: Keyword.get(opts, :max_retries, 3),
retry_delay: Keyword.get(opts, :retry_delay, 1000),
backoff_multiplier: Keyword.get(opts, :backoff_multiplier, 2)
},
max_retries: Keyword.get(opts, :max_retries, 3),
retry_delay: Keyword.get(opts, :retry_delay, 1000)
}
end
@doc """
Starts the GraphQL transport GenServer.
"""
@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl ExUtcp.Transports.Behaviour
def register_tool_provider(provider) do
case provider.type do
:graphql ->
case GenServer.call(__MODULE__, {:register_tool_provider, provider}) do
{:ok, tools} -> {:ok, tools}
{:error, reason} -> {:error, reason}
end
_ -> {:error, "GraphQL transport can only be used with GraphQL providers"}
end
end
@impl ExUtcp.Transports.Behaviour
def deregister_tool_provider(provider) do
case provider.type do
:graphql ->
GenServer.call(__MODULE__, {:deregister_tool_provider, provider})
_ -> {:error, "GraphQL transport can only be used with GraphQL providers"}
end
end
@impl ExUtcp.Transports.Behaviour
def call_tool(tool_name, args, provider) do
case provider.type do
:graphql ->
case GenServer.call(__MODULE__, {:call_tool, tool_name, args, provider}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
_ -> {:error, "GraphQL transport can only be used with GraphQL providers"}
end
end
@impl ExUtcp.Transports.Behaviour
def call_tool_stream(tool_name, args, provider) do
case provider.type do
:graphql ->
case GenServer.call(__MODULE__, {:call_tool_stream, tool_name, args, provider}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
_ -> {:error, "GraphQL transport can only be used with GraphQL providers"}
end
end
@impl ExUtcp.Transports.Behaviour
def close do
GenServer.call(__MODULE__, :close)
end
@impl ExUtcp.Transports.Behaviour
def transport_name do
"graphql"
end
@impl ExUtcp.Transports.Behaviour
def supports_streaming? do
true
end
@doc """
Executes a GraphQL query.
"""
@spec query(pid(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def query(provider, query_string, variables \\ %{}, opts \\ []) do
case GenServer.call(__MODULE__, {:query, provider, query_string, variables, opts}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
@doc """
Executes a GraphQL mutation.
"""
@spec mutation(pid(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def mutation(provider, mutation_string, variables \\ %{}, opts \\ []) do
case GenServer.call(__MODULE__, {:mutation, provider, mutation_string, variables, opts}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
@doc """
Executes a GraphQL subscription.
"""
@spec subscription(pid(), String.t(), map(), keyword()) :: {:ok, [map()]} | {:error, term()}
def subscription(provider, subscription_string, variables \\ %{}, opts \\ []) do
case GenServer.call(__MODULE__, {:subscription, provider, subscription_string, variables, opts}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
@doc """
Introspects the GraphQL schema.
"""
@spec introspect_schema(pid(), keyword()) :: {:ok, map()} | {:error, term()}
def introspect_schema(provider, opts \\ []) do
case GenServer.call(__MODULE__, {:introspect_schema, provider, opts}) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
# GenServer callbacks
@impl GenServer
def init(opts) do
state = new(opts)
# Start the connection pool
case Pool.start_link(state.pool_opts) do
{:ok, _pool_pid} ->
{:ok, state}
{:error, reason} ->
{:stop, reason}
end
end
@impl GenServer
def handle_call({:register_tool_provider, provider}, _from, state) do
case discover_tools(provider, state) do
{:ok, tools} -> {:reply, {:ok, tools}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:deregister_tool_provider, _provider}, _from, state) do
# For now, just return ok. In a real implementation, we might want to
# close the specific connection or clean up resources.
{:reply, :ok, state}
end
@impl GenServer
def handle_call({:call_tool, tool_name, args, provider}, _from, state) do
case execute_tool_call(tool_name, args, provider, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:call_tool_stream, tool_name, args, provider}, _from, state) do
case execute_tool_stream(tool_name, args, provider, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:query, provider, query_string, variables, opts}, _from, state) do
case get_connection_and_execute(provider, fn conn ->
Connection.query(conn, query_string, variables, opts)
end, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:mutation, provider, mutation_string, variables, opts}, _from, state) do
case get_connection_and_execute(provider, fn conn ->
Connection.mutation(conn, mutation_string, variables, opts)
end, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:subscription, provider, subscription_string, variables, opts}, _from, state) do
case get_connection_and_execute(provider, fn conn ->
Connection.subscription(conn, subscription_string, variables, opts)
end, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call({:introspect_schema, provider, opts}, _from, state) do
case get_connection_and_execute(provider, fn conn ->
Connection.introspect_schema(conn, opts)
end, state) do
{:ok, result} -> {:reply, {:ok, result}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@impl GenServer
def handle_call(:close, _from, state) do
Pool.close_all_connections()
{:reply, :ok, state}
end
# Private functions
defp discover_tools(provider, state) do
with_retry(fn ->
case Pool.get_connection(provider) do
{:ok, conn} ->
case Connection.introspect_schema(conn, [timeout: state.connection_timeout]) do
{:ok, schema} ->
tools = Schema.extract_tools(schema)
{:ok, tools}
{:error, reason} -> {:error, "Failed to discover tools: #{inspect(reason)}"}
end
{:error, reason} ->
{:error, "Failed to get connection: #{inspect(reason)}"}
end
end, state.retry_config)
end
defp execute_tool_call(tool_name, args, provider, state) do
with_retry(fn ->
case Pool.get_connection(provider) do
{:ok, conn} ->
# Convert tool call to GraphQL query
case build_graphql_operation(tool_name, args) do
{:query, query_string, variables} ->
case Connection.query(conn, query_string, variables, [timeout: state.connection_timeout]) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, "Failed to execute query: #{inspect(reason)}"}
end
end
{:error, reason} ->
{:error, "Failed to get connection: #{inspect(reason)}"}
end
end, state.retry_config)
end
defp execute_tool_stream(tool_name, args, provider, state) do
with_retry(fn ->
case Pool.get_connection(provider) do
{:ok, conn} ->
# Convert tool stream to GraphQL subscription
case build_graphql_subscription(tool_name, args) do
{:subscription, subscription_string, variables} ->
case Connection.subscription(conn, subscription_string, variables, [timeout: state.connection_timeout]) do
{:ok, results} ->
{:ok, %{type: :stream, data: results}}
{:error, reason} ->
{:error, "Failed to execute subscription: #{inspect(reason)}"}
end
end
{:error, reason} ->
{:error, "Failed to get connection: #{inspect(reason)}"}
end
end, state.retry_config)
end
defp get_connection_and_execute(provider, fun, state) do
with_retry(fn ->
case Pool.get_connection(provider) do
{:ok, conn} ->
fun.(conn)
{:error, reason} ->
{:error, "Failed to get connection: #{inspect(reason)}"}
end
end, state.retry_config)
end
defp build_graphql_operation(tool_name, args) do
# Simple implementation - in a real system, this would be more sophisticated
# For now, we'll treat all tool calls as queries
# In a real implementation, this would determine operation type based on tool name or configuration
query_string = """
query #{String.replace(tool_name, ".", "_")}($input: JSON!) {
#{String.replace(tool_name, ".", "_")}(input: $input) {
result
success
error
}
}
"""
variables = %{"input" => args}
{:query, query_string, variables}
end
defp build_graphql_subscription(tool_name, args) do
# Simple implementation - in a real system, this would be more sophisticated
subscription_string = """
subscription #{String.replace(tool_name, ".", "_")}($input: JSON!) {
#{String.replace(tool_name, ".", "_")}(input: $input) {
data
timestamp
}
}
"""
variables = %{"input" => args}
{:subscription, subscription_string, variables}
end
defp with_retry(fun, retry_config, attempt \\ 0) do
case fun.() do
{:ok, result} -> {:ok, result}
{:error, _reason} when attempt < retry_config.max_retries ->
delay = retry_config.retry_delay * :math.pow(retry_config.backoff_multiplier, attempt)
:timer.sleep(round(delay))
with_retry(fun, retry_config, attempt + 1)
{:error, reason} -> {:error, reason}
end
end
end