lib/ores.ex

defmodule Wiki.Ores do
  @moduledoc """
  This module provides an adapter for the [ORES](https://www.mediawiki.org/wiki/ORES) scoring service.

  ## Examples

  ```elixir
  "enwiki"
  |> Wiki.Ores.new()
  |> Wiki.Ores.request(
    models: ["damaging", "wp10"],
    revids: 456789
  )
  |> Jason.encode!(pretty: true)
  |> IO.puts()
  ```
  """

  alias Wiki.Util

  # TODO:
  #  * Wrap models?
  #  * Chunk at 50 revisions per request.
  #  * Offer parallelism up to 4.

  @doc """
  Create a new ORES client.

  ## Arguments

  - `project` - Short code for the wiki where your articles appear.  For example, "enwiki" for English Wikipedia.

  ## Return value

  Returns an opaque client object, which should be passed to `request/2`.
  """
  @spec new(String.t()) :: Tesla.Client.t()
  def new(project) do
    url = endpoint() <> project <> "/"

    client([
      {Tesla.Middleware.BaseUrl, url}
    ])
  end

  defp endpoint do
    Application.get_env(:wiki_elixir, :ores_endpoint, "https://ores.wikimedia.org/v3/scores/")
  end

  @doc """
  Make an ORES request.

  Don't request scores for more than 50 revisions per request.

  ## Arguments

  - `client` - Client object as returned by `new/1`.
  - `params` - Keyword list of query parameters,
    - `:models` - Learning models to query.  These vary per wiki, see the [support matrix](https://tools.wmflabs.org/ores-support-checklist/)
    for availability and to read about what each model is scoring.  Multiple models can be passed as a list, for example,
    `[:damaging, :wp10]`, or as a single atom, `:damaging`.
    - `:revids` - Revision IDs to score, as a single integer or as a list.
  """
  @spec request(Tesla.Client.t(), keyword | map) :: map
  def request(client, params) do
    response = Tesla.get!(client, "/", query: normalize(params))

    assert_success(response)

    response.body
  end

  @spec normalize(keyword) :: keyword
  defp normalize(params) do
    params
    |> pipe_lists()
  end

  defp pipe_lists(params) do
    params
    |> Enum.map(fn
      {k, v} when is_list(v) -> {k, Enum.join(v, "|")}
      entry -> entry
    end)
  end

  defp assert_success(result) do
    cond do
      result.body in [nil, "", %{}] ->
        raise "Empty response"

      not is_map(result.body) ->
        raise "Malformed response, HTTP status #{result.status}"

      error = result.body["error"] ->
        raise summarize_error(error)

      result.status < 200 or result.status >= 300 ->
        raise "Error received with HTTP status #{result.status}"

      true ->
        nil
    end
  end

  defp summarize_error(error) do
    error["message"] ||
      error["code"] ||
      "unknown"
  end

  @spec client(list) :: Tesla.Client.t()
  defp client(extra) do
    middleware =
      extra ++
        [
          {Tesla.Middleware.Compression, format: "gzip"},
          {Tesla.Middleware.Headers,
           [
             {"user-agent", Util.user_agent()}
           ]},
          Tesla.Middleware.JSON
          # Debugging only:
          # Tesla.Middleware.Logger
        ]

    Tesla.client(middleware, Util.default_adapter())
  end
end