diff --git a/aspnetcore/web-api/jsonpatch.md b/aspnetcore/web-api/jsonpatch.md index d9644898c533..34698dc7c0da 100644 --- a/aspnetcore/web-api/jsonpatch.md +++ b/aspnetcore/web-api/jsonpatch.md @@ -1,11 +1,12 @@ --- title: JsonPatch in ASP.NET Core web API author: wadepickett -description: Learn how to handle JSON Patch requests in an ASP.NET Core web API. +description: "JSON Patch in ASP.NET Core web API: Learn how to handle JSON Patch requests, apply partial updates, and improve API efficiency with System.Text.Json." monikerRange: '>= aspnetcore-3.1' ms.author: wpickett +ms.reviewer: wpickett ms.custom: mvc -ms.date: 06/03/2025 +ms.date: 05/27/2026 uid: web-api/jsonpatch --- # JSON Patch support in ASP.NET Core web API @@ -37,14 +38,14 @@ For an overview of the JSON Patch standard, see [jsonpatch.com](https://jsonpatc ## JSON Patch support in ASP.NET Core web API -JSON Patch support in ASP.NET Core web API is based on serialization, starting with .NET 10, implementing based on serialization. This feature: +JSON Patch support in ASP.NET Core web API is based on serialization, starting with .NET 10. It implements based on serialization. This feature: * Requires the [`Microsoft.AspNetCore.JsonPatch.SystemTextJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.JsonPatch.SystemTextJson) NuGet package. * Aligns with modern .NET practices by leveraging the library, which is optimized for .NET. * Provides improved performance and reduced memory usage compared to the legacy `Newtonsoft.Json`-based implementation. For more information on the legacy `Newtonsoft.Json`-based implementation, see the [.NET 9 version of this article](?view=aspnetcore-9.0&preserve-view=true). > [!NOTE] -> The implementation of based on serialization isn't a drop-in replacement for the legacy `Newtonsoft.Json`-based implementation. It doesn't support dynamic types, for example . +> The implementation of based on serialization isn't a drop-in replacement for the legacy `Newtonsoft.Json`-based implementation. It doesn't support dynamic types, such as . > [!IMPORTANT] > The JSON Patch standard has ***inherent security risks***. Since these risks are inherent to the JSON Patch standard, the ASP.NET Core implementation ***doesn't attempt to mitigate inherent security risks***. It's the responsibility of the developer to ensure that the JSON Patch document is safe to apply to the target object. For more information, see the [Mitigating Security Risks](#mitigating-security-risks) section. @@ -57,19 +58,19 @@ To enable JSON Patch support with , install the [`Microso dotnet add package Microsoft.AspNetCore.JsonPatch.SystemTextJson ``` -This package provides a class to represent a JSON Patch document for objects of type `T` and custom logic for serializing and deserializing JSON Patch documents using . The key method of the class is , which applies the patch operations to a target object of type `T`. +This package provides a class to represent a JSON Patch document for objects of type `TModel` and custom logic for serializing and deserializing JSON Patch documents using . The key method of the class is , which applies the patch operations to a target object of type `TModel`. -## Action method code applying JSON Patch +## Minimal API PATCH endpoint applying JSON Patch -In an API controller, an action method for JSON Patch: +In a Minimal API, a PATCH endpoint for JSON Patch: -* Is annotated with the attribute. -* Accepts a , typically with [](xref:Microsoft.AspNetCore.Mvc.FromBodyAttribute). +* Uses `MapPatch` to define the route. +* Accepts a parameter. * Calls on the patch document to apply the changes. -### Example Controller Action method: +### Example Minimal API PATCH endpoint -:::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs" id="snippet_PatchAction" highlight="1,2,14-19"::: +:::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs" id="snippet_PatchMethod"::: This code from the sample app works with the following `Customer` and `Order` models: @@ -77,29 +78,33 @@ This code from the sample app works with the following `Customer` and `Order` mo :::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Order.cs"::: -The sample action method's key steps: +The sample PATCH endpoint's key steps: * **Retrieve the Customer**: - * The method retrieves a `Customer` object from the database `AppDb` using the provided id. - * If no `Customer` object is found, it returns a `404 Not Found` response. + * The endpoint retrieves a `Customer` object from the database `AppDb` using the provided `id`. + * If no `Customer` object is found, it returns a `404 Not Found` response via `TypedResults.NotFound()`. * **Apply JSON Patch**: - * The method applies the JSON Patch operations from the patchDoc to the retrieved `Customer` object. - * If errors occur during the patch application, such as invalid operations or conflicts, they are captured by an error handling delegate. This delegate adds error messages to the `ModelState` using the type name of the affected object and the error message. -* **Validate ModelState**: - * After applying the patch, the method checks the `ModelState` for errors. - * If the `ModelState` is invalid, such as due to patch errors, it returns a `400 Bad Request` response with the validation errors. -* **Return the Updated Customer**: - * If the patch is successfully applied and the `ModelState` is valid, the method returns the updated `Customer` object in the response. + * The method applies the JSON Patch operations from the `patchDoc` to the retrieved `Customer` object. + * If errors occur during the patch application, such as invalid operations or conflicts, an error handling delegate captures them. This delegate collects error messages into a dictionary keyed by the type name of the affected object. +* **Return validation errors**: + * If the error handling delegate captures any errors during the patch application, the endpoint returns a `ValidationProblem` response containing the error details via `TypedResults.ValidationProblem(errors)`. +* **Save and return the Updated Customer**: + * If the patch is successfully applied with no errors, the changes are saved to the database and the endpoint returns the updated `Customer` object via `TypedResults.Ok(customer)`. -### Example error response: +### Example error response -The following example shows the body of a `400 Bad Request` response for a JSON Patch operation when the specified path is invalid: +The following example shows the body of a validation problem response for a JSON Patch operation when the specified path is invalid: ```json { - "Customer": [ - "The target location specified by path segment 'foobar' was not found." - ] + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Customer": [ + "The target location specified by path segment 'foobar' was not found." + ] + } } ``` @@ -189,7 +194,7 @@ Key differences between and the new patch) } ``` -### Business Logic Subversion +### Business logic subversion * **Scenario**: Patch operations can manipulate fields with implicit invariants (for example, internal flags, IDs, or computed fields), violating business constraints. * **Impact**: Data integrity issues and unintended app behavior. @@ -304,26 +309,21 @@ public void Validate(JsonPatchDocument patch) * **Scenario**: Unauthenticated or unauthorized clients send malicious JSON Patch requests. * **Impact**: Unauthorized access to modify sensitive data or disrupt app behavior. * **Mitigation**: - * Protect endpoints accepting JSON Patch requests with proper authentication and authorization mechanisms. + * Protect endpoints that accept JSON Patch requests by using proper authentication and authorization mechanisms. * Restrict access to trusted clients or users with appropriate permissions. ## Get the code -[View or download sample code](https://github.com/dotnet/AspNetCore.Docs/tree/main/aspnetcore/web-api/jsonpatch/samples). ([How to download](xref:fundamentals/index#how-to-download-a-sample)). - -To test the sample, run the app and send HTTP requests with the following settings: +[View or download sample code](https://github.com/dotnet/AspNetCore.Docs/tree/main/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample). ([How to download](xref:fundamentals/index#how-to-download-a-sample)). -* URL: `http://localhost:{port}/jsonpatch/jsonpatchwithmodelstate` -* HTTP method: `PATCH` -* Header: `Content-Type: application/json-patch+json` -* Body: Copy and paste one of the JSON patch document samples from the *JSON* project folder. +To test the sample, run the app and send HTTP requests by using the included `.http` file. ## Additional resources * [IETF RFC 5789 PATCH method specification](https://tools.ietf.org/html/rfc5789) * [IETF RFC 6902 JSON Patch specification](https://tools.ietf.org/html/rfc6902) * [IETF RFC 6901 JSON Pointer](https://tools.ietf.org/html/rfc6901) -* [ASP.NET Core JSON Patch source code](https://github.com/dotnet/AspNetCore/tree/main/src/Features/JsonPatch/src) +* [ASP.NET Core JSON Patch source code](https://github.com/dotnet/aspnetcore/tree/main/src/Features/JsonPatch.SystemTextJson/src) :::moniker-end diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs index f1b1255f9182..14b6900285d4 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; using App.Data; using App.Models; @@ -11,27 +13,48 @@ namespace App.Controllers; public class CustomerController : ControllerBase { [HttpGet("{id}", Name = "GetCustomer")] - public Customer Get(AppDb db, string id) + public async Task Get(AppDb db, string id) { // Retrieve the customer by ID - var customer = db.Customers.FirstOrDefault(c => c.Id == id); + var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == id); // Return 404 Not Found if customer doesn't exist if (customer == null) { - Response.StatusCode = 404; - return null; + return NotFound(); + } + + return Ok(customer); + } + + [HttpPut("{id}", Name = "PutCustomer")] + public async Task Put(AppDb db, string id, [FromBody] Customer body) + { + var customer = await db.Customers.Include(c => c.Orders).FirstOrDefaultAsync(c => c.Id == id); + if (customer is null) + { + body.Id = id; + db.Customers.Add(body); + await db.SaveChangesAsync(); + return CreatedAtRoute("GetCustomer", new { id }, body); } - return customer; + customer.Name = body.Name; + customer.Email = body.Email; + customer.PhoneNumber = body.PhoneNumber; + customer.Address = body.Address; + + await db.SaveChangesAsync(); + + return Ok(customer); } // [HttpPatch("{id}", Name = "UpdateCustomer")] - public IActionResult Update(AppDb db, string id, [FromBody] JsonPatchDocument patchDoc) + public async Task Update(AppDb db, string id, [FromBody] JsonPatchDocument patchDoc) { // Retrieve the customer by ID - var customer = db.Customers.FirstOrDefault(c => c.Id == id); + var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == id); // Return 404 Not Found if customer doesn't exist if (customer == null) @@ -48,9 +71,13 @@ public IActionResult Update(AppDb db, string id, [FromBody] JsonPatchDocument diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs index 693d29955f9c..2aff6c951db7 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs @@ -1,7 +1,8 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; - +using System.ComponentModel.DataAnnotations; using App.Data; using App.Models; @@ -10,20 +11,42 @@ public static void MapCustomerApi(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/customers").WithTags("Customers"); - group.MapGet("/{id}", async Task, NotFound>> (AppDb db, string id) => + group.MapGet("/{id}", async Task, NotFound>> (AppDb db, string id) => { return await db.Customers.Include(c => c.Orders).FirstOrDefaultAsync(c => c.Id == id) is Customer customer ? TypedResults.Ok(customer) - : TypedResults.NotFound(); + : TypedResults.NotFound(new ()); }); - group.MapPatch("/{id}", async Task,NotFound,BadRequest, ValidationProblem>> (AppDb db, string id, + group.MapPut("/{id}", async Task,Created,ValidationProblem>> (AppDb db, string id, Customer body) => + { + var customer = await db.Customers.Include(c => c.Orders).FirstOrDefaultAsync(c => c.Id == id); + if (customer is null) + { + body.Id = id; + db.Customers.Add(body); + await db.SaveChangesAsync(); + return TypedResults.Created($"/customers/{id}", body); + } + + customer.Name = body.Name; + customer.Email = body.Email; + customer.PhoneNumber = body.PhoneNumber; + customer.Address = body.Address; + + await db.SaveChangesAsync(); + + return TypedResults.Ok(customer); + }); + + // + group.MapPatch("/{id}", async Task,ValidationProblem,NotFound>> (AppDb db, string id, JsonPatchDocument patchDoc) => { var customer = await db.Customers.Include(c => c.Orders).FirstOrDefaultAsync(c => c.Id == id); if (customer is null) { - return TypedResults.NotFound(); + return TypedResults.NotFound(new ()); } if (patchDoc != null) { @@ -38,15 +61,19 @@ public static void MapCustomerApi(this IEndpointRouteBuilder routes) } errors[key] = errors[key].Append(jsonPatchError.ErrorMessage).ToArray(); }); + if (errors != null) { return TypedResults.ValidationProblem(errors); } + + // Only save if there are no errors await db.SaveChangesAsync(); } return TypedResults.Ok(customer); }) .Accepts>("application/json-patch+json"); + // } } \ No newline at end of file diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http index eb981fd996be..3daf5ab5c4e6 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http @@ -3,14 +3,16 @@ GET {{HostAddress}}/openapi/v1.json Accept: application/json +### +### Minimal API requests ### -GET {{HostAddress}}/api/customers/1 +GET {{HostAddress}}/customers/1 Accept: application/json ### -PATCH {{HostAddress}}/api/customers/1 +PATCH {{HostAddress}}/customers/1 Content-Type: application/json-patch+json Accept: application/json @@ -18,7 +20,7 @@ Accept: application/json { "op": "replace", "path": "/email", - "value": "foo@bar.baz" + "value": "jdoe456@outlook.com" } ] @@ -26,7 +28,7 @@ Accept: application/json # Error response -PATCH {{HostAddress}}/api/customers/1 +PATCH {{HostAddress}}/customers/1 Content-Type: application/json-patch+json Accept: application/json @@ -39,15 +41,15 @@ Accept: application/json ] ### -### Minimal API requests +### Controllers requests ### -GET {{HostAddress}}/customers/1 +GET {{HostAddress}}/api/customers/1 Accept: application/json ### -PATCH {{HostAddress}}/customers/1 +PATCH {{HostAddress}}/api/customers/1 Content-Type: application/json-patch+json Accept: application/json @@ -55,7 +57,7 @@ Accept: application/json { "op": "replace", "path": "/email", - "value": "foo@bar.baz" + "value": "baz@foo.qux" } ] @@ -63,7 +65,7 @@ Accept: application/json # Error response -PATCH {{HostAddress}}/customers/1 +PATCH {{HostAddress}}/api/customers/1 Content-Type: application/json-patch+json Accept: application/json diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs index 1af9e4fda0d3..67e8eaecc793 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace App.Models; public class Customer @@ -8,7 +10,6 @@ public class Customer public string? PhoneNumber { get; set; } public string? Address { get; set; } public List? Orders { get; set; } - public Customer() { Id = Guid.NewGuid().ToString(); diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Program.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Program.cs index 1abd02b425a7..ffca5c69b3ae 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Program.cs +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Program.cs @@ -12,6 +12,8 @@ builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("AppDb"))); +builder.Services.AddProblemDetails(); + builder.Services.AddOpenApi(); var app = builder.Build(); @@ -26,7 +28,7 @@ app.UseHttpsRedirection(); -app.UseAuthorization(); +app.UseStatusCodePages(); app.MapControllers(); diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json index 0c208ae9181e..d7b2fc5dca0f 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" } } }