Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions lib/docusign/request_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
243 changes: 243 additions & 0 deletions test/docusign/custom_headers_integration_test.exs
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions test/docusign/request_builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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