diff --git a/lib/conversations_client.ex b/lib/conversations_client.ex index 08b010f..3c0cf54 100644 --- a/lib/conversations_client.ex +++ b/lib/conversations_client.ex @@ -9,6 +9,11 @@ defmodule ExMicrosoftBot.Client.Conversations do alias ExMicrosoftBot.Models alias ExMicrosoftBot.Client + @type pagination_opts :: [ + page_size: non_neg_integer(), + continuation_token: String.t() + ] + @doc """ Create a new Conversation. @@ -87,6 +92,36 @@ defmodule ExMicrosoftBot.Client.Conversations do |> deserialize_response(&Models.ResourceResponse.parse/1) end + @doc """ + Fetches all members in a conversation, with pagination. + + @see [API Reference](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0#get-conversation-paged-members) + + ## Options + * `page_size`: the suggested page size. As the description indicates, this + might not be honored by the BotFramework API (i.e. all members + are returned instead). + * `continuation_token`: a token received from a previous request to this API + that indicates the next page. + + *Note:* The REST API reference doesn't mention `page_size` being a suggested + value, but empirical testing and the JavaScript SDK docs corroborate it. + + @see [JavaScript SDK docs](https://docs.microsoft.com/en-us/javascript/api/botframework-connector/conversationsgetconversationpagedmembersoptionalparams?view=botbuilder-ts-latest) + """ + @spec get_paged_members( + service_url :: String.t(), + conversation_id :: String.t(), + opts :: pagination_opts() + ) :: {:ok, Models.PagedMembersResult.t()} | Client.error_type() + def get_paged_members(service_url, conversation_id, opts \\ []) do + service_url + |> conversations_url("/#{conversation_id}/pagedmembers") + |> add_pagination_opts(opts) + |> get() + |> deserialize_response(&Models.PagedMembersResult.parse/1) + end + @doc """ This function takes a ConversationId and returns an array of ChannelAccount[] objects which are the members of the conversation. @@ -94,7 +129,6 @@ defmodule ExMicrosoftBot.Client.Conversations do When ActivityId is passed in then it returns the members of the particular activity in the conversation. - @see [API Reference](https://docs.botframework.com/en-us/restapi/connector/#!/Conversations/Conversations_GetActivityMembers) """ @spec get_members( @@ -192,4 +226,27 @@ defmodule ExMicrosoftBot.Client.Conversations do defp conversations_url(service_url, path), do: conversations_url(service_url) <> path + + defp add_pagination_opts(url, []), do: url + + defp add_pagination_opts(url, opts) do + query = + Enum.reduce(opts, %{}, fn + {:page_size, page_size}, query -> Map.put(query, :pageSize, page_size) + {:continuation_token, token}, query -> Map.put(query, :continuationToken, token) + {unknown, _value}, _query -> raise "unknown pagination param: #{inspect(unknown)}" + end) + + url + |> URI.parse() + |> Map.put(:query, encode_query(query)) + |> URI.to_string() + end + + if Kernel.function_exported?(URI, :encode_query, 2) do + # Elixir > v1.12 + defp encode_query(query), do: URI.encode_query(query, :rfc3986) + else + defp encode_query(query), do: URI.encode_query(query) + end end diff --git a/lib/models/paged_members_result.ex b/lib/models/paged_members_result.ex new file mode 100644 index 0000000..a5dce14 --- /dev/null +++ b/lib/models/paged_members_result.ex @@ -0,0 +1,37 @@ +defmodule ExMicrosoftBot.Models.PagedMembersResult do + @moduledoc """ + BotFramework structure for returning paginated conversation member data. + @see [API Reference](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0#pagedmembersresult-object) + """ + + @derive [Poison.Encoder] + defstruct [:members, :continuationToken] + + @type t :: %__MODULE__{ + members: [ExMicrosoftBot.Models.ChannelAccount.t()], + continuationToken: String.t() | nil + } + + @doc """ + Decodes a map into `ExMicrosoftBot.Models.PagedMembersResult` + """ + @spec parse(map :: map()) :: {:ok, __MODULE__.t()} + def parse(map) when is_map(map) do + {:ok, Poison.Decode.transform(map, %{as: decoding_map()})} + end + + @doc """ + Decodes a JSON string into `ExMicrosoftBot.Models.PagedMembersResult` + """ + @spec parse(json :: String.t()) :: __MODULE__.t() + def parse(json) when is_binary(json) do + elem(parse(Poison.decode!(json)), 1) + end + + @doc false + def decoding_map() do + %__MODULE__{ + members: [ExMicrosoftBot.Models.ChannelAccount.decoding_map()] + } + end +end diff --git a/test/client/conversations_test.exs b/test/client/conversations_test.exs index 84f8489..b427dba 100644 --- a/test/client/conversations_test.exs +++ b/test/client/conversations_test.exs @@ -1,9 +1,9 @@ defmodule ExMicrosoftBot.Client.ConversationsTest do use ExUnit.Case - import Plug.Conn, only: [read_body: 1, resp: 3, get_req_header: 2] + import Plug.Conn, only: [fetch_query_params: 1, read_body: 1, resp: 3, get_req_header: 2] - alias ExMicrosoftBot.Models.{Activity, ChannelAccount, ResourceResponse} + alias ExMicrosoftBot.Models.{Activity, ChannelAccount, PagedMembersResult, ResourceResponse} alias ExMicrosoftBot.Client.Conversations @bypass_port Application.fetch_env!(:ex_microsoftbot, Bypass) |> Keyword.fetch!(:port) @@ -135,4 +135,71 @@ defmodule ExMicrosoftBot.Client.ConversationsTest do ) == {:ok, ""} end end + + describe "get_paged_members/3" do + setup do + bypass = Bypass.open(port: @bypass_port) + {:ok, bypass: bypass} + end + + test "fetches paginated member results", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/v3/conversations/xyz/pagedmembers", fn conn -> + assert conn |> get_req_header("content-type") |> List.first() == "application/json" + assert conn |> get_req_header("accept") |> List.first() == "application/json" + assert conn |> get_req_header("authorization") |> List.first() == "Bearer" + + assert {:ok, "", conn} = read_body(conn) + + response = + Poison.encode!(%{ + members: [ + %{ + id: "42", + name: "James", + surname: "Lindy", + email: "james@lindy.com" + } + ], + continuationToken: "zzz" + }) + + resp(conn, 200, response) + end) + + assert {:ok, %PagedMembersResult{members: members, continuationToken: token}} = + Conversations.get_paged_members("http://localhost:#{@bypass_port}", "xyz") + + assert members == [ + %ChannelAccount{ + id: "42", + name: "James", + surname: "Lindy", + email: "james@lindy.com", + objectId: nil, + userPrincipalName: nil, + tenantId: nil + } + ] + + assert token == "zzz" + end + + test "accepts pagination args", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/v3/conversations/xyz/pagedmembers", fn conn -> + assert {:ok, "", conn} = read_body(conn) + + %{query_params: query} = fetch_query_params(conn) + + assert query["pageSize"] == "42" + assert query["continuationToken"] == "xxx" + + resp(conn, 200, Poison.encode!(%{members: []})) + end) + + paging = [page_size: 42, continuation_token: "xxx"] + + assert {:ok, %PagedMembersResult{members: [], continuationToken: nil}} = + Conversations.get_paged_members("http://localhost:#{@bypass_port}", "xyz", paging) + end + end end