Search code examples
.net-coreasp.net-core-webapiblazorblazor-client-sideblazor-webassembly

Unexpected error using JsonPatchDocument with Blazor


I am using a Blazor WebAssembly (WASM) client to perform an update via an .NET Core REST API. To do this I am sending a JsonPatchDocument<T> via an HTTP PATCH request, where T is one of my application's data transfer objects (DTOs).

It is not working. I get back a 500 internal server error status code in my Blazor application. I get a little bit more detail in Postman, but not enough for me to understand the problem.

Here is the calling code in my Blazor WASM application:

@code
{
[Parameter]
public int BookId { get; set; } = 101;

private async Task HandleClickAsync()
{
    string newTitle = "How to make JsonPatchDocument work with Blazor - Second Edition";

    var patchDocument = new JsonPatchDocument<Book>()
        .Replace(c => c.Title, newTitle);

    var json = JsonSerializer.Serialize(patchDocument);
    var content = new StringContent(json, Encoding.UTF8, "application/json-patch+json");
    var response = await HttpClient.PatchAsync($"https://localhost:44367/api/books/{BookId}", content);


    if (response.IsSuccessStatusCode)
    {
        // Handle success
    }
    else if (response.StatusCode == HttpStatusCode.NotFound)
    {
        // Handle not found
    }
    else
    {
        // Handle unexpected failures
    }
}
}

And here is my controller method:

[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
    [HttpPatch("{id:int}")]
    public async Task<ActionResult> PatchAsync(
        int id,
        [FromBody] JsonPatchDocument<Book> patch)
    {
        // We're just going to fake an asynchronous database call and return a 200 status code to the client
        await Task.FromResult(true);
        return Ok();
    }
}

Here is my DTO:

public class Book
{
    public int Id { get; set; }

    public string Title { get; set; }
}

The patch document I'm sending, when serialized to JSON, looks like this:

{"Operations":[{"value":"How to make JsonPatchDocument work with Blazor - Second Edition","OperationType":2,"path":"/Title","op":"replace","from":null}],"ContractResolver":{}}

The error detail that I'm seeing in Postman is:

System.NotSupportedException: Deserialization of interface types is not supported. Type 'Newtonsoft.Json.Serialization.IContractResolver'
   at System.Text.Json.ThrowHelper.ThrowNotSupportedException_DeserializeCreateObjectDelegateIsNull(Type invalidType)
   at System.Text.Json.JsonSerializer.HandleStartObject(JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore(JsonSerializerOptions options, Utf8JsonReader& reader, ReadStack& readStack)
   at System.Text.Json.JsonSerializer.ReadCore(JsonReaderState& readerState, Boolean isFinalBlock, ReadOnlySpan`1 buffer, JsonSerializerOptions options, ReadStack& readStack)
   at System.Text.Json.JsonSerializer.ReadAsync[TValue](Stream utf8Json, Type returnType, JsonSerializerOptions options, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter.ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
   at Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter.ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
   at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BodyModelBinder.BindModelAsync(ModelBindingContext bindingContext)
   at Microsoft.AspNetCore.Mvc.ModelBinding.ParameterBinder.BindModelAsync(ActionContext actionContext, IModelBinder modelBinder, IValueProvider valueProvider, ParameterDescriptor parameter, ModelMetadata metadata, Object value)
   at Microsoft.AspNetCore.Mvc.Controllers.ControllerBinderDelegateProvider.<>c__DisplayClass0_0.<<CreateBinderDelegate>g__Bind|0>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|24_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeFilterPipelineAsync()
--- End of stack trace from previous location where exception was thrown ---
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
   at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
   at Microsoft.AspNetCore.Builder.Extensions.MapWhenMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Builder.Extensions.MapMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

HEADERS
=======
Accept: */*
Accept-Encoding: gzip, deflate, br
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 175
Content-Type: application/json
Host: localhost:44367
User-Agent: PostmanRuntime/7.26.3
Postman-Token: b4444f41-b80f-4ef5-92d5-2416d68d471e

None of my projects depend directly Newtonsoft. I don't know if the Microsoft libraries that I reference in turn depend on Newtonsoft though. The error suggests maybe they do.

The behaviour can be observed in this little repository on GitHub: https://github.com/BenjaminCharlton/JsonPatchDocumentWithBlazor

Does anybody know why it won't work and/or what will fix it, please?

Thank you


Solution

  • I managed to resolve this difficulty, and the input from both Pavel and Enet was useful, thank you.

    For anybody else with the same issue, here's what you need to know to fix it:

    1. As of now (late 2020) the efforts to move .NET Core from Newtonsoft.Json to System.Text.Json are incomplete. The package Microsoft.AspNetCore.JsonPatch still depends on Newtonsoft.Json.

    2. The .NET Core development team are aware as GitHub has lots of issues reporting this. But they have all been closed with no action. Apparently there's too much effort involved in switching Microsoft.AspNetCore.JsonPatch over to System.Text.Json.

    3. To use Newtonsoft for JsonPatches but not for anything else there is a nice little hack described here that you should use in the Startup class of your Web API/server project. Pay particular attention to the use of the GetJsonPatchInputFormatter helper method called inside Startup.ConfigureServices

    4. But this on its own will not probably solve the 50X and 40X HTTP errors that your Blazor WASM/client project will receive because, if you serialize your JsonPatch using System.Text.Json it adds an empty ContractResolver object to the end of the JSON string (it looks like ,"ContractResolver":{} ), which breaks on the server side. For some reason, the request won't match your any controller routes you made.

    5. To get around this, you must also use Newtonsoft.Json on the Blazor client. You don't have to use it for everything; you just have to use it for serializing all your JsonPatches. It's a few more lines of code with Newtonsoft.Json than it is with System.Text.Json but I made an extension method so it's not repeated all over the place. The extension method looks like this:

       public static class HttpClientExtensions
       {
           public static async Task<HttpResponseMessage> PatchAsync<T>(this HttpClient client,
           string requestUri,
           JsonPatchDocument<T> patchDocument)
           where T : class
       {
           var writer = new StringWriter();
           var serializer = new JsonSerializer();
           serializer.Serialize(writer, patchDocument);
           var json = writer.ToString();
      
           var content = new StringContent(json, Encoding.UTF8, "application/json-patch+json");
           return await client.PatchAsync(requestUri, content);
       }
      

      }

    And that is it. This workaround worked for me and I hope it does for you too.