diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7c0980..7ccc2478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add support for custom headers in API requests via `:headers` option parameter. Useful for DocuSign-specific headers like `X-DocuSign-Edit` required for locked envelope operations. + ### Bug Fixes - Fix custom opts in API requests - change `:query` to `:params` to match Req API requirements diff --git a/lib/docusign/request_builder.ex b/lib/docusign/request_builder.ex index dd8dd692..f4ed3481 100644 --- a/lib/docusign/request_builder.ex +++ b/lib/docusign/request_builder.ex @@ -54,13 +54,40 @@ defmodule DocuSign.RequestBuilder do - `definitions` (Map) - Map of parameter name to parameter location. - `options` (KeywordList) - The provided optional parameters + ### Special Parameters + + - `:headers` - A map of custom headers to add to the request. These will be merged + with the default headers. This is useful for DocuSign-specific headers like + `X-DocuSign-Edit` which are required for certain operations on locked envelopes. + ### Returns Map + + ### Examples + + # Add custom headers for locked envelope operations + optional_params = %{body: :body} + opts = [ + body: envelope_data, + headers: %{"X-DocuSign-Edit" => ~s({"LockToken": "abc123", "LockDurationInSeconds": "600"})} + ] + add_optional_params(request, optional_params, opts) + """ @spec add_optional_params(map(), %{optional(atom()) => atom()}, keyword()) :: map() def add_optional_params(request, _, []), do: request + def add_optional_params(request, definitions, [{:headers, custom_headers} | tail]) when is_map(custom_headers) do + # Handle custom headers specially - merge them with existing headers + request = + Enum.reduce(custom_headers, request, fn {key, value}, acc -> + add_param(acc, :headers, key, value) + end) + + add_optional_params(request, definitions, tail) + end + def add_optional_params(request, definitions, [{key, value} | tail]) do case definitions do %{^key => location} -> diff --git a/test/docusign/custom_headers_integration_test.exs b/test/docusign/custom_headers_integration_test.exs new file mode 100644 index 00000000..e9155e06 --- /dev/null +++ b/test/docusign/custom_headers_integration_test.exs @@ -0,0 +1,243 @@ +defmodule DocuSign.CustomHeadersIntegrationTest do + use ExUnit.Case, async: false + + alias DocuSign.Api.EnvelopeLocks + alias DocuSign.OAuth.Fake + + setup do + bypass = Bypass.open() + + # Store original config for cleanup + original_hostname = Application.get_env(:docusign, :hostname) + original_client_id = Application.get_env(:docusign, :client_id) + original_user_id = Application.get_env(:docusign, :user_id) + original_private_key_file = Application.get_env(:docusign, :private_key_file) + original_oauth_implementation = Application.get_env(:docusign, :oauth_implementation) + + # Configure the app to use our test server + Application.put_env(:docusign, :hostname, "localhost:#{bypass.port}") + Application.put_env(:docusign, :client_id, "test_client_id") + Application.put_env(:docusign, :user_id, "test_user_id") + Application.put_env(:docusign, :private_key_file, "test/support/test_key") + Application.put_env(:docusign, :oauth_implementation, Fake) + + # Start ClientRegistry if not already running with Fake implementation + case Process.whereis(DocuSign.ClientRegistry) do + nil -> + {:ok, _} = DocuSign.ClientRegistry.start_link(oauth_impl: Fake) + + pid -> + # Stop existing registry and start with Fake implementation + Process.exit(pid, :kill) + {:ok, _} = DocuSign.ClientRegistry.start_link(oauth_impl: Fake) + end + + # Add cleanup function + on_exit(fn -> + # Restore original config + if original_hostname do + Application.put_env(:docusign, :hostname, original_hostname) + else + Application.delete_env(:docusign, :hostname) + end + + if original_client_id do + Application.put_env(:docusign, :client_id, original_client_id) + else + Application.delete_env(:docusign, :client_id) + end + + if original_user_id do + Application.put_env(:docusign, :user_id, original_user_id) + else + Application.delete_env(:docusign, :user_id) + end + + if original_private_key_file do + Application.put_env(:docusign, :private_key_file, original_private_key_file) + else + Application.delete_env(:docusign, :private_key_file) + end + + if original_oauth_implementation do + Application.put_env(:docusign, :oauth_implementation, original_oauth_implementation) + else + Application.delete_env(:docusign, :oauth_implementation) + end + end) + + {:ok, bypass: bypass} + end + + describe "custom headers support" do + test "X-DocuSign-Edit header is sent in PUT requests for locked envelopes", %{ + bypass: bypass + } do + # Setup - Get connection + {:ok, conn} = DocuSign.Connection.get("test_user_id") + + account_id = "17035828" + envelope_id = "test-envelope-123" + lock_token = "abc123-lock-token" + lock_duration = "600" + + lock_edit_header = + Jason.encode!(%{ + "LockDurationInSeconds" => lock_duration, + "LockToken" => lock_token + }) + + # Mock the envelope lock update endpoint + Bypass.expect_once( + bypass, + "PUT", + "/restapi/v2.1/accounts/#{account_id}/envelopes/#{envelope_id}/lock", + fn conn -> + # CRITICAL: Verify the X-DocuSign-Edit header was sent! + headers = Map.new(conn.req_headers) + + assert headers["x-docusign-edit"] == lock_edit_header, + "Expected X-DocuSign-Edit header to be present with lock token" + + # Read and verify body + {:ok, body, conn} = Plug.Conn.read_body(conn) + request_body = Jason.decode!(body) + + # Verify request structure + assert request_body["lockDurationInSeconds"] + + # Return success response + response = %{ + "lockDurationInSeconds" => lock_duration, + "lockToken" => lock_token, + "lockedByUser" => %{ + "email" => "test@example.com", + "userName" => "Test User" + } + } + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, Jason.encode!(response)) + end + ) + + # Act - Call the API with custom headers + lock_request_body = %{ + "lockDurationInSeconds" => lock_duration + } + + result = + EnvelopeLocks.lock_put_envelope_lock( + conn, + account_id, + envelope_id, + body: lock_request_body, + headers: %{"X-DocuSign-Edit" => lock_edit_header} + ) + + # Assert - Verify success + assert {:ok, lock_info} = result + assert lock_info.lockToken == lock_token + assert lock_info.lockDurationInSeconds == lock_duration + end + + test "multiple custom headers are sent correctly", %{bypass: bypass} do + {:ok, conn} = DocuSign.Connection.get("test_user_id") + + account_id = "17035828" + envelope_id = "test-envelope-456" + + custom_header_1_value = "custom-value-1" + custom_header_2_value = "custom-value-2" + + # Mock endpoint that expects multiple custom headers + Bypass.expect_once( + bypass, + "PUT", + "/restapi/v2.1/accounts/#{account_id}/envelopes/#{envelope_id}/lock", + fn conn -> + headers = Map.new(conn.req_headers) + + # Verify both custom headers are present + assert headers["x-custom-header-1"] == custom_header_1_value + assert headers["x-custom-header-2"] == custom_header_2_value + + {:ok, body, conn} = Plug.Conn.read_body(conn) + Jason.decode!(body) + + response = %{ + "lockDurationInSeconds" => "300", + "lockToken" => "token123" + } + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, Jason.encode!(response)) + end + ) + + # Call with multiple custom headers + result = + EnvelopeLocks.lock_put_envelope_lock( + conn, + account_id, + envelope_id, + body: %{"lockDurationInSeconds" => "300"}, + headers: %{ + "X-Custom-Header-1" => custom_header_1_value, + "X-Custom-Header-2" => custom_header_2_value + } + ) + + assert {:ok, _lock_info} = result + end + + test "custom headers work with POST requests", %{bypass: bypass} do + {:ok, conn} = DocuSign.Connection.get("test_user_id") + + account_id = "17035828" + envelope_id = "test-envelope-789" + + custom_header_value = "post-custom-value" + + # Mock the envelope lock creation endpoint + Bypass.expect_once( + bypass, + "POST", + "/restapi/v2.1/accounts/#{account_id}/envelopes/#{envelope_id}/lock", + fn conn -> + headers = Map.new(conn.req_headers) + + # Verify custom header is present + assert headers["x-custom-header"] == custom_header_value + + {:ok, body, conn} = Plug.Conn.read_body(conn) + Jason.decode!(body) + + response = %{ + "lockDurationInSeconds" => "600", + "lockToken" => "new-lock-token" + } + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(201, Jason.encode!(response)) + end + ) + + # Call POST endpoint with custom headers + result = + EnvelopeLocks.lock_post_envelope_lock( + conn, + account_id, + envelope_id, + body: %{"lockDurationInSeconds" => "600"}, + headers: %{"X-Custom-Header" => custom_header_value} + ) + + assert {:ok, lock_info} = result + assert lock_info.lockToken == "new-lock-token" + end + end +end diff --git a/test/docusign/request_builder_test.exs b/test/docusign/request_builder_test.exs index c4d3dbbc..2a35e3a7 100644 --- a/test/docusign/request_builder_test.exs +++ b/test/docusign/request_builder_test.exs @@ -30,4 +30,92 @@ defmodule DocuSign.RequestBuilderTest do refute Map.has_key?(decoded, "approveTabs") end end + + describe "custom headers support" do + test "adds custom headers to request" do + request = %{} + optional_params = %{} + + opts = [ + headers: %{ + "X-DocuSign-Edit" => ~s({"LockToken": "abc123", "LockDurationInSeconds": "600"}) + } + ] + + result = RequestBuilder.add_optional_params(request, optional_params, opts) + + assert %{headers: headers} = result + assert {"X-DocuSign-Edit", ~s({"LockToken": "abc123", "LockDurationInSeconds": "600"})} in headers + end + + test "merges custom headers with existing headers" do + request = %{headers: [{"existing-header", "existing-value"}]} + optional_params = %{} + + opts = [ + headers: %{ + "X-DocuSign-Edit" => ~s({"LockToken": "token123"}) + } + ] + + result = RequestBuilder.add_optional_params(request, optional_params, opts) + + assert %{headers: headers} = result + assert {"X-DocuSign-Edit", ~s({"LockToken": "token123"})} in headers + assert {"existing-header", "existing-value"} in headers + end + + test "supports multiple custom headers" do + request = %{} + optional_params = %{} + + opts = [ + headers: %{ + "X-Another-Header" => "value3", + "X-Custom-Header" => "value2", + "X-DocuSign-Edit" => "value1" + } + ] + + result = RequestBuilder.add_optional_params(request, optional_params, opts) + + assert %{headers: headers} = result + assert {"X-DocuSign-Edit", "value1"} in headers + assert {"X-Custom-Header", "value2"} in headers + assert {"X-Another-Header", "value3"} in headers + end + + test "works alongside other optional params" do + request = %{} + optional_params = %{body: :body} + + body_data = %{test: "data"} + + opts = [ + body: body_data, + headers: %{"X-Custom-Header" => "custom-value"} + ] + + result = RequestBuilder.add_optional_params(request, optional_params, opts) + + assert %{body: body, headers: headers} = result + assert body == Jason.encode!(body_data) + assert {"X-Custom-Header", "custom-value"} in headers + end + + test "ignores headers option if not a map" do + request = %{} + optional_params = %{} + + opts = [ + headers: "invalid-not-a-map" + ] + + # Should not crash, just ignore the invalid headers + result = RequestBuilder.add_optional_params(request, optional_params, opts) + + # Should not have added any headers + refute Map.has_key?(result, :headers) + end + end end