Current section
Files
Jump to
Current section
Files
lib/client.ex
defmodule SanityEx.Client do
@moduledoc """
`SanityEx.Client` is used for interactions with the Sanity HTTP API.
It provides an interface for making queries, sending patches, and constructing URLs for asset IDs.
"""
use GenServer
require Jason
@doc """
Starts the client with the given options.
This function is typically called when your application starts.
## Options
* `:project_id` - The project ID for your Sanity project
* `:api_version` - The dated version of the Sanity API to use.
* `:token` - The API token for making authorized requests.
The following are optional:
* `:dataset` - The dataset to query against. Defaults to `production`.
* `:asset_url` - The base asset URL to use. Defaults to `cdn.sanity.io/images/{project_id}/{dataset}/`.
## Examples
iex> SanityEx.Client.start_link([
project_id: "your_project_id",
api_version: "v2021-06-07",
dataset: "dev",
token: "your_token"
])
{:ok, %{ ... }}
"""
@spec start_link(Keyword.t()) :: {:ok, pid()} | {:error, any()}
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@spec init(Keyword.t()) :: {:ok, map()}
def init(opts) do
project_id = Keyword.get(opts, :project_id)
api_version = Keyword.get(opts, :api_version)
dataset = Keyword.get(opts, :dataset, "production")
asset_url =
Keyword.get(opts, :asset_url, "https://cdn.sanity.io/images/#{project_id}/#{dataset}/")
token = Keyword.get(opts, :token)
state = %{
:project_id => project_id,
:dataset => dataset,
:api_version => api_version,
:asset_url => asset_url,
:token => token
}
{:ok, state}
end
defp get_headers(token) do
[
{"Content-Type", "application/json"},
{"Authorization", "Bearer #{token}"},
{"Accept", "application/json"}
]
end
defp is_valid_query?(query) do
case is_binary(query) and String.trim(query) != "" do
true -> {:ok, query}
_ -> {:error, :invalid_query}
end
end
defp is_valid_asset?(asset_id) do
case is_binary(asset_id) and String.trim(asset_id) != "" do
true -> {:ok, asset_id}
_ -> {:error, :invalid_asset}
end
end
defp is_valid_url?(url) do
url
|> URI.parse()
|> Map.get(:scheme)
|> case do
nil -> {:error, :invalid_url}
_ -> {:ok, url}
end
end
defp construct_query_url(state, query, api_cdn) do
api =
case api_cdn do
true -> "api"
false -> "apicdn"
end
[
"https://",
to_string(state[:project_id]),
".",
api,
".sanity.io/",
to_string(state[:api_version]),
"/data/query/",
to_string(state[:dataset]),
"/?",
URI.encode_query(%{"query" => query})
]
|> Enum.join()
end
@doc """
Executes a GROQ query against the Sanity API.
Takes a GROQ query as a string and sends it to the Sanity API, returning the body of the response as a string,
or an error tuple on failure.
## Params
* `query_str` - The GROQ query string to be executed.
* `api_cdn` - Query against Sanity's CDN for faster response times - defaults to true if not specified
## Examples
iex> SanityEx.Client.query("*[_type == 'post']{title, slug, _id}")
{:ok, sanity_response} | {:error, {:sanity_error, status_code, message}} | {:error, {:fetch_error, message}} | {:error, {:params, message}}
"""
@spec query(String.t(), boolean()) ::
{:ok, String.t()}
| {:error, {:sanity_error, integer(), String.t()}}
| {:error, {:fetch_error, String.t()}}
| {:error, {:params, String.t()}}
def query(query_str, api_cdn \\ true),
do: GenServer.call(__MODULE__, {:fetch, query_str, api_cdn})
@doc """
Executes multiple GROQ queries against the Sanity API asynchronously.
Takes a list of GROQ queries as strings and sends them to the Sanity API.
Each query is executed in a separate process. All queries are waited on and returned as a list.
Each result is a tuple, where the first element is the query and the second is its result.
## Params
- `queries`: A list of GROQ queries as strings
- `api_cdn`: Query against Sanity's CDN for faster response times - defaults to true if not specified
## Returns
A list of tuples where the first element is the query string and the second element is the result of that query.
The result of a query is the body of the response as a string on success, or an error tuple on failure.
## Examples
iex> SanityEx.Client.query_async(["*[_type == 'post']{title, slug, _id}", "*[_type == 'author']{name, _id}"])
[
{"*[_type == 'post']{title, slug, _id}", {:ok, sanity_response1}},
{"*[_type == 'author']{name, _id}", {:ok, sanity_response2}}
]
"""
@spec query_async([String.t()], boolean()) :: [
{String.t(), {:ok, String.t()} | {:error, any()}}
]
def query_async(queries, api_cdn \\ true) do
queries
|> Enum.map(&Task.async(fn -> {&1, GenServer.call(__MODULE__, {:fetch, &1, api_cdn})} end))
|> Enum.map(&Task.await/1)
end
@doc """
Constructs a CDN URL for a given Sanity asset ID.
Takes an asset ID and optional query parameters map, and returns the constructed URL as a string.
It returns `{:ok, url}` on success, or an error tuple on failure.
## Params
* `asset_id` - The ID of the asset for which the URL should be constructed.
* `query_params` - Optional query parameters map to be appended to the URL.
## Examples
iex> SanityEx.Client.url_for("image-asset-id")
{:ok, "https://cdn.sanity.io/images/..."}
iex> SanityEx.Client.url_for("image-asset-id", %{w: 100, h: 100})
{:ok, "https://cdn.sanity.io/images/...?w=100&h=100"}
"""
@spec url_for(String.t(), map() | String.t()) ::
{:ok, String.t()} | {:error, {:params, String.t()}}
def url_for(asset_id, query_params \\ %{}),
do: GenServer.call(__MODULE__, {:url_for, asset_id, query_params})
@doc """
Executes a list of patches on the Sanity API.
This function takes a list of patches and sends them to the Sanity API as a single transaction.
Each patch should be a map that defines an operation on a document.
## Params
- `patches`: A list of patches. Each patch is a map that defines an operation on a document.
## Returns
The function returns the body of the response as a string on success, or an error tuple on failure.
## Examples
iex> SanityEx.Client.patch([
%{
"patch" => %{
"id" => "person-1234",
"set" => %{"name" => "Remington Steele"}
}
},
%{
"patch" => %{
"id" => "remingtons",
"insert" => %{
"after" => "people[-1]",
"items" => [
%{
"_type" => "reference",
"_ref" => "person-1234"
}
]
}
}
}
])
"""
@spec patch(list(map())) :: {:ok, String.t()} | {:error, any()}
def patch(patches) do
GenServer.call(__MODULE__, {:patch, patches})
end
def handle_call({:fetch, query, api_cdn}, _from, state) do
http_client = Application.get_env(:sanity_ex, :http_client, SanityEx.DefaultHTTPClient)
with {:ok, query} <- is_valid_query?(query),
{:ok, url} <- is_valid_url?(construct_query_url(state, query, api_cdn)),
headers <- get_headers(state[:token]) do
case http_client.get(url, headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:reply, {:ok, body}, state}
{:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
{:reply, {:error, {:sanity_error, status_code, body}}, state}
{:error, %HTTPoison.Error{reason: reason}} ->
{:reply, {:error, {:fetch_error, reason}}, state}
_ ->
{:reply, {:error, {:fetch_error, "Unknown error occured during request"}}, state}
end
else
{:error, :invalid_query} ->
{:reply, {:error, {:params, "Query must be a non-empty string"}}, state}
{:error, :invalid_url} ->
{:reply, {:error, {:params, "Query URL must be a valid URL"}}, state}
end
end
def handle_call({:url_for, asset_id, query_params}, _from, state) do
with {:ok, asset_id} <- is_valid_asset?(asset_id),
{:ok, url} <- is_valid_url?(state[:asset_url] <> asset_id) do
case query_params do
%{} ->
{:reply, {:ok, url}, state}
params when is_binary(params) ->
{:reply, {:ok, url <> "?" <> params}, state}
_ ->
{:reply, {:ok, url <> "?" <> URI.encode_query(query_params)}, state}
end
else
{:error, :invalid_asset} ->
{:reply, {:error, {:params, "Asset ID must be a non-empty string"}}, state}
{:error, :invalid_url} ->
{:reply, {:error, {:params, "Asset URL must be a valid URL"}}, state}
end
end
def handle_call({:patch, patches}, _from, state) do
http_client = Application.get_env(:sanity_ex, :http_client, SanityEx.DefaultHTTPClient)
with {:ok, url} <- is_valid_url?(state[:query_url]),
headers <- get_headers(state[:token]),
payload <- Jason.encode(%{"mutations" => patches}) do
case http_client.post(url, payload, headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:reply, {:ok, body}, state}
{:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
{:reply, {:error, {:sanity_error, status_code, body}}, state}
{:error, %HTTPoison.Error{reason: reason}} ->
{:reply, {:error, {:fetch_error, reason}}, state}
_ ->
{:reply, {:error, {:fetch_error, "Unknown error occurred during request"}}, state}
end
else
{:error, :invalid_url} ->
{:reply, {:error, {:params, "Query URL must be a valid URL"}}, state}
end
end
end