diff --git a/aspnetcore/web-api/jsonpatch.md b/aspnetcore/web-api/jsonpatch.md index f2a883caefea..865a6c883e1a 100644 --- a/aspnetcore/web-api/jsonpatch.md +++ b/aspnetcore/web-api/jsonpatch.md @@ -1,239 +1,311 @@ --- title: JsonPatch in ASP.NET Core web API -author: rick-anderson +author: wadepickett description: Learn how to handle JSON Patch requests in an ASP.NET Core web API. monikerRange: '>= aspnetcore-3.1' -ms.author: riande +ms.author: wpickett ms.custom: mvc -ms.date: 03/09/2022 +ms.date: 06/03/2025 uid: web-api/jsonpatch --- -# JsonPatch in ASP.NET Core web API +# JSON Patch support in ASP.NET Core web API :::moniker range=">= aspnetcore-10.0" This article explains how to handle JSON Patch requests in an ASP.NET Core web API. -## Package installation +JSON Patch support in ASP.NET Core web API is based on serialization, and requires the [`Microsoft.AspNetCore.JsonPatch.SystemTextJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.JsonPatch.SystemTextJson) NuGet package. -JSON Patch support in ASP.NET Core web API is based on `Newtonsoft.Json` and requires the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. To enable JSON Patch support: +## What is the JSON Patch standard? -* Install the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. -* Call . For example: +The JSON Patch standard: - :::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/Program.cs" id="snippet1" highlight="4"::: +* Is a standard format for describing changes to apply to a JSON document. +* Is defined in [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) and is widely used in RESTful APIs to perform partial updates to JSON resources. +* Describes a sequence of operations that modify a JSON document such as: + + * `add` + * `remove` + * `replace` + * `move` + * `copy` + * `test` -`AddNewtonsoftJson` replaces the default `System.Text.Json`-based input and output formatters used for formatting ***all*** JSON content. This extension method is compatible with the following MVC service registration methods: +In web apps, JSON Patch is commonly used in a PATCH operation to perform partial updates of a resource. Rather than sending the entire resource for an update, clients can send a JSON Patch document containing only the changes. Patching reduces payload size and improves efficiency. -* -* -* +For an overview of the JSON Patch standard, see [jsonpatch.com](https://jsonpatch.com/). -JsonPatch requires setting the `Content-Type` header to `application/json-patch+json`. +## JSON Patch support in ASP.NET Core web API -## Add support for JSON Patch when using System.Text.Json +JSON Patch support in ASP.NET Core web API is based on serialization, starting with .NET 10, implementing based on serialization. This feature: -The `System.Text.Json`-based input formatter doesn't support JSON Patch. To add support for JSON Patch using `Newtonsoft.Json`, while leaving the other input and output formatters unchanged: +* 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](xref:web-api/jsonpatch?view=aspnetcore-9.0&preserve-view=true). -* Install the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. -* Update `Program.cs`: +> [!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 . - :::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/Program.cs" id="snippet_both" highlight="6-9"::: - :::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/MyJPIF.cs"::: +> [!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. -The preceding code creates an instance of and inserts it as the first entry in the collection. This order of registration ensures that: +## Enable JSON Patch support with -* `NewtonsoftJsonPatchInputFormatter` processes JSON Patch requests. -* The existing `System.Text.Json`-based input and formatters process all other JSON requests and responses. +To enable JSON Patch support with , install the [`Microsoft.AspNetCore.JsonPatch.SystemTextJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.JsonPatch.SystemTextJson) NuGet package. -Use the `Newtonsoft.Json.JsonConvert.SerializeObject` method to serialize a . +```dotnetcli +dotnet add package Microsoft.AspNetCore.JsonPatch.SystemTextJson --prerelease +``` -## PATCH HTTP request method +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`. -The PUT and [PATCH](https://tools.ietf.org/html/rfc5789) methods are used to update an existing resource. The difference between them is that PUT replaces the entire resource, while PATCH specifies only the changes. +## Action method code applying JSON Patch -## JSON Patch +In an API controller, an action method for JSON Patch: -[JSON Patch](https://tools.ietf.org/html/rfc6902) is a format for specifying updates to be applied to a resource. A JSON Patch document has an array of *operations*. Each operation identifies a particular type of change. Examples of such changes include adding an array element or replacing a property value. +* Is annotated with the attribute. +* Accepts a , typically with [](xref:Microsoft.AspNetCore.Mvc.FromBodyAttribute). +* Calls on the patch document to apply the changes. -For example, the following JSON documents represent a resource, a JSON Patch document for the resource, and the result of applying the Patch operations. +### Example Controller Action method: -### Resource example +:::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs" id="snippet_PatchAction" highlight="1,2,14-19"::: -:::code language="json" source="~/web-api/jsonpatch/snippets/customer.json"::: +This code from the sample app works with the following `Customer` and `Order` models: -### JSON patch example +:::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs"::: -:::code language="json" source="~/web-api/jsonpatch/snippets/add.json"::: +:::code language="csharp" source="~/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Order.cs"::: -In the preceding JSON: +The sample action method's key steps: -* The `op` property indicates the type of operation. -* The `path` property indicates the element to update. -* The `value` property provides the new value. +* **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. +* **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. -### Resource after patch +### Example error response: -Here's the resource after applying the preceding JSON Patch document: +The following example shows the body of a `400 Bad Request` response for a JSON Patch operation when the specified path is invalid: ```json { - "customerName": "Barry", - "orders": [ - { - "orderName": "Order0", - "orderType": null - }, - { - "orderName": "Order1", - "orderType": null - }, - { - "orderName": "Order2", - "orderType": null - } + "Customer": [ + "The target location specified by path segment 'foobar' was not found." ] } ``` -The changes made by applying a JSON Patch document to a resource are atomic. If any operation in the list fails, no operation in the list is applied. - -## Path syntax - -The [path](https://tools.ietf.org/html/rfc6901) property of an operation object has slashes between levels. For example, `"/address/zipCode"`. - -Zero-based indexes are used to specify array elements. The first element of the `addresses` array would be at `/addresses/0`. To `add` to the end of an array, use a hyphen (`-`) rather than an index number: `/addresses/-`. - -### Operations - -The following table shows supported operations as defined in the [JSON Patch specification](https://tools.ietf.org/html/rfc6902): - -|Operation | Notes | -|-----------|--------------------------------| -| `add` | Add a property or array element. For existing property: set value.| -| `remove` | Remove a property or array element. | -| `replace` | Same as `remove` followed by `add` at same location. | -| `move` | Same as `remove` from source followed by `add` to destination using value from source. | -| `copy` | Same as `add` to destination using value from source. | -| `test` | Return success status code if value at `path` = provided `value`.| - -## JSON Patch in ASP.NET Core - -The ASP.NET Core implementation of JSON Patch is provided in the [Microsoft.AspNetCore.JsonPatch](https://www.nuget.org/packages/microsoft.aspnetcore.jsonpatch/) NuGet package. - -## Action method code - -In an API controller, an action method for JSON Patch: - -* Is annotated with the `HttpPatch` attribute. -* Accepts a , typically with [`[FromBody]`](xref:Microsoft.AspNetCore.Mvc.FromBodyAttribute). -* Calls on the patch document to apply the changes. - -Here's an example: - -:::code language="csharp" source="~/web-api/jsonpatch/samples/3.x/api/Controllers/HomeController.cs" id="snippet_PatchAction" highlight="1,3,9"::: - -This code from the sample app works with the following `Customer` model: +## Apply a JSON Patch document to an object -:::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/Models/Customer.cs"::: +The following examples demonstrate how to use the method to apply a JSON Patch document to an object. -:::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/Models/Order.cs"::: +### Example: Apply a to an object -The sample action method: +The following example demonstrates: -* Constructs a `Customer`. -* Applies the patch. -* Returns the result in the body of the response. +* The `add`, `replace`, and `remove` operations. +* Operations on nested properties. +* Adding a new item to an array. +* Using a JSON String Enum Converter in a JSON patch document. -In a real app, the code would retrieve the data from a store such as a database and update the database after applying the patch. - -### Model state +```csharp +// Original object +var person = new Person { + FirstName = "John", + LastName = "Doe", + Email = "johndoe@gmail.com", + PhoneNumbers = [new() {Number = "123-456-7890", Type = PhoneNumberType.Mobile}], + Address = new Address + { + Street = "123 Main St", + City = "Anytown", + State = "TX" + } +}; + +// Raw JSON patch document +string jsonPatch = """ +[ + { "op": "replace", "path": "/FirstName", "value": "Jane" }, + { "op": "remove", "path": "/Email"}, + { "op": "add", "path": "/Address/ZipCode", "value": "90210" }, + { "op": "add", "path": "/PhoneNumbers/-", "value": { "Number": "987-654-3210", + "Type": "Work" } } +] +"""; + +// Deserialize the JSON patch document +var patchDoc = JsonSerializer.Deserialize>(jsonPatch); + +// Apply the JSON patch document +patchDoc!.ApplyTo(person); + +// Output updated object +Console.WriteLine(JsonSerializer.Serialize(person, serializerOptions)); +``` -The preceding action method example calls an overload of `ApplyTo` that takes model state as one of its parameters. With this option, you can get error messages in responses. The following example shows the body of a 400 Bad Request response for a `test` operation: +The previous example results in the following output of the updated object: -```json +```output { - "Customer": [ - "The current value 'John' at path 'customerName' != test value 'Nancy'." - ] + "firstName": "Jane", + "lastName": "Doe", + "address": { + "street": "123 Main St", + "city": "Anytown", + "state": "TX", + "zipCode": "90210" + }, + "phoneNumbers": [ + { + "number": "123-456-7890", + "type": "Mobile" + }, + { + "number": "987-654-3210", + "type": "Work" + } + ] } ``` -### Dynamic objects - -The following action method example shows how to apply a patch to a dynamic object: +The method generally follows the conventions and options of for processing the , including the behavior controlled by the following options: -:::code language="csharp" source="~/web-api/jsonpatch/samples/6.x/api/Controllers/HomeController.cs" id="snippet_Dynamic"::: +* : Whether numeric properties are read from strings. +* : Whether property names are case-sensitive. -## The add operation +Key differences between and the new implementation: -* If `path` points to an array element: inserts new element before the one specified by `path`. -* If `path` points to a property: sets the property value. -* If `path` points to a nonexistent location: - * If the resource to patch is a dynamic object: adds a property. - * If the resource to patch is a static object: the request fails. +* The runtime type of the target object, not the declared type, determines which properties patches. +* deserialization relies on the declared type to identify eligible properties. -The following sample patch document sets the value of `CustomerName` and adds an `Order` object to the end of the `Orders` array. +### Example: Apply a JsonPatchDocument with error handling -:::code language="json" source="~/web-api/jsonpatch/snippets/add.json"::: +There are various errors that can occur when applying a JSON Patch document. For example, the target object may not have the specified property, or the value specified might be incompatible with the property type. -## The remove operation +JSON `Patch` supports the `test` operation, which checks if a specified value equals the target property. If it doesn't, it returns an error. -* If `path` points to an array element: removes the element. -* If `path` points to a property: - * If resource to patch is a dynamic object: removes the property. - * If resource to patch is a static object: - * If the property is nullable: sets it to null. - * If the property is non-nullable, sets it to `default`. +The following example demonstrates how to handle these errors gracefully. -The following sample patch document sets `CustomerName` to null and deletes `Orders[0]`: +> [!Important] +> The object passed to the method is modified in place. The caller is responsible for discarding changes if any operation fails. -:::code language="json" source="~/web-api/jsonpatch/snippets/remove.json"::: +```csharp +// Original object +var person = new Person { + FirstName = "John", + LastName = "Doe", + Email = "johndoe@gmail.com" +}; -## The replace operation +// Raw JSON patch document +string jsonPatch = """ +[ + { "op": "replace", "path": "/Email", "value": "janedoe@gmail.com"}, + { "op": "test", "path": "/FirstName", "value": "Jane" }, + { "op": "replace", "path": "/LastName", "value": "Smith" } +] +"""; -This operation is functionally the same as a `remove` followed by an `add`. +// Deserialize the JSON patch document +var patchDoc = JsonSerializer.Deserialize>(jsonPatch); -The following sample patch document sets the value of `CustomerName` and replaces `Orders[0]`with a new `Order` object: - -:::code language="json" source="~/web-api/jsonpatch/snippets/replace.json"::: +// Apply the JSON patch document, catching any errors +Dictionary? errors = null; +patchDoc!.ApplyTo(person, jsonPatchError => + { + errors ??= new (); + var key = jsonPatchError.AffectedObject.GetType().Name; + if (!errors.ContainsKey(key)) + { + errors.Add(key, new string[] { }); + } + errors[key] = errors[key].Append(jsonPatchError.ErrorMessage).ToArray(); + }); +if (errors != null) +{ + // Print the errors + foreach (var error in errors) + { + Console.WriteLine($"Error in {error.Key}: {string.Join(", ", error.Value)}"); + } +} -## The move operation +// Output updated object +Console.WriteLine(JsonSerializer.Serialize(person, serializerOptions)); +``` -* If `path` points to an array element: copies `from` element to location of `path` element, then runs a `remove` operation on the `from` element. -* If `path` points to a property: copies value of `from` property to `path` property, then runs a `remove` operation on the `from` property. -* If `path` points to a nonexistent property: - * If the resource to patch is a static object: the request fails. - * If the resource to patch is a dynamic object: copies `from` property to location indicated by `path`, then runs a `remove` operation on the `from` property. +The previous example results in the following output: -The following sample patch document: +```output +Error in Person: The current value 'John' at path 'FirstName' is not equal +to the test value 'Jane'. +{ + "firstName": "John", + "lastName": "Smith", <<< Modified! + "email": "janedoe@gmail.com", <<< Modified! + "phoneNumbers": [] +} +``` -* Copies the value of `Orders[0].OrderName` to `CustomerName`. -* Sets `Orders[0].OrderName` to null. -* Moves `Orders[1]` to before `Orders[0]`. +## Mitigating security risks -:::code language="json" source="~/web-api/jsonpatch/snippets/move.json"::: +When using the `Microsoft.AspNetCore.JsonPatch.SystemTextJson` package, it's critical to understand and mitigate potential security risks. The following sections outline the identified security risks associated with JSON Patch and provide recommended mitigations to ensure secure usage of the package. -## The copy operation +> [!IMPORTANT] +> ***This is not an exhaustive list of threats.*** App developers must conduct their own threat model reviews to determine an app-specific comprehensive list and come up with appropriate mitigations as needed. For example, apps which expose collections to patch operations should consider the potential for algorithmic complexity attacks if those operations insert or remove elements at the beginning of the collection. -This operation is functionally the same as a `move` operation without the final `remove` step. +To minimize security risks when integrating JSON Patch functionality into their apps, developers should: -The following sample patch document: +* Run comprehensive threat models for their own apps. +* Address identified threats. +* Follow the recommended mitigations in the following sections. -* Copies the value of `Orders[0].OrderName` to `CustomerName`. -* Inserts a copy of `Orders[1]` before `Orders[0]`. +### Denial of Service (DoS) via memory amplification -:::code language="json" source="~/web-api/jsonpatch/snippets/copy.json"::: +* **Scenario**: A malicious client submits a `copy` operation that duplicates large object graphs multiple times, leading to excessive memory consumption. +* **Impact**: Potential Out-Of-Memory (OOM) conditions, causing service disruptions. +* **Mitigation**: + * Validate incoming JSON Patch documents for size and structure before calling . + * The validation must be app specific, but an example validation can look similar to the following: -## The test operation +```csharp +public void Validate(JsonPatchDocument patch) +{ + // This is just an example. It's up to the developer to make sure that + // this case is handled properly, based on the app needs. + if (patch.Operations.Where(op=>op.OperationType == OperationType.Copy).Count() + > MaxCopyOperationsCount) + { + throw new InvalidOperationException(); + } +} +``` -If the value at the location indicated by `path` is different from the value provided in `value`, the request fails. In that case, the whole PATCH request fails even if all other operations in the patch document would otherwise succeed. +### Business Logic Subversion -The `test` operation is commonly used to prevent an update when there's a concurrency conflict. +* **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. +* **Mitigation**: + * Use POCOs (Plain Old CLR Objects) with explicitly defined properties that are safe to modify. + * Avoid exposing sensitive or security-critical properties in the target object. + * If a POCO object isn't used, validate the patched object after applying operations to ensure business rules and invariants aren't violated. -The following sample patch document has no effect if the initial value of `CustomerName` is "John", because the test fails: +### Authentication and authorization -:::code language="json" source="~/web-api/jsonpatch/snippets/test-fail.json"::: +* **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. + * Restrict access to trusted clients or users with appropriate permissions. ## Get the code diff --git a/aspnetcore/web-api/jsonpatch/includes/jsonpatch9.md b/aspnetcore/web-api/jsonpatch/includes/jsonpatch9.md index b002fc0fdee2..fc6b9ba8cd95 100644 --- a/aspnetcore/web-api/jsonpatch/includes/jsonpatch9.md +++ b/aspnetcore/web-api/jsonpatch/includes/jsonpatch9.md @@ -2,9 +2,14 @@ This article explains how to handle JSON Patch requests in an ASP.NET Core web API. +> [!IMPORTANT] +> The JSON Patch standard has ***inherent security risks***. This implementation ***doesn't attempt to mitigate these 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. + ## Package installation -JSON Patch support in ASP.NET Core web API is based on `Newtonsoft.Json` and requires the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. To enable JSON Patch support: +JSON Patch support in ASP.NET Core web API is based on `Newtonsoft.Json` and requires the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. + +To enable JSON Patch support: * Install the [`Microsoft.AspNetCore.Mvc.NewtonsoftJson`](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.NewtonsoftJson/) NuGet package. * Call . For example: @@ -234,6 +239,53 @@ To test the sample, run the app and send HTTP requests with the following settin * Header: `Content-Type: application/json-patch+json` * Body: Copy and paste one of the JSON patch document samples from the *JSON* project folder. +## Mitigating security risks + +When using the `Microsoft.AspNetCore.JsonPatch` package with the `Newtonsoft.Json`-based implementation, it's critical to understand and mitigate potential security risks. The following sections outline the identified security risks associated with JSON Patch and provide recommended mitigations to ensure secure usage of the package. + +> [!IMPORTANT] +> ***This is not an exhaustive list of threats.*** App developers must conduct their own threat model reviews to determine an app-specific comprehensive list and come up with appropriate mitigations as needed. For example, apps which expose collections to patch operations should consider the potential for algorithmic complexity attacks if those operations insert or remove elements at the beginning of the collection. + +By running comprehensive threat models for their own apps and addressing identified threats while following the recommended mitigations below, consumers of these packages can integrate JSON Patch functionality into their apps while minimizing security risks. + +### Denial of Service (DoS) via memory amplification + +* **Scenario**: A malicious client submits a `copy` operation that duplicates large object graphs multiple times, leading to excessive memory consumption. +* **Impact**: Potential Out-Of-Memory (OOM) conditions, causing service disruptions. +* **Mitigation**: + * Validate incoming JSON Patch documents for size and structure before calling `ApplyTo`. + * The validation needs to be app specific, but an example validation can look similar to the following: + +```csharp +public void Validate(JsonPatchDocument patch) +{ + // This is just an example. It's up to the developer to make sure that + // this case is handled properly, based on the app needs. + if (patch.Operations.Where(op => op.OperationType == OperationType.Copy).Count() + > MaxCopyOperationsCount) + { + throw new InvalidOperationException(); + } +} +``` + +### 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. +* **Mitigation**: + * Use POCO objects with explicitly defined properties that are safe to modify. + * Avoid exposing sensitive or security-critical properties in the target object. + * If no POCO object is used, validate the patched object after applying operations to ensure business rules and invariants aren't violated. + +### Authentication and authorization + +* **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. + * Restrict access to trusted clients or users with appropriate permissions. + ## Additional resources * [IETF RFC 5789 PATCH method specification](https://tools.ietf.org/html/rfc5789) @@ -247,6 +299,9 @@ To test the sample, run the app and send HTTP requests with the following settin This article explains how to handle JSON Patch requests in an ASP.NET Core web API. +> [!IMPORTANT] +> The JSON Patch standard has ***inherent security risks***. Since these risks are inherent to the JSON Patch standard, this 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. + ## Package installation To enable JSON Patch support in your app, complete the following steps: @@ -476,11 +531,5 @@ To test the sample, run the app and send HTTP requests with the following settin * Header: `Content-Type: application/json-patch+json` * Body: Copy and paste one of the JSON patch document samples from the *JSON* project folder. -## 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 Patch path format spec](https://tools.ietf.org/html/rfc6901) -* [ASP.NET Core JSON Patch source code](https://github.com/dotnet/AspNetCore/tree/main/src/Features/JsonPatch/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 new file mode 100644 index 000000000000..f1b1255f9182 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Controllers/CustomerController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +using App.Data; +using App.Models; + +namespace App.Controllers; + +[ApiController] +[Route("/api/customers")] +public class CustomerController : ControllerBase +{ + [HttpGet("{id}", Name = "GetCustomer")] + public Customer Get(AppDb db, string id) + { + // Retrieve the customer by ID + var customer = db.Customers.FirstOrDefault(c => c.Id == id); + + // Return 404 Not Found if customer doesn't exist + if (customer == null) + { + Response.StatusCode = 404; + return null; + } + + return customer; + } + + // + [HttpPatch("{id}", Name = "UpdateCustomer")] + public IActionResult Update(AppDb db, string id, [FromBody] JsonPatchDocument patchDoc) + { + // Retrieve the customer by ID + var customer = db.Customers.FirstOrDefault(c => c.Id == id); + + // Return 404 Not Found if customer doesn't exist + if (customer == null) + { + return NotFound(); + } + + patchDoc.ApplyTo(customer, jsonPatchError => + { + var key = jsonPatchError.AffectedObject.GetType().Name; + ModelState.AddModelError(key, jsonPatchError.ErrorMessage); + } + ); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + return new ObjectResult(customer); + } + // +} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs new file mode 100644 index 000000000000..693d29955f9c --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/CustomerApi.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.EntityFrameworkCore; + +using App.Data; +using App.Models; + +internal static class CustomerApi { + public static void MapCustomerApi(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/customers").WithTags("Customers"); + + 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(); + }); + + group.MapPatch("/{id}", async Task,NotFound,BadRequest, ValidationProblem>> (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(); + } + if (patchDoc != null) + { + Dictionary? errors = null; + patchDoc.ApplyTo(customer, jsonPatchError => + { + errors ??= new (); + var key = jsonPatchError.AffectedObject.GetType().Name; + if (!errors.ContainsKey(key)) + { + errors.Add(key, new string[] { }); + } + errors[key] = errors[key].Append(jsonPatchError.ErrorMessage).ToArray(); + }); + if (errors != null) + { + return TypedResults.ValidationProblem(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/Data/AppDb.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Data/AppDb.cs new file mode 100644 index 000000000000..45f39d082413 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Data/AppDb.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using App.Models; + +namespace App.Data; + +public class AppDb : DbContext +{ + public required DbSet Customers { get; set; } + + public AppDb(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure entity relationships here if needed + modelBuilder.Entity() + .HasKey(c => c.Id); + } +} \ No newline at end of file diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Data/AppDbSeeder.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Data/AppDbSeeder.cs new file mode 100644 index 000000000000..d68222a464a7 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Data/AppDbSeeder.cs @@ -0,0 +1,53 @@ +using App.Models; +using Microsoft.EntityFrameworkCore; + +namespace App.Data; + +public static class AppDbSeeder +{ + public static async Task Seed(WebApplication app) + { + // Create and seed the database + using (var scope = app.Services.CreateScope()) + { + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + context.Database.EnsureCreated(); + + if (context.Customers.Any()) + { + return; + } + + Customer[] customers = { + new Customer + { + Id = "1", + Name = "John Doe", + Email = "john.doe@example.com", + PhoneNumber = "555-123-4567", + Address = "123 Main St, Anytown, USA" + }, + new Customer + { + Id = "2", + Name = "Jane Smith", + Email = "jane.smith@example.com", + PhoneNumber = "555-987-6543", + Address = "456 Oak Ave, Somewhere, USA" + }, + new Customer + { + Id = "3", + Name = "Bob Johnson", + Email = "bob.johnson@example.com", + PhoneNumber = "555-555-5555", + Address = "789 Pine Rd, Elsewhere, USA" + } + }; + + await context.Customers.AddRangeAsync(customers); + await context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.csproj b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.csproj new file mode 100644 index 000000000000..ae1404b68190 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http new file mode 100644 index 000000000000..eb981fd996be --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/JsonPatchSample.http @@ -0,0 +1,76 @@ +@HostAddress = http://localhost:5221 + +GET {{HostAddress}}/openapi/v1.json +Accept: application/json + +### + +GET {{HostAddress}}/api/customers/1 +Accept: application/json + +### + +PATCH {{HostAddress}}/api/customers/1 +Content-Type: application/json-patch+json +Accept: application/json + +[ + { + "op": "replace", + "path": "/email", + "value": "foo@bar.baz" + } +] + +### + +# Error response + +PATCH {{HostAddress}}/api/customers/1 +Content-Type: application/json-patch+json +Accept: application/json + +[ + { + "op": "add", + "path": "/foobar", + "value": 42 + } +] + +### +### Minimal API requests +### + +GET {{HostAddress}}/customers/1 +Accept: application/json + +### + +PATCH {{HostAddress}}/customers/1 +Content-Type: application/json-patch+json +Accept: application/json + +[ + { + "op": "replace", + "path": "/email", + "value": "foo@bar.baz" + } +] + +### + +# Error response + +PATCH {{HostAddress}}/customers/1 +Content-Type: application/json-patch+json +Accept: application/json + +[ + { + "op": "add", + "path": "/foobar", + "value": 42 + } +] 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 new file mode 100644 index 000000000000..1af9e4fda0d3 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Customer.cs @@ -0,0 +1,16 @@ +namespace App.Models; + +public class Customer +{ + public string Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + 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/Models/Order.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Order.cs new file mode 100644 index 000000000000..876f7b7c24d3 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Models/Order.cs @@ -0,0 +1,14 @@ +namespace App.Models; + +public class Order +{ + public string Id { get; set; } + public DateTime? OrderDate { get; set; } + public DateTime? ShipDate { get; set; } + public decimal TotalAmount { get; set; } + + public Order() + { + 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 new file mode 100644 index 000000000000..1abd02b425a7 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +using App.Data; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); + +// Add the CatalogContext to the DI container +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("AppDb"))); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +await AppDbSeeder.Seed(app); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.MapCustomerApi(); + +app.Run(); diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/appsettings.json b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json similarity index 80% rename from aspnetcore/web-api/jsonpatch/samples/10.x/api/appsettings.json rename to aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json index 10f68b8c8b4f..0c208ae9181e 100644 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/appsettings.json +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.Development.json @@ -4,6 +4,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "AllowedHosts": "*" + } } diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.json b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.json new file mode 100644 index 000000000000..ee5013eca103 --- /dev/null +++ b/aspnetcore/web-api/jsonpatch/samples/10.x/JsonPatchSample/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "AppDb": "Data Source=app.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Controllers/HomeController.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Controllers/HomeController.cs deleted file mode 100644 index cf7eb4badad3..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Controllers/HomeController.cs +++ /dev/null @@ -1,104 +0,0 @@ -using JsonPatchSample.Models; -using Microsoft.AspNetCore.JsonPatch; -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; -using System.Dynamic; - -namespace JsonPatchSample.Controllers; - -[Route("jsonpatch/[action]")] -[ApiController] -public class HomeController : ControllerBase -{ - // - [HttpPatch] - public IActionResult JsonPatchWithModelState( - [FromBody] JsonPatchDocument patchDoc) - { - if (patchDoc != null) - { - var customer = CreateCustomer(); - - patchDoc.ApplyTo(customer, ModelState); - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - return new ObjectResult(customer); - } - else - { - return BadRequest(ModelState); - } - } - // - - [HttpPatch] - public IActionResult JsonPatchWithModelStateAndPrefix( - [FromBody] JsonPatchDocument patchDoc, - string prefix) - { - var customer = CreateCustomer(); - - patchDoc.ApplyTo(customer, ModelState, prefix); - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - return new ObjectResult(customer); - } - - [HttpPatch] - public IActionResult JsonPatchWithoutModelState([FromBody] JsonPatchDocument patchDoc) - { - var customer = CreateCustomer(); - - patchDoc.ApplyTo(customer); - - return new ObjectResult(customer); - } - - [HttpPatch] - public IActionResult JsonPatchForProduct([FromBody] JsonPatchDocument patchDoc) - { - var product = new Product(); - - patchDoc.ApplyTo(product); - - return new ObjectResult(product); - } - - // - [HttpPatch] - public IActionResult JsonPatchForDynamic([FromBody]JsonPatchDocument patch) - { - dynamic obj = new ExpandoObject(); - patch.ApplyTo(obj); - - return Ok(obj); - } - // - - private Customer CreateCustomer() - { - return new Customer - { - CustomerName = "John", - Orders = new List() - { - new Order - { - OrderName = "Order0" - }, - new Order - { - OrderName = "Order1" - } - } - }; - } -} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/JsonPatchSample.csproj b/aspnetcore/web-api/jsonpatch/samples/10.x/api/JsonPatchSample.csproj deleted file mode 100644 index 9fdb95013b67..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/JsonPatchSample.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net6.0 - enable - enable - - - - - - - - - - - diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Category.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Category.cs deleted file mode 100644 index 108f5ae39e1e..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Category.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonPatchSample.Models -{ - public class Category - { - public string CategoryName { get; set; } - } -} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Customer.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Customer.cs deleted file mode 100644 index d64fd761dc7c..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Customer.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonPatchSample.Models; - -public class Customer -{ - public string? CustomerName { get; set; } - public List? Orders { get; set; } -} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Order.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Order.cs deleted file mode 100644 index d3ce1355e151..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Order.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonPatchSample.Models; - -public class Order -{ - public string OrderName { get; set; } - public string OrderType { get; set; } -} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Product.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Product.cs deleted file mode 100644 index 5ea1f5085e4c..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Models/Product.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonPatchSample.Models -{ - public class Product - { - public string ProductName { get; set; } - public Category ProductCategory { get; set; } - } -} diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/MyJPIF.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/MyJPIF.cs deleted file mode 100644 index 6a20608945c0..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/MyJPIF.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Options; - -namespace JsonPatchSample; - -public static class MyJPIF -{ - public static NewtonsoftJsonPatchInputFormatter GetJsonPatchInputFormatter() - { - var builder = new ServiceCollection() - .AddLogging() - .AddMvc() - .AddNewtonsoftJson() - .Services.BuildServiceProvider(); - - return builder - .GetRequiredService>() - .Value - .InputFormatters - .OfType() - .First(); - } -} \ No newline at end of file diff --git a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Program.cs b/aspnetcore/web-api/jsonpatch/samples/10.x/api/Program.cs deleted file mode 100644 index 6b755abe0c02..000000000000 --- a/aspnetcore/web-api/jsonpatch/samples/10.x/api/Program.cs +++ /dev/null @@ -1,42 +0,0 @@ -#define BOTH // FIRST BOTH -#if NEVER -#elif FIRST -// -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddControllers() - .AddNewtonsoftJson(); - -var app = builder.Build(); - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); -// -#elif BOTH -// -using JsonPatchSample; -using Microsoft.AspNetCore.Mvc.Formatters; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddControllers(options => -{ - options.InputFormatters.Insert(0, MyJPIF.GetJsonPatchInputFormatter()); -}); - -var app = builder.Build(); - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); -// -#endif