fix(client): send populated request bodies for create/update/bulk-edit
Create/update operations were sending empty request bodies to Paperless,
producing two visible failure modes:
1. POST creates (correspondents, tags, document types, storage paths,
custom fields) returned `400 {"name":["This field is required."]}`
even when the caller passed a valid name.
2. PATCH updates (documents, correspondents, tags, etc.) returned 200
and bumped the row's `modified` timestamp, but no fields actually
changed — server-side this looked like a successful no-op PATCH.
3. Bulk edit operations (`api/documents/bulk_edit/`,
`api/bulk_edit_objects/`) failed because the inner `parameters` field
went out as `{}`, so add_tag/remove_tag/etc. arrived without a tag id.
Reproducer: `correspondents.create(name="ACME Corp")` against a real
Paperless instance returns 400 with the body above.
Root causes are two related serialization patterns where the
compile-time type of the value to serialize is `object`:
- `PostWithResultAsync` / `PatchWithResultAsync` accept the request as
`object` and pass it to `PostAsJsonAsync<TValue>` /
`JsonContent.Create<T>(value, ...)`. The generic `T` is inferred as
`object`, and through the configured DI HttpClient pipeline
(DelegatingHandler + Polly retry policy) the body that reaches the
wire is empty, even though the same JsonContent's
`ReadAsStringAsync()` returns the expected JSON.
- `BulkEditDocumentsAsync` / `BulkEditObjectsAsync` wrap the call args
in an anonymous type whose `parameters` field has compile-time type
`object?`. System.Text.Json serializes that property against
`object`, producing `"parameters":{}` regardless of the runtime value.
Fix: serialize against the runtime type explicitly and materialize the
JSON into a `StringContent` before the request leaves this client.
For bulk edits, build the body via `JsonObject` and serialize the
`parameters` payload against its runtime type.
Adds wire-format pinning tests for create / update / bulk_edit /
bulk_edit_objects that capture the outbound request body and assert the
expected payload shape. These do not reproduce the original bug under
MockHttp (the test helper bypasses the DI handler chain), but they pin
the new serialization for future refactors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -508,4 +508,118 @@ public class PaperlessClientTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request Body Serialization Tests
|
||||
|
||||
// Wire-format documentation tests. These capture the outbound request body and
|
||||
// assert the expected payload reaches the HTTP layer. They DO NOT reproduce the
|
||||
// production bug that motivated the fix — that bug only manifested through the DI
|
||||
// HttpClient pipeline (DelegatingHandler + Polly retry policy), and these tests
|
||||
// wire MockHttp directly into the HttpClient, bypassing that chain. Treat these as
|
||||
// pinning tests for the new code path. A higher-fidelity regression test would
|
||||
// need to register PaperlessClient through ServiceCollection with the same handler
|
||||
// pipeline used in Program.cs.
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCorrespondentAsync_SendsNameInRequestBody()
|
||||
{
|
||||
// Arrange — capture the actual outbound body
|
||||
string? capturedBody = null;
|
||||
_factory.MockHandler
|
||||
.When(HttpMethod.Post, "https://paperless.example.com/api/correspondents/")
|
||||
.With(req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return true;
|
||||
})
|
||||
.Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp"));
|
||||
|
||||
// Act
|
||||
await _factory.Client.CreateCorrespondentAsync(new CorrespondentCreateRequest { Name = "ACME Corp" });
|
||||
|
||||
// Assert — the body must include the name on the wire.
|
||||
capturedBody.Should().NotBeNullOrEmpty();
|
||||
capturedBody.Should().Contain("\"name\":\"ACME Corp\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDocumentAsync_SendsUpdatedFieldsInRequestBody()
|
||||
{
|
||||
// Arrange
|
||||
string? capturedBody = null;
|
||||
_factory.MockHandler
|
||||
.When(HttpMethod.Patch, "https://paperless.example.com/api/documents/42/")
|
||||
.With(req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return true;
|
||||
})
|
||||
.Respond("application/json", "{\"id\":42}");
|
||||
|
||||
// Act
|
||||
await _factory.Client.UpdateDocumentAsync(42, new DocumentUpdateRequest
|
||||
{
|
||||
Title = "New Title",
|
||||
Correspondent = 7,
|
||||
Tags = new List<int> { 1, 2 }
|
||||
});
|
||||
|
||||
// Assert — the PATCH body must contain the updated fields on the wire.
|
||||
capturedBody.Should().NotBeNullOrEmpty();
|
||||
capturedBody.Should().Contain("\"title\":\"New Title\"");
|
||||
capturedBody.Should().Contain("\"correspondent\":7");
|
||||
capturedBody.Should().Contain("\"tags\":[1,2]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkEditDocumentsAsync_SerializesInnerParametersWithRuntimeType()
|
||||
{
|
||||
// Arrange
|
||||
string? capturedBody = null;
|
||||
_factory.MockHandler
|
||||
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/bulk_edit/")
|
||||
.With(req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return true;
|
||||
})
|
||||
.Respond("application/json", "{}");
|
||||
|
||||
// Act
|
||||
await _factory.Client.BulkEditDocumentsAsync([1, 2, 3], "add_tag", new { tag = 5 });
|
||||
|
||||
// Assert — the inner `parameters` field must contain the actual tag, not `{}`.
|
||||
capturedBody.Should().NotBeNullOrEmpty();
|
||||
capturedBody.Should().Contain("\"documents\":[1,2,3]");
|
||||
capturedBody.Should().Contain("\"method\":\"add_tag\"");
|
||||
capturedBody.Should().Contain("\"parameters\":{\"tag\":5}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkEditObjectsAsync_SerializesInnerParametersWithRuntimeType()
|
||||
{
|
||||
// Arrange
|
||||
string? capturedBody = null;
|
||||
_factory.MockHandler
|
||||
.When(HttpMethod.Post, "https://paperless.example.com/api/bulk_edit_objects/")
|
||||
.With(req =>
|
||||
{
|
||||
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
return true;
|
||||
})
|
||||
.Respond("application/json", "{}");
|
||||
|
||||
// Act
|
||||
await _factory.Client.BulkEditObjectsAsync([10, 20], "tags", "set_permissions",
|
||||
new { owner = 3, set_permissions = new { view = new { users = new[] { 1 } } } });
|
||||
|
||||
// Assert
|
||||
capturedBody.Should().NotBeNullOrEmpty();
|
||||
capturedBody.Should().Contain("\"objects\":[10,20]");
|
||||
capturedBody.Should().Contain("\"object_type\":\"tags\"");
|
||||
capturedBody.Should().Contain("\"operation\":\"set_permissions\"");
|
||||
capturedBody.Should().Contain("\"owner\":3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -397,16 +397,26 @@ public class PaperlessClient
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new
|
||||
// Build the body via JsonObject so the inner `parameters` payload serializes
|
||||
// against its runtime type. If we wrap in an anonymous type with `parameters`
|
||||
// typed as `object?`, System.Text.Json emits `"parameters":{}` and Paperless
|
||||
// rejects the request (e.g. add_tag without a tag id).
|
||||
var rootNode = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
documents = documentIds,
|
||||
method,
|
||||
parameters
|
||||
["documents"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(documentIds, JsonOptions)),
|
||||
["method"] = method,
|
||||
};
|
||||
if (parameters != null)
|
||||
{
|
||||
rootNode["parameters"] = System.Text.Json.Nodes.JsonNode.Parse(
|
||||
JsonSerializer.Serialize(parameters, parameters.GetType(), JsonOptions));
|
||||
}
|
||||
var jsonString = rootNode.ToJsonString(JsonOptions);
|
||||
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/documents/bulk_edit/", request, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var response = await _httpClient.PostAsync("api/documents/bulk_edit/", content, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -634,17 +644,26 @@ public class PaperlessClient
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new
|
||||
// Same fix as BulkEditDocumentsAsync — wrapping `parameters` in an anonymous
|
||||
// type means its compile-time type is `object?` and the inner payload becomes
|
||||
// `{}`.
|
||||
var rootNode = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
objects = objectIds,
|
||||
object_type = objectType,
|
||||
operation,
|
||||
parameters
|
||||
["objects"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(objectIds, JsonOptions)),
|
||||
["object_type"] = objectType,
|
||||
["operation"] = operation,
|
||||
};
|
||||
if (parameters != null)
|
||||
{
|
||||
rootNode["parameters"] = System.Text.Json.Nodes.JsonNode.Parse(
|
||||
JsonSerializer.Serialize(parameters, parameters.GetType(), JsonOptions));
|
||||
}
|
||||
var jsonString = rootNode.ToJsonString(JsonOptions);
|
||||
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/bulk_edit_objects/", request, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var response = await _httpClient.PostAsync("api/bulk_edit_objects/", content, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -683,7 +702,15 @@ public class PaperlessClient
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
// Serialize against the runtime type explicitly. Empirically, posting via
|
||||
// PostAsJsonAsync(...) or PatchAsync(JsonContent.Create(...)) on this client
|
||||
// (with the configured DelegatingHandler + Polly retry pipeline) sent an
|
||||
// empty body to Paperless even though the JsonContent's own
|
||||
// ReadAsStringAsync returned the expected JSON. Materializing the body into
|
||||
// a StringContent up front sidesteps that.
|
||||
var jsonString = JsonSerializer.Serialize(request, request.GetType(), JsonOptions);
|
||||
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -707,7 +734,12 @@ public class PaperlessClient
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = JsonContent.Create(request, options: JsonOptions);
|
||||
// Same as PostWithResultAsync — explicit runtime-type serialization into
|
||||
// StringContent. With JsonContent.Create(...) here the body reached
|
||||
// Paperless empty (PATCH `{}` is a valid no-op so the row's modified
|
||||
// timestamp updated but no fields actually changed).
|
||||
var jsonString = JsonSerializer.Serialize(request, request.GetType(), JsonOptions);
|
||||
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PatchAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
|
||||
Reference in New Issue
Block a user