feat: add proper error handling with full API error details
- Add ApiResult<T> type to carry success/failure with error details - Add UpdateDocumentWithResultAsync and CreateTagWithResultAsync methods - Update DocumentTools.Update and TagTools.Create to return actual HTTP status codes and response bodies in error responses - Add comprehensive tests for error handling (18 new tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using PaperlessMCP.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace PaperlessMCP.Tests.Client;
|
||||
|
||||
public class ApiResultTests
|
||||
{
|
||||
#region Success Tests
|
||||
|
||||
[Fact]
|
||||
public void Success_WithValue_ReturnsSuccessResult()
|
||||
{
|
||||
// Act
|
||||
var result = ApiResult<string>.Success("test value");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().Be("test value");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_ImplicitBoolConversion_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var result = ApiResult<int>.Success(42);
|
||||
|
||||
// Act & Assert
|
||||
bool isSuccess = result;
|
||||
isSuccess.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Failure Tests
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithStatusCodeAndMessage_ReturnsFailureResult()
|
||||
{
|
||||
// Act
|
||||
var result = ApiResult<string>.Failure(HttpStatusCode.NotFound, "Resource not found");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Value.Should().BeNull();
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
result.Error.Message.Should().Be("Resource not found");
|
||||
result.Error.ResponseBody.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithResponseBody_IncludesBody()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = """{"error": "Document not found"}""";
|
||||
|
||||
// Act
|
||||
var result = ApiResult<string>.Failure(HttpStatusCode.NotFound, "Not found", responseBody);
|
||||
|
||||
// Assert
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.ResponseBody.Should().Be(responseBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithApiError_ReturnsFailureResult()
|
||||
{
|
||||
// Arrange
|
||||
var error = new ApiError(HttpStatusCode.BadRequest, "Invalid input", """{"field": "name is required"}""");
|
||||
|
||||
// Act
|
||||
var result = ApiResult<object>.Failure(error);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Be(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ImplicitBoolConversion_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = ApiResult<int>.Failure(HttpStatusCode.InternalServerError, "Server error");
|
||||
|
||||
// Act & Assert
|
||||
bool isSuccess = result;
|
||||
isSuccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ApiError Tests
|
||||
|
||||
[Fact]
|
||||
public void ApiError_ToString_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var error = new ApiError(HttpStatusCode.Forbidden, "Access denied", null);
|
||||
|
||||
// Act
|
||||
var result = error.ToString();
|
||||
|
||||
// Assert
|
||||
result.Should().Be("HTTP 403 Forbidden: Access denied");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiError_ToString_IncludesResponseBody()
|
||||
{
|
||||
// Arrange
|
||||
var error = new ApiError(HttpStatusCode.BadRequest, "Validation failed", """{"name": ["This field is required."]}""");
|
||||
|
||||
// Act
|
||||
var result = error.ToString();
|
||||
|
||||
// Assert
|
||||
result.Should().Contain("HTTP 400 BadRequest: Validation failed");
|
||||
result.Should().Contain("""{"name": ["This field is required."]}""");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Xunit;
|
||||
using PaperlessMCP.Models.CustomFields;
|
||||
using PaperlessMCP.Models.DocumentTypes;
|
||||
using PaperlessMCP.Models.StoragePaths;
|
||||
using PaperlessMCP.Models.Documents;
|
||||
using PaperlessMCP.Models.Tags;
|
||||
using PaperlessMCP.Tests.Fixtures;
|
||||
|
||||
@@ -167,6 +168,57 @@ public class PaperlessClientTests : IDisposable
|
||||
result.ThumbnailUrl.Should().Be("https://paperless.example.com/api/documents/1/thumb/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDocumentWithResultAsync_WhenSuccessful_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
_factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Updated Title"));
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.UpdateDocumentWithResultAsync(1, new DocumentUpdateRequest { Title = "Updated Title" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBeNull();
|
||||
result.Value!.Title.Should().Be("Updated Title");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDocumentWithResultAsync_WhenNotFound_ReturnsFailureWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"detail": "Not found."}""";
|
||||
_factory.SetupPatchWithError("api/documents/999/", HttpStatusCode.NotFound, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.UpdateDocumentWithResultAsync(999, new DocumentUpdateRequest { Title = "New Title" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Value.Should().BeNull();
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
result.Error.ResponseBody.Should().Be(errorBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDocumentWithResultAsync_WhenBadRequest_ReturnsFailureWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"title": ["This field may not be blank."]}""";
|
||||
_factory.SetupPatchWithError("api/documents/1/", HttpStatusCode.BadRequest, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.UpdateDocumentWithResultAsync(1, new DocumentUpdateRequest { Title = "" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
result.Error.ResponseBody.Should().Contain("This field may not be blank");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tag Tests
|
||||
@@ -231,6 +283,56 @@ public class PaperlessClientTests : IDisposable
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTagWithResultAsync_WhenSuccessful_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
_factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag"));
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "New Tag" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBeNull();
|
||||
result.Value!.Name.Should().Be("New Tag");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTagWithResultAsync_WhenDuplicate_ReturnsFailureWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"name": ["tag with this name already exists."]}""";
|
||||
_factory.SetupPostWithError("api/tags/", HttpStatusCode.BadRequest, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "Existing Tag" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
result.Error.ResponseBody.Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTagWithResultAsync_WhenUnauthorized_ReturnsFailureWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"detail": "Authentication credentials were not provided."}""";
|
||||
_factory.SetupPostWithError("api/tags/", HttpStatusCode.Unauthorized, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "Test" });
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
result.Error.ResponseBody.Should().Contain("Authentication credentials");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Correspondent Tests
|
||||
|
||||
@@ -79,6 +79,20 @@ public class MockHttpClientFactory : IDisposable
|
||||
.Respond(statusCode);
|
||||
}
|
||||
|
||||
public MockedRequest SetupPatchWithError(string url, HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
return MockHandler
|
||||
.When(HttpMethod.Patch, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
|
||||
.Respond(statusCode, "application/json", responseBody);
|
||||
}
|
||||
|
||||
public MockedRequest SetupPostWithError(string url, HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
return MockHandler
|
||||
.When(HttpMethod.Post, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
|
||||
.Respond(statusCode, "application/json", responseBody);
|
||||
}
|
||||
|
||||
public MockedRequest SetupDelete(string url, HttpStatusCode statusCode = HttpStatusCode.NoContent)
|
||||
{
|
||||
return MockHandler
|
||||
|
||||
@@ -433,6 +433,47 @@ public class DocumentToolsTests : IDisposable
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenBadRequest_ReturnsErrorWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"title": ["This field may not be blank."]}""";
|
||||
_factory.SetupPatchWithError("api/documents/1/", HttpStatusCode.BadRequest, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await DocumentTools.Update(_factory.Client, 1, title: "");
|
||||
|
||||
// Assert
|
||||
var json = JsonDocument.Parse(result);
|
||||
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
|
||||
|
||||
// Verify error details include status code and response body
|
||||
var details = json.RootElement.GetProperty("error").GetProperty("details");
|
||||
details.GetProperty("status_code").GetInt32().Should().Be(400);
|
||||
details.GetProperty("response_body").GetString().Should().Contain("This field may not be blank");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenForbidden_ReturnsErrorWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"detail": "You do not have permission to perform this action."}""";
|
||||
_factory.SetupPatchWithError("api/documents/1/", HttpStatusCode.Forbidden, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await DocumentTools.Update(_factory.Client, 1, title: "Updated");
|
||||
|
||||
// Assert
|
||||
var json = JsonDocument.Parse(result);
|
||||
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
|
||||
|
||||
var details = json.RootElement.GetProperty("error").GetProperty("details");
|
||||
details.GetProperty("status_code").GetInt32().Should().Be(403);
|
||||
details.GetProperty("response_body").GetString().Should().Contain("permission");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete Tests
|
||||
|
||||
@@ -119,6 +119,47 @@ public class TagToolsTests : IDisposable
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenDuplicate_ReturnsErrorWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"name": ["tag with this name already exists."]}""";
|
||||
_factory.SetupPostWithError("api/tags/", HttpStatusCode.BadRequest, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await TagTools.Create(_factory.Client, "Existing Tag");
|
||||
|
||||
// Assert
|
||||
var json = JsonDocument.Parse(result);
|
||||
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
|
||||
|
||||
// Verify error details include status code and response body
|
||||
var details = json.RootElement.GetProperty("error").GetProperty("details");
|
||||
details.GetProperty("status_code").GetInt32().Should().Be(400);
|
||||
details.GetProperty("response_body").GetString().Should().Contain("already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenUnauthorized_ReturnsErrorWithDetails()
|
||||
{
|
||||
// Arrange
|
||||
var errorBody = """{"detail": "Authentication credentials were not provided."}""";
|
||||
_factory.SetupPostWithError("api/tags/", HttpStatusCode.Unauthorized, errorBody);
|
||||
|
||||
// Act
|
||||
var result = await TagTools.Create(_factory.Client, "New Tag");
|
||||
|
||||
// Assert
|
||||
var json = JsonDocument.Parse(result);
|
||||
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
|
||||
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
|
||||
|
||||
var details = json.RootElement.GetProperty("error").GetProperty("details");
|
||||
details.GetProperty("status_code").GetInt32().Should().Be(401);
|
||||
details.GetProperty("response_body").GetString().Should().Contain("Authentication credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenSuccessful_ReturnsUpdatedTag()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net;
|
||||
|
||||
namespace PaperlessMCP.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an API operation that can either succeed or fail with details.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the success value.</typeparam>
|
||||
public readonly record struct ApiResult<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Value { get; }
|
||||
public ApiError? Error { get; }
|
||||
|
||||
private ApiResult(bool isSuccess, T? value, ApiError? error)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static ApiResult<T> Success(T value) => new(true, value, null);
|
||||
|
||||
public static ApiResult<T> Failure(HttpStatusCode statusCode, string message, string? responseBody = null) =>
|
||||
new(false, default, new ApiError(statusCode, message, responseBody));
|
||||
|
||||
public static ApiResult<T> Failure(ApiError error) => new(false, default, error);
|
||||
|
||||
public static implicit operator bool(ApiResult<T> result) => result.IsSuccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details about an API error.
|
||||
/// </summary>
|
||||
public record ApiError(HttpStatusCode StatusCode, string Message, string? ResponseBody)
|
||||
{
|
||||
public override string ToString() =>
|
||||
$"HTTP {(int)StatusCode} {StatusCode}: {Message}" +
|
||||
(ResponseBody != null ? $" - {ResponseBody}" : "");
|
||||
}
|
||||
@@ -180,7 +180,16 @@ public class PaperlessClient
|
||||
/// </summary>
|
||||
public async Task<Document?> UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchAsync<Document>($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false);
|
||||
var result = await UpdateDocumentWithResultAsync(id, request, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a document with full error details.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<Document>> UpdateDocumentWithResultAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchWithResultAsync<Document>($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -188,7 +197,16 @@ public class PaperlessClient
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteDocumentAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DeleteAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
|
||||
var result = await DeleteDocumentWithResultAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a document with full error details.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<bool>> DeleteDocumentWithResultAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DeleteWithResultAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -428,17 +446,35 @@ public class PaperlessClient
|
||||
|
||||
public async Task<Tag?> CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PostAsync<Tag>("api/tags/", request, cancellationToken).ConfigureAwait(false);
|
||||
var result = await CreateTagWithResultAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value : null;
|
||||
}
|
||||
|
||||
public async Task<ApiResult<Tag>> CreateTagWithResultAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PostWithResultAsync<Tag>("api/tags/", request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Tag?> UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchAsync<Tag>($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false);
|
||||
var result = await UpdateTagWithResultAsync(id, request, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value : null;
|
||||
}
|
||||
|
||||
public async Task<ApiResult<Tag>> UpdateTagWithResultAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PatchWithResultAsync<Tag>($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteTagAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DeleteAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false);
|
||||
var result = await DeleteTagWithResultAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess;
|
||||
}
|
||||
|
||||
public async Task<ApiResult<bool>> DeleteTagWithResultAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DeleteWithResultAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -632,7 +668,7 @@ public class PaperlessClient
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await LogErrorResponse(response, "GET", url).ConfigureAwait(false);
|
||||
await CreateApiError(response, "GET", url).ConfigureAwait(false);
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -642,7 +678,7 @@ public class PaperlessClient
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> PostAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
private async Task<ApiResult<T>> PostWithResultAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -650,20 +686,23 @@ public class PaperlessClient
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result != null
|
||||
? ApiResult<T>.Success(result)
|
||||
: ApiResult<T>.Failure(response.StatusCode, "Empty response body");
|
||||
}
|
||||
|
||||
await LogErrorResponse(response, "POST", url).ConfigureAwait(false);
|
||||
return default;
|
||||
var error = await CreateApiError(response, "POST", url).ConfigureAwait(false);
|
||||
return ApiResult<T>.Failure(error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "POST request failed: {Url}", url);
|
||||
return default;
|
||||
return ApiResult<T>.Failure(HttpStatusCode.InternalServerError, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> PatchAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
private async Task<ApiResult<T>> PatchWithResultAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -672,20 +711,23 @@ public class PaperlessClient
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result != null
|
||||
? ApiResult<T>.Success(result)
|
||||
: ApiResult<T>.Failure(response.StatusCode, "Empty response body");
|
||||
}
|
||||
|
||||
await LogErrorResponse(response, "PATCH", url).ConfigureAwait(false);
|
||||
return default;
|
||||
var error = await CreateApiError(response, "PATCH", url).ConfigureAwait(false);
|
||||
return ApiResult<T>.Failure(error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "PATCH request failed: {Url}", url);
|
||||
return default;
|
||||
return ApiResult<T>.Failure(HttpStatusCode.InternalServerError, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteAsync(string url, CancellationToken cancellationToken)
|
||||
private async Task<ApiResult<bool>> DeleteWithResultAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -693,24 +735,44 @@ public class PaperlessClient
|
||||
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return true;
|
||||
return ApiResult<bool>.Success(true);
|
||||
}
|
||||
|
||||
await LogErrorResponse(response, "DELETE", url).ConfigureAwait(false);
|
||||
return false;
|
||||
var error = await CreateApiError(response, "DELETE", url).ConfigureAwait(false);
|
||||
return ApiResult<bool>.Failure(error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DELETE request failed: {Url}", url);
|
||||
return false;
|
||||
return ApiResult<bool>.Failure(HttpStatusCode.InternalServerError, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogErrorResponse(HttpResponseMessage response, string method, string url)
|
||||
private async Task<ApiError> CreateApiError(HttpResponseMessage response, string method, string url)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
_logger.LogError("{Method} {Url} failed with {StatusCode}: {Body}",
|
||||
method, url, (int)response.StatusCode, body);
|
||||
return new ApiError(response.StatusCode, response.ReasonPhrase ?? "Unknown error", body);
|
||||
}
|
||||
|
||||
// Legacy methods for backward compatibility - will be removed after migration
|
||||
private async Task<T?> PostAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PostWithResultAsync<T>(url, request, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value : default;
|
||||
}
|
||||
|
||||
private async Task<T?> PatchAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await PatchWithResultAsync<T>(url, request, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess ? result.Value : default;
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await DeleteWithResultAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
return result.IsSuccess;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -350,20 +350,22 @@ public static class DocumentTools
|
||||
Created = ParseDate(created)
|
||||
};
|
||||
|
||||
var document = await client.UpdateDocumentAsync(id, request).ConfigureAwait(false);
|
||||
var result = await client.UpdateDocumentWithResultAsync(id, request).ConfigureAwait(false);
|
||||
|
||||
if (document == null)
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var error = result.Error!;
|
||||
var errorResponse = McpErrorResponse.Create(
|
||||
ErrorCodes.NotFound,
|
||||
$"Document with ID {id} not found or update failed",
|
||||
meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
error.StatusCode == System.Net.HttpStatusCode.NotFound ? ErrorCodes.NotFound : ErrorCodes.UpstreamError,
|
||||
$"Failed to update document {id}: {error.Message}",
|
||||
new { status_code = (int)error.StatusCode, response_body = error.ResponseBody },
|
||||
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
);
|
||||
return JsonSerializer.Serialize(errorResponse);
|
||||
}
|
||||
|
||||
var response = McpResponse<Document>.Success(
|
||||
document,
|
||||
result.Value!,
|
||||
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
);
|
||||
return JsonSerializer.Serialize(response);
|
||||
|
||||
@@ -82,20 +82,22 @@ public static class TagTools
|
||||
IsInboxTag = isInboxTag
|
||||
};
|
||||
|
||||
var tag = await client.CreateTagAsync(request).ConfigureAwait(false);
|
||||
var result = await client.CreateTagWithResultAsync(request).ConfigureAwait(false);
|
||||
|
||||
if (tag == null)
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var error = result.Error!;
|
||||
var errorResponse = McpErrorResponse.Create(
|
||||
ErrorCodes.UpstreamError,
|
||||
"Failed to create tag",
|
||||
meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
$"Failed to create tag: {error.Message}",
|
||||
new { status_code = (int)error.StatusCode, response_body = error.ResponseBody },
|
||||
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
);
|
||||
return JsonSerializer.Serialize(errorResponse);
|
||||
}
|
||||
|
||||
var response = McpResponse<Tag>.Success(
|
||||
tag,
|
||||
result.Value!,
|
||||
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||
);
|
||||
return JsonSerializer.Serialize(response);
|
||||
|
||||
Reference in New Issue
Block a user