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"], revids: 456789 ) # %{ # "enwiki" => %{ # "models" => %{ # "damaging" => %{"version" => "0.5.1"} # }, # "scores" => %{ # "456789" => %{ # "damaging" => %{ # "score" => %{ # "prediction" => false, # "probability" => %{ # "false" => 0.9784615344695441, # "true" => 0.021538465530455946 # } # } # } # } # } # } # } ``` """ alias Wiki.{Error, Util} @type client_option :: {:adapter, module()} | {:debug, true} | {:endpoint, binary()} | {:user_agent, binary()} @typedoc """ - `:adapter` - Override the HTTP adapter - `:debug` - Turn on verbose logging by setting to `true` - `:endpoint` - Override the base URL to query - `:user_agent` - Override the user-agent header string """ @type client_options :: [client_option()] @default_adapter Tesla.Adapter.Hackney @default_endpoint "https://ores.wikimedia.org/v3/scores/" # 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. - `opts` - Configuration options that can change client behavior ## Return value Returns an opaque client object, which should be passed to `request/2`. """ @spec new(String.t(), client_options()) :: Tesla.Client.t() def new(project, opts \\ []) do endpoint = opts[:endpoint] || @default_endpoint url = endpoint <> project <> "/" client(url, opts) 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) :: {:ok, map} | {:error, any()} def request(client, params) do with {:ok, response} <- Tesla.get(client, "/", query: normalize(params)), {:ok, result} <- validate(response) do {:ok, result.body} end end @doc """ Assertive variant of `request`. """ @spec request!(Tesla.Client.t(), keyword | map) :: map def request!(client, params) do case request(client, params) do {:ok, result} -> result {:error, error = %Error{}} -> raise error {:error, error} -> raise %Error{message: "#{inspect(error)}"} end 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 validate(result) do with nil <- validate_body_type(result.body), nil <- validate_api_errors(result.body), nil <- validate_http_status(result.status) do {:ok, result} end end defp validate_http_status(status) do case status do status when status >= 200 and status < 300 -> nil status -> {:error, %Error{message: "Error received with HTTP status #{status}"}} end end defp validate_body_type(body) do with body when is_map(body) <- body, body when body != %{} <- body do nil else _ -> {:error, %Error{message: "Empty response"}} end end defp validate_api_errors(body) do case body["error"] do nil -> nil error -> {:error, %Error{message: summarize_error(error)}} end end defp summarize_error(error) do error["message"] || error["code"] || "unknown" end @spec client(binary(), client_options()) :: Tesla.Client.t() defp client(url, opts) do adapter = opts[:adapter] || @default_adapter user_agent = opts[:user_agent] || Util.default_user_agent() ([ {Tesla.Middleware.BaseUrl, url}, {Tesla.Middleware.Headers, [ {"user-agent", user_agent} ]}, Tesla.Middleware.FollowRedirects, Tesla.Middleware.JSON, Tesla.Middleware.Compression ] ++ if(opts[:debug], do: [Tesla.Middleware.Logger], else: [])) |> Tesla.client(adapter) end end