# Copyright 2019 Hubert Chathi # # 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 Polyjuice.Client.Endpoint do defmodule HttpSpec do @typedoc """ The description of how to handle the endpoint. - `method` is the HTTP verb - `headers` is a list of the HTTP headers - `path` is the path of the endpoint, relative to the homeserver's base URL - `query` is an enumerable of `{key, value}` tuples giving the query parameters - `body` is the HTTP body (if any) as a binary, or `{:file, filename}` - `transform` is a function to transform the result (status code, headers, content) to a return value - `auth_required` indicates whether the end point requires authentication """ @type t :: %__MODULE__{ method: atom, path: String.t(), headers: [{String.t(), String.t()}], query: Keyword.t(), body: iodata() | {:file, String.t()} | {Enumerable.t(), integer}, auth_required: boolean, stream_response: boolean } @enforce_keys [:method, :path, :headers] defstruct [ :method, :path, :headers, :query, body: "", auth_required: true, stream_response: false ] @doc "The r0 client URL prefix" def prefix_r0, do: "_matrix/client/r0" @doc "The unstable client URL prefix" def prefix_unstable, do: "_matrix/client/unstable" @doc "The r0 media URL prefix" def prefix_media_r0, do: "_matrix/media/r0" @doc "Headers for endpoints that accept JSON." def accept_json, do: [ {"Accept", "application/json"}, {"Accept-Encoding", "gzip, deflate"} ] @doc "Headers for endpoints that send and accept JSON." def send_json, do: [ {"Accept", "application/json"}, {"Accept-Encoding", "gzip, deflate"}, {"Content-Type", "application/json"} ] @doc """ Create a `GET` request. `prefix` is a prefix for the path. It can be one of `r0` (indicating `_matrix/client/r0`), `unstable` (indicating `_matrix/client/unstable`), `media_r0` (indicating `_matrix/media/r0`), or a string giving any other prefix. `opts` is a keyword list giving any of the other parameters for `Polyjuice.Client.Endpoint.HttpSpec.t()`. """ def get(prefix, path, opts \\ []) when is_binary(path) and is_list(opts) do create(:get, prefix, path, accept_json(), opts) end @doc """ Create a `POST` request. See `get/3`. """ def post(prefix, path, opts \\ []) when is_binary(path) and is_list(opts) do create(:post, prefix, path, send_json(), opts) end @doc """ Create a `PUT` request. See `get/3`. """ def put(prefix, path, opts \\ []) when is_binary(path) and is_list(opts) do create(:put, prefix, path, send_json(), opts) end @doc """ Create a `DELETE` request. See `get/3`. """ def delete(prefix, path, opts \\ []) when is_binary(path) and is_list(opts) do create(:delete, prefix, path, accept_json(), opts) end defp create(method, prefix, path, default_headers, opts) do prefix = case prefix do :r0 -> Polyjuice.Client.Endpoint.HttpSpec.prefix_r0() :unstable -> Polyjuice.Client.Endpoint.HttpSpec.prefix_unstable() :media_r0 -> Polyjuice.Client.Endpoint.HttpSpec.prefix_media_r0() p when is_binary(p) -> String.trim_leading(p, "/") end path = if String.starts_with?(path, "/"), do: [path], else: ["/", path] %Polyjuice.Client.Endpoint.HttpSpec{ method: method, headers: Keyword.get(opts, :headers, default_headers), path: IO.iodata_to_binary([prefix, path]), query: Keyword.get(opts, :query, nil), body: Keyword.get(opts, :body, ""), auth_required: Keyword.get(opts, :auth_required, true), stream_response: Keyword.get(opts, :stream_response, false) } end end defprotocol Proto do @moduledoc """ Matrix client endpoint. """ @doc """ Generate the spec for calling the endpoint via HTTP. """ @spec http_spec(endpoint_args :: __MODULE__.t()) :: Polyjuice.Client.Endpoint.HttpSpec.t() def http_spec(endpoint_args) @doc """ Transform the HTTP result into a return value. """ @spec transform_http_result( endpoint_args :: __MODULE__.t(), status_code :: integer(), headers :: [{String.t(), String.t()}, ...], body :: String.t() ) :: any def transform_http_result(endpoint_args, status_code, headers, body) end defprotocol BodyParser do @fallback_to_any true @spec parse(endpoint_args :: __MODULE__.t(), body :: any) :: any def parse(endpoint_args, body) end defimpl BodyParser, for: Any do def parse(_endpoint_args, body), do: {:ok, body} end @spec parse_response( endpoint_args :: map, status_code :: integer(), headers :: [{String.t(), String.t()}, ...], body :: String.t() ) :: any def parse_response(%{} = endpoint_args, status_code, headers, body) when is_integer(status_code) and is_list(headers) and is_binary(body) do {:ok, decoded_body} = content_decode(body, headers) # make sure it's JSON content with "application/json" <- get_header(headers, "content-type"), {:ok, json} <- Jason.decode(decoded_body) do case status_code do 200 -> Polyjuice.Client.Endpoint.BodyParser.parse(endpoint_args, json) _ -> {:error, status_code, json} end else _ -> {:error, if(status_code == 200, do: 500, else: status_code), %{"errcode" => "CA_UHOREG_POLYJUICE_BAD_RESPONSE", "body" => decoded_body}} end end @spec get_header( headers :: list({String.t(), String.t()}), name :: String.t(), default :: any ) :: any def get_header(headers, name, default \\ nil) do name = String.downcase(name, :ascii) {_, value} = Enum.find( headers, {nil, default}, fn {n, _} -> String.downcase(n, :ascii) == name end ) value end def content_decode(encoded, headers) do case get_header(headers, "content-encoding", "identity") do "identity" -> {:ok, encoded} "gzip" -> {:ok, :zlib.gunzip(encoded)} "deflate" -> {:ok, :zlib.uncompress(encoded)} "x-gzip" -> {:ok, :zlib.gunzip(encoded)} _ -> {:error, :unknown_method} end end end