Current section

Files

Jump to
tornex lib tornex spec_query.ex
Raw

lib/tornex/spec_query.ex

# Copyright 2024-2025 tiksan
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule Tornex.SpecQuery do
@moduledoc """
The query struct containing API request data for API v2 requests.
The `Tornex.SpecQuery` struct works against the OpenAPI specification for the Torn API v2.
`Tornex.SpecQuery` can be used by `Tornex.API.get/1` and `Tornex.Scheduler.Bucket.enqueue/1`
to make API calls. The struct stores the required information to perform the API call. To use
APIv2 calls with the OpenAPI specification and an autogenerated client, you will need to add
the [Tornium/torngen_elixir_client](https://github.com/Tornium/torngen_elixir_client) library
to your dependencies; this library includes the paths and schemas required to make and parse
APIv2 calls. Alternatively, to make APIv2 calls without the OpenAPI specification, use
`Tornex.Query` instead and prepend the resource with `v2/` (e.g. `v2/user`).
## Preparing Query
Create an empty query using `new/0`. Use `put_path/2` and `put_parameter!/3` to set up the query.
iex> query =
...> Tornex.SpecQuery.new(nice: 0)
...> |> Tornex.SpecQuery.put_key("foo")
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Bounties)
...> |> Tornex.SpecQuery.put_parameter!(:id, 2383326)
## Making Query
The `SpecQuery` can be executed using `Tornex.API.get/1` and `Tornex.Scheduler.Bucket.enqueue/1`
to perform the API calls. Once the API call has been finished, the response can be parsed into
schema structs and validated using `parse/2`.
iex> api_response = Tornex.API.get(query)
iex> parsed_response = Tornex.SpecQuery.parse(query, api_response)
"""
@host Application.compile_env(:tornex, :base_url) || "https://api.torn.com/v2"
@typedoc """
Path or query parameters following the paths provided to the `Tornex.SpecQuery`.
The parameter should be of the form `{:parameter_key, parameter_value}`. For example, for the `{crimeId}`
path parameter in `Torngen.Client.Path.Torn.CrimeId.Subcrimes` would be `{:crimeId, 1}` to list the
subcrimes of `Search for Cash`.
"""
@type parameter :: {atom(), term()}
@type niceness :: -20..20
@typedoc """
The fallback value for the ID of the resource.
This only needs to be set to `{:parameter_key, parameter_value}` when `Tornex.Scheduler.Bucket.enqueue/1`
is used. For example, `Torngen.Client.User.Equipment` would have a `:resource_id` of `{:id, 2_383_326}`.
"""
@type resource_id :: {atom(), term()} | nil
@type t :: %__MODULE__{
paths: [module()],
parameters: [parameter()],
key: String.t() | nil,
# Values required for the scheduler
key_owner: non_neg_integer(),
nice: niceness(),
origin: GenServer.from() | nil,
quarantine?: boolean(),
resource_id: resource_id()
}
defstruct [
:paths,
:parameters,
:key,
# Values required for the scheduler
:key_owner,
:nice,
:origin,
:quarantine?,
:resource_id
]
@doc """
Initialize an empty query against the OpenAPI specification.
By default, the niceness of the request will be set to 20 and the key owner will be set to 0. The key
owner of 0 is intended to be for requests where the owner of the key is not known (e.g. for determining
the owner of the API key).
## Options
* `:paths` - list of API paths
* `:parameters` - list of query/path parameters
* `:key` - API key
* `:key_owner` - ID of the owner of the API key (default: `0`)
* `:nice` - Priority of the API call between -20 and 20 (default: `20`)
* `:quarantine?` - Boolean to quarantine the query from the `QueryRegistry` (default: `false`)
* `:resource_id` - fallback ID of the resource for use in the scheduler (default: `nil`)
"""
@spec new(opts :: Keyword.t()) :: t()
def new(opts \\ []) do
%__MODULE__{
paths: Keyword.get(opts, :paths, []),
parameters: Keyword.get(opts, :parameters, []),
key: Keyword.get(opts, :key, nil),
key_owner: Keyword.get(opts, :key_owner, 0),
nice: Keyword.get(opts, :nice, 20),
origin: nil,
quarantine?: Keyword.get(opts, :quarantine?, false),
resource_id: Keyword.get(opts, :resource_id, nil)
}
end
@doc """
Add an API key to the query.
"""
@spec put_key(query :: t(), api_key :: String.t()) :: t()
def put_key(%__MODULE__{} = query, api_key) when is_binary(api_key) do
%__MODULE__{query | key: api_key}
end
@doc """
Add the owner of the API key to the query.
"""
@spec put_key_owner(query :: t(), key_owner :: pos_integer()) :: t()
def put_key_owner(%__MODULE__{} = query, key_owner) when is_integer(key_owner) do
%__MODULE__{query | key_owner: key_owner}
end
@doc """
Add an OpenAPI path to the query.
The OpenAPI path must be an implementation of the `Torngen.Client.Path` behavior (originating from the `torngen`
library). However, multiple base resources can not be combined into one query when adding paths to the query.
This includes paths for the same resource type (e.g. user) but differing resource IDs such as
`/user/${id}/bounties`.
If the path is already in the query, the path will not be added to the query.
## Examples
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Attacks)
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Bounties)
"""
@spec put_path(query :: t() | Tornex.Scheduler.ExecutionUnit.t(), path :: module()) :: t()
def put_path(%{paths: paths} = query, path) when is_atom(path) do
if Enum.member?(paths, path) do
query
else
%{query | paths: [path | paths]}
end
end
@doc """
Add an OpenAPI parameter to the query.
During the execution of the query, the parameter will be inserted as either a path or query parameter depending
on the OpenAPI specifications for the paths included in the query. The `parameter_value` must be an implementation
of the `String.Chars` protocol. If `parameter_value` is a list and the `parameter_name` is a query parameter,
the values of the list will be joined with commas and used as the parameter value.
If the parameter name is already in the query, a `RuntimeError` will be raised.
## Examples
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Personalstats)
...> |> Tornex.SpecQuery.put_parameter(:id, 2383326)
...> |> |> Tornex.SpecQuery.put_parameter(:stat, ["attackswon", "attackslost"])
"""
@deprecated "Use put_parameter!/3 instead."
@spec put_parameter(query :: t(), parameter_name :: atom(), parameter_value :: term()) :: t()
def put_parameter(%__MODULE__{} = query, parameter_name, parameter_value) do
put_parameter!(query, parameter_name, parameter_value)
end
@doc """
Add an OpenAPI parameter to the query.
During the execution of the query, the parameter will be inserted as either a path or query parameter depending
on the OpenAPI specifications for the paths included in the query. The `parameter_value` must be an implementation
of the `String.Chars` protocol. If `parameter_value` is a list and the `parameter_name` is a query parameter,
the values of the list will be joined with commas and used as the parameter value.
If the parameter name is already in the query but the existing parameter value is not equal to the value to
be inserted, a `RuntimeError` will be raised. If the parameter value is not an implementation of the
`String.Chars` protocol, a `Protocol.UndefinedError` will be raised.
## Examples
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Personalstats)
...> |> Tornex.SpecQuery.put_parameter!(:id, 2383326)
...> |> |> Tornex.SpecQuery.put_parameter!(:stat, ["attackswon", "attackslost"])
"""
@spec put_parameter!(
query,
parameter_name :: atom(),
parameter_value :: term()
) :: query
when query: t() | Tornex.Scheduler.ExecutionUnit.t()
def put_parameter!(%{parameters: parameters} = query, parameter_name, parameter_value) when is_atom(parameter_name) do
String.Chars.impl_for!(parameter_value)
case Enum.find(parameters, fn {k, _v} -> k == parameter_name end) do
nil ->
%{query | parameters: [{parameter_name, parameter_value} | parameters]}
{^parameter_name, ^parameter_value} ->
query
{^parameter_name, _existing_parameter_value} ->
raise """
The #{parameter_name} parameter is already in this query. There can not be duplicate parameter keys in a query.
"""
end
end
@doc """
Parse a `SpecQuery` to determine the base path and selections.
Raises `RuntimeError` if the `SpecQuery` does not contain any paths or has conflicting base paths.
## Examples
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Personalstats)
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Bounties)
...> |> Tornex.SpecQuery.put_parameter(:id, 2383326)
...> |> Tornex.SpecQuery.put_parameter(:stats, ["attackswon", "attackslost"])
iex> Tornex.SpecQuery.path_selections!()
{"user/{id}/", ["personalstats", "bounties"]}
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Personalstats)
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Attacks)
...> |> Tornex.SpecQuery.put_parameter(:id, 2383326)
...> |> Tornex.SpecQuery.put_parameter(:stats, ["attackswon", "attackslost"])
iex> Tornex.SpecQuery.path_selections!()
** (RuntimeError) 2 base paths were added to the query **
iex> query = Tornex.SpecQuery.new()
iex> Tornex.SpecQuery.path_selections!()
** (RuntimeError) No paths were added to the query **
"""
@spec path_selections!(query :: t()) :: {String.t(), [String.t()]}
def path_selections!(%__MODULE__{paths: []} = _query) do
raise "No paths were added to the query"
end
def path_selections!(%__MODULE__{} = query) do
{base_path!(query), selections!(query)}
end
@doc """
Generate and validate a URI to perform the `Tornex.SpecQuery`.
Raises `RuntimeError` if the `SpecQuery` does not contain all of the necessary path parameters
or if a base path and selections cannot be determined by `path_selections!/1`.
## Examples
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Personalstats)
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Id.Bounties)
...> |> Tornex.SpecQuery.put_parameter!(:id, 2383326)
...> |> Tornex.SpecQuery.put_parameter!(:stat, ["attackswon", "attackslost"])
iex> Tornex.SpecQuery.uri!(query)
%URI{
scheme: "https",
userinfo: nil,
host: "api.torn.com",
port: 443,
path: "/v2/user/2383326",
query: "selections=personalstats,bounties&stat=attackswon,attackslost",
}
iex> query =
...> Tornex.SpecQuery.new()
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.Faction.Id.Chain)
...> |> Tornex.SpecQuery.put_path(Torngen.Client.Path.Faction.Id.Members)
iex> Tornex.SpecQuery.uri!(query)
** (RuntimeError) Invalid fragment "{id}" in generated URI: https://api.torn.com/v2/faction/{id}/?selections=chain,members **
"""
@spec uri!(query :: t()) :: URI.t()
def uri!(%__MODULE__{parameters: parameters, paths: paths} = query) do
{path, selections} = path_selections!(query)
@host
|> URI.new!()
|> URI.append_path("/" <> path <> "/")
|> append_selections(selections)
|> insert_url_path_parameters(paths, parameters)
|> insert_url_query_parameters(paths, parameters)
|> validate_url!()
|> validate_required_params!(paths)
end
@doc """
Parse the response of an API call against the query into schema structs.
"""
@spec parse(query :: t(), response :: list() | map()) :: %{module() => term()}
def parse(%__MODULE__{paths: paths} = _query, response)
when (is_list(response) or is_map(response)) and Kernel.length(paths) > 0 do
# TODO: Document this function
paths
|> Enum.map(fn path when is_atom(path) -> {path, path.parse(response)} end)
|> Map.new()
end
@doc """
Get the base path of the query.
"""
@spec base_path!(query :: t()) :: String.t()
def base_path!(%__MODULE__{paths: paths} = _query) do
bases =
paths
|> Enum.map(fn path ->
{base, _selection} = path.path_selection()
base
end)
|> Enum.uniq()
case Kernel.length(bases) do
1 -> Enum.at(bases, 0)
base_count -> raise "#{base_count} base paths were added to the query"
end
end
@doc """
Get a list of selections used in the query.
"""
@spec selections!(query :: t()) :: [String.t()]
def selections!(%__MODULE__{paths: paths} = _query) do
paths
|> Enum.map(fn path ->
{_base, selection} = path.path_selection()
selection
end)
|> Enum.uniq()
end
@doc """
Get the resource for the paths used in the query.
"""
@spec resource!(query :: t()) :: String.t()
def resource!(%__MODULE__{} = query) do
query
|> base_path!()
|> String.split("/")
|> Enum.at(0)
end
@doc """
Get the resource ID used in the query.
The resource ID is the variable path parameter in some Torn API OpenAPI specification paths. For example, the resource
ID of `/user/{id}/basic` would be the value the `{id}` path parameter resolves to. Path parameters that are not `{id}`
are less common, but would still apply to this; for example, `/forum/{categoryIds}/threads` would be `{categoryIds}`.
However, not all specification paths have a resource ID though (e.g. `/faction/warfare`) and these apply to the
"owner" of the resource or apply to everyone. For resources such as `user`, the "owner" is the user themself; but
for the `faction` resource, the "owner" is any member of the faction (typically any member of the faction with API
Access permissions).
"""
@spec resource_id!(query :: t()) :: parameter() | nil
def resource_id!(%__MODULE__{paths: paths, parameters: parameters, resource_id: fallback_resource_id} = _query) do
found_parameters =
Enum.filter(parameters, fn {parameter_name, parameter_value} ->
Enum.any?(paths, fn path ->
case path.parameter(parameter_name, parameter_value) do
{:path, path_parameter_name, _value} -> parameter_name == path_parameter_name
_ -> false
end
end)
end)
case found_parameters do
[] ->
# There were no matching path paramters found. Either no resource ID was provided when the query
# struct was constructed; OR there is no resource owner. Since there is no resource ID provided
# by the query's parameters, we can try to use the fallback `:resource_id` if one was provided when `new/1`
# was called.
fallback_resource_id
[{parameter_name, _parameter_value} = resource_id_parameter] when is_atom(parameter_name) ->
resource_id_parameter
_ when is_list(found_parameters) and length(found_parameters) > 1 ->
# There should only ever be one path parameter in the Torn API OpenAPI specification. If there are
# more, either the specification was updated; OR there's an issue with the parsing of the query
# or the query itself.
raise "There were #{length(found_parameters)} path parameters found, but only 1 was expected"
end
end
@spec insert_url_path_parameters(uri :: URI.t(), paths :: [atom()], parameters: [parameter()]) :: URI.t()
defp insert_url_path_parameters(%URI{} = uri, paths, parameters)
when is_list(paths) and is_list(parameters) do
parameters = Enum.filter(parameters, fn {name, value} -> valid_parameter?(paths, :path, name, value) end)
do_insert_path_parameter(uri, parameters)
end
@spec insert_url_query_parameters(uri :: URI.t(), paths :: [atom()], parameters: [parameter()]) :: URI.t()
defp insert_url_query_parameters(%URI{} = uri, paths, parameters) when is_list(paths) and is_list(parameters) do
parameters = Enum.filter(parameters, fn {name, value} -> valid_parameter?(paths, :query, name, value) end)
do_insert_query_parameter(uri, parameters)
end
@spec valid_parameter?(
paths :: [module()],
parameter_type :: :path | :query,
parameter_key :: atom(),
parameter_value :: term()
) :: boolean()
defp valid_parameter?(paths, parameter_type, parameter_key, parameter_value)
when is_list(paths) and parameter_type in [:path, :query] and is_atom(parameter_key) do
Enum.any?(paths, fn path ->
case path.parameter(parameter_key, parameter_value) do
{^parameter_type, _name, _value} -> true
:error -> false
_ -> false
end
end)
end
defp do_insert_path_parameter(%URI{} = uri, [{name, value} | remaining_parameters])
when is_atom(name) do
parameter_name = "{" <> Atom.to_string(name) <> "}"
if not String.contains?(uri.path, parameter_name) do
raise "Invalid path parameter #{name}"
end
uri = %URI{
uri
| path: String.replace(uri.path, parameter_name, to_string(value))
}
do_insert_path_parameter(uri, remaining_parameters)
end
defp do_insert_path_parameter(%URI{} = uri, []) do
uri
end
defp do_insert_query_parameter(%URI{} = uri, [{name, value} | remaining_parameters])
when is_atom(name) and is_list(value) do
uri = URI.append_query(uri, "#{name}=#{Enum.join(value, ",")}")
do_insert_query_parameter(uri, remaining_parameters)
end
defp do_insert_query_parameter(%URI{} = uri, [{name, value} | remaining_parameters]) when is_atom(name) do
uri = URI.append_query(uri, "#{name}=#{value}")
do_insert_query_parameter(uri, remaining_parameters)
end
defp do_insert_query_parameter(%URI{} = uri, []) do
uri
end
defp append_selections(%URI{} = uri, selections) when is_list(selections) do
URI.append_query(uri, "selections=" <> Enum.join(selections, ","))
end
defp validate_url!(%URI{} = uri) do
# Need to make sure the user passed a path parameter if they paths includes one
uri_string = URI.to_string(uri)
case Regex.run(~r"\{.*\}", uri_string) do
nil ->
uri
[matched_fragment] when is_binary(matched_fragment) ->
raise "Invalid fragment \"#{matched_fragment}\" in generated URI: #{uri_string}"
_ ->
raise "Unknown fragment matching behavior for generated URI: #{uri_string}"
end
end
defp validate_required_params!(%URI{} = uri, paths) when is_list(paths) do
do_validate_required_params!(uri, paths)
end
defp do_validate_required_params!(%URI{} = uri, [_path | remaining_paths]) do
# TODO: Validate that each required parameter exists
# NOTE: This could be done with guards on parameters that check values:
# required params: is_integer(value)
# optional params: is_nil(value) or is_integer(value)
do_validate_required_params!(uri, remaining_paths)
end
defp do_validate_required_params!(%URI{} = uri, []) do
uri
end
@doc """
Merge a `SpecQuery` into another `SpecQuery`.
WARNING: It is assumed that it has already been validated that it is possible to merge the
two `SpecQuery` in terms of security and how the API functions.
"""
@spec merge(query :: t(), acc :: t() | Tornex.Scheduler.ExecutionUnit.t()) :: t() | Tornex.Scheduler.ExecutionUnit.t()
def merge(%__MODULE__{paths: paths, parameters: parameters, nice: nice} = _query, %mod{nice: acc_nice} = acc)
when mod in [__MODULE__, Tornex.Scheduler.ExecutionUnit] do
acc = Enum.reduce(paths, acc, fn path, acc -> put_path(acc, path) end)
acc =
Enum.reduce(parameters, acc, fn {parameter_key, parameter_value}, acc when is_atom(parameter_key) ->
try do
put_parameter!(acc, parameter_key, parameter_value)
rescue
# We want to ignore runtime errors from duplicate parameter keys with non-equal values.
RuntimeError -> acc
end
end)
# We want to use the minimum niceness between the accumulator and the provided query to set the priority of the
# query as high as possible (lowest niceness is highest priority).
%{acc | nice: min(acc_nice, nice)}
end
end