defmodule Wiki.Action.Session do @moduledoc """ This module provides a struct for holding private connection state and accumulated results. ## Fields - `result` - Map with recursively merged values from all requests made using this session. - `state` - Cache for session state and accumulation. """ @type client :: Req.Request.t() @type result :: {:ok, t()} | {:error, any} @type state :: keyword @type t :: %__MODULE__{ __client__: client, result: map, state: keyword } defstruct __client__: nil, result: %{}, state: [] end defmodule Wiki.Action do @moduledoc """ Adapter to the MediaWiki [Action API](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Main_page) To create an API client call `new/2` and specify a site to connect to. Most functions return an api session which holds the latest results and can be reused to pipe chain requests together. As an example, when getting site statistics: ### Request ``` Wiki.Action.new(:dewiki) |> Wiki.Action.get!( action: :query, meta: :siteinfo, siprop: :statistics ) ``` ### Response ``` %Wiki.Action.Session{ ... result: %{ "batchcomplete" => true, "query" => %{ "statistics" => %{ "activeusers" => 19393, "admins" => 188, "articles" => 2583636, "edits" => 211249646, "images" => 130213, "jobs" => 0, "pages" => 7164514, "queued-massmessages" => 0, "users" => 3716049 } } }, ... } ``` ## Authentication Log in with a [bot password](https://www.mediawiki.org/wiki/Manual:Bot_passwords): ### Request ``` authed_api = Wiki.Action.new(:enwiki) |> Wiki.Action.authenticate!( Application.get_env(:example_app, :bot_username), Application.get_env(:example_app, :bot_password) ) ``` ### Response ``` %Wiki.Action.Session{ ... result: %{ "login" => %{ "lguserid" => 2, "lgusername" => "Admin", "result" => "Success" }, ... } ``` Now the client can be used to get a token and make an edit: ### Request ``` {:ok, api, token} = Wiki.Action.get_token(authed_api, :csrf) Wiki.Action.post!(api, action: :edit, title: "Sandbox", assert: :user, token: token, appendtext: "~~~~ was here." ) ``` ### Response ``` %Wiki.Action.Session{ ... result: %{ "edit" => %{ "contentmodel" => "wikitext", "new" => true, "newrevid" => 38, "newtimestamp" => "2025-11-06T13:36:43Z", "oldrevid" => 0, "pageid" => 20, "result" => "Success", "title" => "Sandbox", "watched" => true } }, ... } ``` ## Continuation Any action that returns more items than the allowed batch size will provide one or more -`continue` fields which can be transparently used to stream results from multiple requests. ### Request ``` Wiki.Action.new(:dewiki) |> Wiki.Action.stream( action: :query, list: :recentchanges, rclimit: 5 ) |> Stream.flat_map(fn response -> response["query"]["recentchanges"] end) |> Stream.map(fn rc -> rc["timestamp"] <> " " <> rc["title"] end) |> Stream.each(&IO.puts/1) |> Stream.run() ``` ### Response ``` 2025-11-06T13:46:12Z Peter Schnupp 2025-11-06T13:46:03Z Kategorie:Mitglied der Hall of Fame des deutschen Sports 2025-11-06T13:46:02Z Portal:Radsport/Qualitätssicherung 2025-11-06T13:46:02Z Kategorie:Wikipedia:DHBW/2025-Controlling 2025-11-06T13:46:02Z Öffentlicher Personennahverkehr in Wien 2025-11-06T13:45:59Z Brouchaud 2025-11-06T13:45:43Z Steingutfabrik Paetsch 2025-11-06T13:45:38Z Kriegerdenkmal Feldmoching (1923) 2025-11-06T13:45:38Z Hotel Bellevue (Ruhla) 2025-11-06T13:45:35Z Emil und die Detektive 2025-11-06T13:45:33Z Parasesarma ... ``` ## Wikibase The [Wikidata](https://www.wikidata.org/) project provides structured data for other wiki projects, and can be accessed through the Action API. Examples: Search for entities called "alphabet", ``` Wiki.Action.new(:wikidatawiki) |> Wiki.Action.get!( action: :wbsearchentities, search: "alphabet", language: :en ) ``` Search for entities with "Frank Zappa" anywhere in the description or contents, ``` Wiki.Action.new(:wikidatawiki) |> Wiki.Action.get!( action: :wbsearchentities, search: "alphabet", language: :en ) ``` Retrieve all data about a specific entity with ID "Q42", ``` Wiki.Action.new(:wikidatawiki) |> Wiki.Action.get!( action: :wbgetentities, ids: "Q42" ) ``` ## Defaults A few parameters are automatically added for convenience, but can be overridden if desired: * The `:format` parameter defaults to `:json`. * `:formatversion` defaults to `2`. """ require Logger alias Wiki.{Action.Session, Error, ReqDebug, SiteMatrix, Util} @typedoc """ Can be a `SiteMatrix.Spec` as returned by `Wiki.SiteMatrix.get`, dbname as an atom like `:enwiki`, or raw `api.php` endpoint for the wiki you will connect to. For example, "https://en.wikipedia.org/w/api.php" """ @type site_action_handle :: SiteMatrix.Spec.t() | atom() | String.t() @type client_option :: {:debug, true} | {:plug, (Plug.Conn.t() -> Plug.Conn.t()) | (Plug.Conn.t(), Plug.opts() -> Plug.Conn.t()) | {module(), any()}} | {:timeout, integer()} | {:user_agent, binary()} @default_timeout 60_000 @typedoc """ - `:debug` - Turn on verbose logging by setting to `true` - `:plug` - In testing, replace network requests with a Plug-like mock - `:timeout` - API call timeout, in seconds. Defaults to #{@default_timeout / 1000}s - `:user_agent` - Override the generic, built in user-agent header string. """ @type client_options :: [client_option()] @doc """ Create a new client session ## Arguments - `site` - target wiki and its action endpoint - `opts` - configuration options which modify client behavior ## Examples Connect to German Wikipedia: ``` api = Wiki.Action.new(:dewiki) ``` Connect to a local development wiki: ``` api = Wiki.Action.new("http://dev.wiki.local.wmftest.net:8080/w/api.php") ``` """ @spec new(site_action_handle(), client_options()) :: Session.t() def new(site, opts \\ []) def new(dbname, opts) when is_atom(dbname), do: new(SiteMatrix.get!(dbname), opts) def new(spec, opts) when is_struct(spec, SiteMatrix.Spec) do new(SiteMatrix.action_api(spec), spec.opts ++ opts) end def new(action_endpoint, opts) when is_binary(action_endpoint) do %Session{ __client__: client(action_endpoint, opts) } end @doc """ Make requests to authenticate a client session. This should only be done using a [bot username and password](https://www.mediawiki.org/wiki/Manual:Bot_passwords), which can be created for any normal user account. ## Arguments - `session` - Base session pointing to a wiki. - `username` - Bot username, may be different than the final logged-in username. - `password` - Bot password. Protect this string, it allows others to take on-wiki actions on your behalf. ## Return value Authenticated session object. """ @spec authenticate(Session.t(), String.t(), String.t()) :: Session.result() def authenticate(session, username, password) do with {:ok, new_session, token} <- get_token(session, :login) do post(new_session, action: :login, lgname: username, lgpassword: password, lgtoken: token ) end end @doc """ Assertive variant of `authenticate` """ @spec authenticate!(Session.t(), String.t(), String.t()) :: Session.t() def authenticate!(session, username, password) do case authenticate(session, username, password) do {:ok, session} -> session {:error, error} -> raise error end end @doc """ Helper to make a token request The Action API requires various [tokens](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Tokens) for login, edit actions, and so on. This function simplifies the request. # TODO: support multiple atoms """ @spec get_token(Session.t(), atom()) :: {:ok, Session.t(), token :: binary()} | {:error, any} def get_token(session, type) do with {:ok, response} <- get(session, action: :query, meta: :tokens, type: type ), token when is_binary(token) <- response.result["query"]["tokens"]["#{type}token"] do {:ok, response, token} else nil -> {:error, %Error{message: "No token found in successful response"}} x -> x end end @doc """ Upload a file from a local path ### Request ``` authed_api |> Wiki.Action.upload("/tmp/Triops_closeup.jpg") ``` ### Response ``` {:ok, %Wiki.Action.Session{ ... result: %{ "upload" => %{ "filename" => "Triops_closeup.jpg", "imageinfo" => %{ "bitdepth" => 8, "canonicaltitle" => "File:Triops closeup.jpg", "comment" => "", "commonmetadata" => [ %{ "name" => "Copyright", "value" => [%{"name" => 0, "value" => "Karsten Grabow"}] }, %{ "name" => "JPEGFileComment", "value" => [%{"name" => 0, "value" => "Copyright Karsten Grabow"}] } ], "descriptionurl" => "http://dev.wiki.local.wmftest.net:8080/wiki/File:Triops_closeup.jpg", "extmetadata" => %{ "DateTime" => %{ "hidden" => "", "source" => "mediawiki-metadata", "value" => "2025-11-07T21:31:17Z" }, "ObjectName" => %{ "source" => "mediawiki-metadata", "value" => "Triops closeup" } }, "height" => 827, "html" => "..." "mediatype" => "BITMAP", "metadata" => [ %{ "name" => "Copyright", "value" => [%{"name" => 0, "value" => "Karsten Grabow"}] }, %{ "name" => "JPEGFileComment", "value" => [%{"name" => 0, "value" => "Copyright Karsten Grabow"}] }, %{"name" => "MEDIAWIKI_EXIF_VERSION", "value" => 2} ], "mime" => "image/jpeg", "parsedcomment" => "", "sha1" => "12ce3375983eb929fba91bf5267ad932929e9217", "size" => 222944, "timestamp" => "2025-11-07T21:31:17Z", "url" => "http://dev.wiki.local.wmftest.net:8080/w/images/4/45/Triops_closeup.jpg", "user" => "Admin", "userid" => 2, "width" => 1280 }, "result" => "Success" } }, }} ``` Upload with additional parameters: ``` authed_api |> Wiki.Action.upload( "/tmp/Test.jpg", text: "This is a test file", comment: "Upload initial draft from Elixir", ignorewarnings: true ) ``` """ @spec upload(Session.t(), binary(), keyword) :: Session.result() def upload(session, path, params \\ []) when is_list(params), do: upload_file_content(session, Path.basename(path), File.stream!(path), params) @doc """ Upload a file with content passed directly, as binary or a stream """ @spec upload_file_content( Session.t(), filename :: binary(), content :: binary() | Enumerable.t(), keyword ) :: Session.result() def upload_file_content(session, filename, content, params \\ []) do with {:ok, new_session, token} <- get_token(session, :csrf) do request(new_session, method: :post, form_multipart: normalize_params( [ action: :upload, filename: filename, token: token ] ++ params ) ++ [file: {content, filename: filename}] ) end end @doc """ Make an API GET request ## Arguments - `session` - `Wiki.Action.Session` object. - `params` - Keyword list of query parameters as atoms or strings. ## Return value Session object with its `.result` populated. """ @spec get(Session.t(), keyword) :: Session.result() def get(session, params), do: request(session, method: :get, params: params) @doc """ Assertive variant of `get`. """ @spec get!(Session.t(), keyword) :: Session.t() def get!(session, params) do case get(session, params) do {:ok, result} -> result {:error, error} -> raise error end end @doc """ Make an API POST request. ## Arguments - `session` - `Wiki.Action.Session` object. If credentials are required for this action, you should have created this object with the `authenticate/3` function. - `params` - Keyword list of query parameters as atoms or strings. ## Return value Session object with a populated `:result` attribute. """ @spec post(Session.t(), keyword) :: Session.result() def post(session, params) when is_list(params), do: request(session, method: :post, form: normalize_params(params)) @doc """ Assertive variant of `post`. """ @spec post!(Session.t(), keyword) :: Session.t() def post!(session, body) do case post(session, body) do {:ok, result} -> result {:error, error} -> raise error end end @doc """ Make a GET request and follow continuations until exhausted or the stream is closed. ## Arguments - `session` - `Wiki.Action.Session` object. - `params` - Keyword list of query parameters as atoms or strings. ## Return value Enumerable `Stream`, where each returned chunk is a raw result map, possibly containing multiple records. This corresponds to `session.result` from the other entry points. """ @spec stream(Session.t(), keyword) :: Enumerable.t() def stream(session, params) do Stream.resource( fn -> {session, :start} end, fn {prev, :start} -> do_stream_get(prev, params) {prev, :cont} -> get_continuation(prev.result) |> case do nil -> {:halt, nil} continue -> do_stream_get(prev, params ++ continue) end end, fn _ -> nil end ) end defp do_stream_get(session, params) do next = get!(session, params) {[next.result], {next, :cont}} end defp get_continuation(result) do case result do # TODO: Test that a cross between a list and query can be continued # in both dimensions. %{"continue" => continue} -> Map.to_list(continue) %{"query-continue" => continue} -> continue |> Map.values() |> Enum.flat_map(&Map.to_list/1) _ -> nil end end @spec request(Session.t(), keyword) :: Session.result() defp request(session, opts) do with {:ok, response} <- Req.request(session.__client__, opts), {:ok, response} <- validate(response) do {:ok, %Session{ __client__: session.__client__ |> update_cookies(response), result: response.body }} else {:error, error = %Error{}} -> {:error, error} {:error, error} -> {:error, %Error{message: "#{inspect(error)}"}} end end defp update_cookies(request, response), do: Req.Request.merge_options(request, cookie_jar: response.private[:cookie_jar]) @spec normalize_params(nil | keyword) :: keyword defp normalize_params(params) defp normalize_params(nil), do: [] defp normalize_params(params) do defaults = [ format: :json, formatversion: 2 ] (defaults ++ params) |> remove_boolean_false() |> pipe_lists() |> stringify_atoms() |> Enum.sort() |> Enum.dedup() end defp remove_boolean_false(params) do params |> Enum.filter(fn {_, v} -> v not in [false, nil] end) end defp stringify_atoms(params) do Enum.map(params, fn {k, v} when is_atom(v) -> {k, Atom.to_string(v)} {k, v} when not is_binary(v) -> {k, inspect(v)} {k, v} -> {k, v} end) end defp pipe_lists(params) do params |> Enum.map(fn {k, v} when is_list(v) -> {k, pipe_list(v)} entry -> entry end) end defp pipe_list(values) do if Enum.any?(values, fn v -> String.contains?(to_string(v), "|") end) do # Use a special join character because pipe would conflict with the value. unit_separator = "\x1f" Enum.join([""] ++ values, unit_separator) else Enum.join(values, "|") end end defp validate(response) do with nil <- validate_http_status(response.status), nil <- validate_body_type(response.body), nil <- validate_api_errors(response.body) do {:ok, response} 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 with nil <- body["error"], nil <- body["errors"] do nil else error when is_map(error) -> {:error, %Error{message: summarize_legacy_error(error)}} errors when is_list(errors) -> {:error, %Error{message: summarize_new_error(errors)}} end end defp summarize_legacy_error(error) do error["info"] || error["code"] || "Unknown error (legacy format)" end defp summarize_new_error(errors) do # TODO: multiple errors case(List.first(errors)) do %{"text" => text} -> text %{"html" => html} -> html %{"key" => key, "params" => params} -> [key, params] |> List.flatten() |> Enum.join("-") %{"code" => code} -> code _ -> "unknown" end end @spec client(binary(), keyword()) :: Req.Request.t() defp client(url, opts) do ((opts |> Keyword.drop([:debug, :timeout])) ++ [ base_url: url, receive_timeout: opts[:timeout] || @default_timeout, user_agent: opts[:user_agent] || Util.default_user_agent() ]) |> Req.new() |> ReqProxy.attach() |> HttpCookie.ReqPlugin.attach(cookie_jar: HttpCookie.Jar.new()) |> Req.Request.prepend_request_steps( normalize: fn request -> update_in( request.options[:params], &normalize_params/1 ) end ) |> ReqDebug.attach(debug: opts[:debug]) end end