Current section
Files
Jump to
Current section
Files
lib/dspace/api.ex
defmodule DSpace.API do
@moduledoc """
Represents a DSpace API client configuration.
## Basic Usage / Configuration
dspace_ex doesn't prescribe the configuration strategy of consuming applications. For API
interactions, declare a `t:DSpace.API.t/0` structure with the necessary configuration when you
need it:
client = %DSpace.API{
endpoint: "https://example.com/server",
access_token: "my-access-token",
csrf_token: "my-csrf-token"
}
Then, use the client struct to perform an operation:
{:ok, item} =
"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|> DSpace.API.Item.fetch()
|> DSpace.API.request(client)
See `new/1` for all client configuration options.
## Session Management
Per DSpace API contract, *all* responses need to be monitored for CSRF token updates, regardless
of client or use context. The actual implementation of the DSpace backend does not currently
refresh CSRF tokens with every request, only with login/logout, when the token refresh endpoints
are called, or the client sends an invalid token.
The `:on_response_hook` field allows applications to handle CSRF token updates. When set, the
function will be invoked whenever a response header contains a CSRF token (which should be every
response).
client = %DSpace.API{
endpoint: "https://example.com/server",
on_response_hook: &MyApp.Session.update_csrf/1
}
The hook will receive a map with a `:csrf_token` key. Invocation is synchronous and will block
until the hook returns. It's probably best to think about using a separate process or a task to
invoke the function for asynchronous handling, depending on your session management strategy.
"""
import DSpace.Utils
alias DSpace.API.Auth
alias DSpace.API.Operation
@external_resource version = DSpace.MixProject.project()[:version]
# DSpace 9.2 / cris-2025.02.00
@api_version "9.2.0"
@user_agent "dspace-ex/#{version}"
@default_http_impl {DSpace.API.HTTP.Req, []}
@derive {Inspect, except: [:access_token, :csrf_token]}
defstruct endpoint: %URI{},
access_token: nil,
csrf_token: nil,
api_version: @api_version,
user_agent: @user_agent,
http_impl: @default_http_impl,
on_response_hook: nil
@typedoc """
A DSpace API client structure.
"""
@type t :: %__MODULE__{
endpoint: URI.t() | binary() | (-> term()),
access_token: nil | binary(),
csrf_token: nil | binary(),
api_version: binary(),
user_agent: binary(),
http_impl: {module(), keyword()},
on_response_hook: nil | (map() -> :ok)
}
# Public API
@doc """
Creates a new API client.
## Parameters
This function takes either
* a keyword list of attributes or
* a single argument that represents the DSpace REST endpoint; either as
* an `t:URI.t/0` structure
* a string
* a 0-arity function that returns a `t:URI.t/0` structure or a string
## Attributes
* `endpoint` - The DSpace REST endpoint, e.g. https://example.com/server. Can be either
* an `t:URI.t/0` structure
* a string
* a 0-arity function that returns a `t:URI.t/0` structure or a string
* `:access_token` - Optional login token or API key used for authentication
* `:csrf_token` - Optional CSRF token. Needed for all modifying requests
* `:api_version` - Optional DSpace API version as a string, defaults to `#{@api_version}`
* `:user_agent` - Optional User agent string, defaults to `#{@user_agent}`
* `:http_impl` - Optional HTTP adapter implementation and options as `{module, options}`
* `:on_response_hook` - Optional callback function invoked when CSRF tokens are updated
"""
@spec new(attributes :: keyword()) :: t()
def new(attributes) when is_list(attributes) do
struct(__MODULE__, attributes)
end
@spec new(endpoint :: URI.t() | binary() | fun()) :: t()
def new(endpoint) when is_struct(endpoint, URI) or is_nonempty_binary(endpoint) or is_function(endpoint, 0) do
new(endpoint: endpoint)
end
@doc """
Updates the API endpoint.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `endpoint` - The DSpace REST endpoint, e.g. https://example.com/server. Can be either
* an `t:URI.t/0` structure
* a string
* a 0-arity function that returns a `t:URI.t/0` structure or a string
"""
@spec put_endpoint(t(), endpoint :: URI.t() | binary()) :: t()
def put_endpoint(%__MODULE__{} = api, endpoint) when is_struct(endpoint, URI) or is_function(endpoint, 0) do
%{api | endpoint: endpoint}
end
def put_endpoint(%__MODULE__{} = api, endpoint) when is_nonempty_binary(endpoint) do
endpoint = URI.parse(endpoint)
put_endpoint(api, endpoint)
end
@doc """
Updates the Access token.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `access_token` - Login token or API key as a string
"""
@spec put_access_token(t(), access_token :: binary()) :: t()
def put_access_token(%__MODULE__{} = api, access_token) when is_nonempty_binary(access_token) do
%{api | access_token: access_token}
end
@doc """
Updates the CSRF token.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `csrf_token` - CSRF token as a string
"""
@spec put_csrf_token(t(), csrf_token :: binary()) :: t()
def put_csrf_token(%__MODULE__{} = api, csrf_token) when is_nonempty_binary(csrf_token) do
%{api | csrf_token: csrf_token}
end
@doc """
Updates the session hook function.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `on_response_hook` - A 1-arity function invoked when CSRF tokens are updated
"""
@spec put_on_response_hook(t(), (map() -> :ok) | nil) :: t()
def put_on_response_hook(%__MODULE__{} = api, on_response_hook)
when is_function(on_response_hook, 1) or is_nil(on_response_hook) do
%{api | on_response_hook: on_response_hook}
end
@doc """
Updates the HTTP adapter implementation.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `http_impl` - A tuple of `{module, options}` where module implements `DSpace.API.HTTP`
behaviour.
"""
@spec put_http_impl(t(), adapter :: {module(), keyword()}) :: t()
def put_http_impl(%__MODULE__{} = api, {module, options} = http_impl) when is_atom(module) and is_list(options) do
%{api | http_impl: http_impl}
end
@doc """
Updates the user agent.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `user_agent` - User agent as a string
"""
@spec put_user_agent(t(), user_agent :: binary()) :: t()
def put_user_agent(%__MODULE__{} = api, user_agent) when is_nonempty_binary(user_agent) do
%{api | user_agent: user_agent}
end
@doc """
Updates the API version.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `version` - DSpace API version as a string
"""
@spec put_api_version(t(), version :: binary()) :: t()
def put_api_version(%__MODULE__{} = api, version) when is_nonempty_binary(version) do
%{api | api_version: version}
end
@doc """
Authenticates with a DSpace API and returns an updated client structure.
Returns returns a `t:DSpace.API.t/0` client structure with updated access- and CSRF tokens.
Managing token lifecycle (checking expiry, deciding when to refresh) is the responsibility of
the consuming application. The token is a JWT and contains an `exp` claim. See
`DSpace.API.Auth.refresh_access_token/0`.
Executing this operation will fetch a CSRF token from the API first if none is configured in the
client struct, since that is a prerequisite for hitting the login endpoint.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
* `username` - Username as a string
* `password` - Password as a string
## Usage
The login operation is executed directly when calling this function. The returned client can
then immediately be used for follow-up operations:
client =
[endpoint: "https://example.com/server"]
|> DSpace.API.new()
|> DSpace.API.login("username", "password")
items =
Item.list()
|> DSpace.API.stream!(client)
"""
@spec login(t(), username, password) :: {:ok, t()} | {:error, Exception.t()}
when username: binary(), password: binary()
def login(%__MODULE__{} = api, username, password) when is_nonempty_binary(username) and is_nonempty_binary(password) do
login = Auth.login(username, password)
case request(login, api, transform: &Auth.tokens_from_response/1) do
{:ok, {auth_token, csrf_token}} -> {:ok, %{api | access_token: auth_token, csrf_token: csrf_token}}
{:error, _reason} = error -> error
end
end
@doc """
Authenticates the client with a DSpace API and raises on errors.
Similar to `login/3`, but returns the updated client structure directly or raises on errors.
"""
@spec login!(t(), username, password) :: t()
when username: binary(), password: binary()
def login!(%__MODULE__{} = api, username, password)
when is_nonempty_binary(username) and is_nonempty_binary(password) do
case login(api, username, password) do
{:ok, api} -> api
{:error, reason} -> raise reason
end
end
@doc """
Updates the path of an operation with a continuation URL.
This function is intended to be used with paginated responses, where the continuation URL
is returned as the third element of the response tuple `{items, meta, next_url}`.
Alternatively, stream operations that return paginated results, as `stream!/3` wraps
pagination automatically and returns a lazy Stream of resources.
## Parameters
* `operation` - A `t:DSpace.API.Operation.t/0`
* `next_url` - The continuation URL returned as the third element of a paginated response
tuple `{items, meta, next_url}`
## Example
client = DSpace.API.new("https://example.com/server")
operation = Collection.list()
{:ok, {collections, _meta, next_url}} = DSpace.API.request(operation, client)
{:ok, {more_collections, _meta, _next_url}} =
operation
|> DSpace.API.next_page(next_url)
|> DSpace.API.request(client)
"""
@spec next_page(Operation.JSON.t(), binary()) :: Operation.JSON.t()
def next_page(%Operation.JSON{} = operation, next_url) when is_nonempty_binary(next_url) do
# `next_url` is already fully built, so we drop the params of the initial operation
%{operation | path: next_url, params: []}
end
@doc """
Verifies if the passed client is authenticated with the DSpace API.
Returns `false` if the server indicates the client is not authenticated, or if the server is
unreachable. Callers who want to separate transport errors from authentication status should
request `DSpace.API.Auth.status/0` directly.
## Parameters
* `api` - A `t:DSpace.API.t/0` structure
"""
@spec authenticated?(t()) :: boolean()
def authenticated?(%__MODULE__{} = api) do
match?({:ok, true}, request(Auth.status(), api))
end
@doc """
Makes a request to the API and returns a result or an error.
## Parameters
* `operation` - A `t:DSpace.API.Operation.t/0`
* `api` - A `t:DSpace.API.t/0` structure
* `options` - Keyword list of options for the request
## Options
* `:transform` - How to transform the API response. Can bei either
* a 1-arity function that takes a `t:DSpace.API.HTTP.Response.t/0` struct and returns a
transformed value
* `false` - passes the raw `t:DSpace.API.HTTP.Response.t/0` struct
* request option overrides passed to the HTTP adapter
## Examples
client = DSpace.API.new("https://example.com/server")
{:ok, item} =
"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|> DSpace.API.Item.fetch()
|> DSpace.API.request(client)
"""
@spec request(Operation.t(), t(), keyword()) :: {:ok, term()} | {:error, Exception.t()}
def request(operation, %__MODULE__{} = api, options \\ []) when is_list(options) do
Operation.perform(operation, api, options)
end
@doc """
Makes a request to the API and returns a result or raises an error.
For parameters and options, see `request/3`.
"""
@spec request!(Operation.t(), t(), keyword()) :: term()
def request!(operation, %__MODULE__{} = api, options \\ []) when is_list(options) do
case request(operation, api, options) do
{:ok, result} -> result
{:error, exception} -> raise exception
end
end
@doc """
Makes a request to the API and returns a stream.
## Parameters
* `operation` - A `t:DSpace.API.Operation.t/0`
* `api` - A `t:DSpace.API.t/0` structure
* `options` - Keyword list of options for the request
## Options
* request option overrides passed to the HTTP adapter
## Examples
client = DSpace.API.new("https://example.com/server")
stream =
[query: "software errors"]
|> DSpace.API.Item.find()
|> DSpace.API.stream!(client)
Enum.take(stream, 5)
"""
@spec stream!(Operation.t(), t(), keyword()) :: Enumerable.t()
def stream!(operation, %__MODULE__{} = api, options \\ []) when is_list(options) do
Operation.stream!(operation, api, options)
end
end