diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..57078b4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,18 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "nuget" # See documentation for possible values
+ directory: "/ChronoJsonDiffPatch" # Location of package manifests
+ schedule:
+ interval: "weekly"
+ reviewers:
+ - "@Hochfrequenz/c-developers-review-team"
+ # Maintain dependencies for GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..2855e02
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,71 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [main]
+ schedule:
+ - cron: "20 5 * * 6"
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ["csharp"]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+ # Learn more:
+ # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 https://git.io/JvXDl
+
+ # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml
new file mode 100644
index 0000000..68d80b7
--- /dev/null
+++ b/.github/workflows/dependabot_automerge.yml
@@ -0,0 +1,18 @@
+name: Dependabot auto-approve / -merge
+on: pull_request
+
+jobs:
+ dependabot:
+ permissions:
+ contents: write
+ pull-requests: write
+ runs-on: ubuntu-latest
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ if: ${{ github.actor == 'dependabot[bot]' }}
+ steps:
+ - name: Approve a PR
+ run: gh pr review --approve "$PR_URL"
+ - name: Enable auto-merge for Dependabot PRs
+ run: gh pr merge --auto --squash "$PR_URL"
diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml
new file mode 100644
index 0000000..10f15e8
--- /dev/null
+++ b/.github/workflows/formatting.yml
@@ -0,0 +1,19 @@
+name: csharpier
+
+on: [push, pull_request]
+
+jobs:
+ format:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.100
+ - name: Restore .NET tools
+ run: dotnet tool restore
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter
+ - name: Run CSharpier
+ run: dotnet csharpier . --check
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter
diff --git a/.github/workflows/nuget_package_push.yml b/.github/workflows/nuget_package_push.yml
new file mode 100644
index 0000000..14b75fb
--- /dev/null
+++ b/.github/workflows/nuget_package_push.yml
@@ -0,0 +1,40 @@
+name: Nuget Release
+
+on:
+ push:
+ tags:
+ - v*
+
+jobs:
+ pushrelease:
+ runs-on: windows-latest
+ env:
+ ACTIONS_ALLOW_UNSECURE_COMMANDS: "true"
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.100
+ - uses: olegtarasov/get-tag@v2.1
+ id: tagChrono
+ with:
+ tagRegex: "v(\\d+\\.\\d+\\.\\d+)"
+ - name: Build/Check for compile errors (dotnet build)
+ working-directory: "JsonPatchDocumentExtensionDataAdapter"
+ run: dotnet build --configuration Release
+ - name: Run Unit Tests (dotnet test)
+ working-directory: "JsonPatchDocumentExtensionDataAdapter"
+ run: dotnet test --configuration Release
+ - name: Create Package ChronoJsonDiffPatch (dotnet pack)
+ working-directory: "JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter"
+ run: dotnet pack ChronoJsonDiffPatch.csproj --configuration Release -p:PackageVersion="${{ steps.tagChrono.outputs.tag }}"
+ - name: Setup Nuget.exe
+ uses: warrenbuckley/Setup-Nuget@v1
+ - name: Nuget push ChronoJsonDiffPatch
+ working-directory: "JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter"
+ # token: https://github.com/Hochfrequenz/JsonPatchDocumentExtensionDataAdapter/settings/secrets/actions/NUGET_ORG_PUSH_TOKEN
+ # expires 2025-09-09
+ run: |
+ nuget setApiKey ${{ secrets.NUGET_ORG_PUSH_TOKEN }}
+ nuget push .\bin\Release\*.nupkg -Source https://api.nuget.org/v3/index.json -SkipDuplicate -NoSymbols
diff --git a/.github/workflows/unittests_and_coverage.yml b/.github/workflows/unittests_and_coverage.yml
new file mode 100644
index 0000000..fe5f40f
--- /dev/null
+++ b/.github/workflows/unittests_and_coverage.yml
@@ -0,0 +1,37 @@
+name: Unittests and Coverage
+
+on: [push, pull_request]
+
+jobs:
+ unittest:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ dotnet-version: [ "8.0.401" ]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ matrix.dotnet-version }}
+ - name: Run Tests
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter
+ run: dotnet test --configuration Release
+ coverage:
+ needs: unittest
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.100
+ - name: Install dependencies
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter
+ run: dotnet restore
+ - name: Install coverlet.msbuild in ChronoJsonDiffPatchTests
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter/UnitTest
+ run: dotnet add package coverlet.msbuild
+ - name: Measure Test Coverage
+ working-directory: ./JsonPatchDocumentExtensionDataAdapter
+ run: dotnet test /p:Threshold=90 /p:Include=\"[*]ChronoJsonDiffPatch.*\" /p:ThresholdType=line /p:CollectCoverage=true /p:SkipAutoProps=true /p:CoverletOutputFormat=lcov --configuration Release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b06670
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+
+JsonPatchDocumentExtensionDataAdapter/UnitTest/obj/
+JsonPatchDocumentExtensionDataAdapter/UnitTest/bin/
+JsonPatchDocumentExtensionDataAdapter/UnitTest/bin/
+
+JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/obj/
+JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/bin/
+
+JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.sln.DotSettings.user
diff --git a/JsonPatchDocumentExtensionDataAdapter/.config/dotnet-tools.json b/JsonPatchDocumentExtensionDataAdapter/.config/dotnet-tools.json
new file mode 100644
index 0000000..02da728
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.config/dotnet-tools.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "csharpier": {
+ "version": "0.29.1",
+ "commands": ["dotnet-csharpier"],
+ "rollForward": false
+ }
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/.gitignore b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/.gitignore
new file mode 100644
index 0000000..f5377e7
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/projectSettingsUpdater.xml
+/.idea.JsonExtensionDataPatchDocumentAdapter.iml
+/contentModel.xml
+/modules.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/indexLayout.xml b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/vcs.xml b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonExtensionDataPatchDocumentAdapter/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/.gitignore b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/.gitignore
new file mode 100644
index 0000000..8325f55
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/modules.xml
+/.idea.JsonPatchDocumentExtensionDataAdapter.iml
+/projectSettingsUpdater.xml
+/contentModel.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/indexLayout.xml b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/vcs.xml b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/.idea/.idea.JsonPatchDocumentExtensionDataAdapter/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/JsonPatchDocumentExtensionDataAdapter/JsonExtensionDataPatchDocumentAdapter.sln.DotSettings.user b/JsonPatchDocumentExtensionDataAdapter/JsonExtensionDataPatchDocumentAdapter.sln.DotSettings.user
new file mode 100644
index 0000000..cbb5f2e
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/JsonExtensionDataPatchDocumentAdapter.sln.DotSettings.user
@@ -0,0 +1,6 @@
+
+ <SessionState ContinuousTestingMode="0" Name="JsonPatchDocumentTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <TestAncestor>
+ <TestId>MSTest::CA9CA5DA-279F-4021-96CE-13784AAD2564::net8.0::UnitTest.JsonPatchDocumentTest</TestId>
+ </TestAncestor>
+</SessionState>
\ No newline at end of file
diff --git a/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.sln b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.sln
new file mode 100644
index 0000000..7451064
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonPatchDocumentExtensionDataAdapter", "JsonPatchDocumentExtensionDataAdapter\JsonPatchDocumentExtensionDataAdapter.csproj", "{093295C4-E041-4DD2-B97A-C2CC6958E214}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTest", "UnitTest\UnitTest.csproj", "{CA9CA5DA-279F-4021-96CE-13784AAD2564}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {093295C4-E041-4DD2-B97A-C2CC6958E214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {093295C4-E041-4DD2-B97A-C2CC6958E214}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {093295C4-E041-4DD2-B97A-C2CC6958E214}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {093295C4-E041-4DD2-B97A-C2CC6958E214}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA9CA5DA-279F-4021-96CE-13784AAD2564}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA9CA5DA-279F-4021-96CE-13784AAD2564}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA9CA5DA-279F-4021-96CE-13784AAD2564}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA9CA5DA-279F-4021-96CE-13784AAD2564}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/InconsistentPropertyNamesException.cs b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/InconsistentPropertyNamesException.cs
new file mode 100644
index 0000000..c792f25
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/InconsistentPropertyNamesException.cs
@@ -0,0 +1,43 @@
+using System;
+using Newtonsoft.Json;
+
+namespace JsonExtensionDataPatchDocumentAdapter;
+
+///
+/// an error that is raised if and don't match
+///
+/// It's important for the models to use consistent property names because ASP.Net Core internally relies on Newtonsoft
+public class InconsistentPropertyNamesException : Exception
+{
+ ///
+ /// name of the property
+ ///
+ public string PropertyName { get; }
+
+ ///
+ /// json name of the property using Newtonsoft
+ ///
+ public string? NewtonsoftJsonPropertyName { get; }
+
+ ///
+ /// json name of the property using System.Text.Json
+ ///
+ public string? SystemTextPropertyName { get; }
+
+ internal InconsistentPropertyNamesException(
+ string propertyName,
+ string? newtonsoftJsonPropertyName = null,
+ string? systemTextPropertyName = null
+ )
+ : base($"The property name and the json property name don't match for {propertyName}")
+ {
+ PropertyName = propertyName;
+ NewtonsoftJsonPropertyName = newtonsoftJsonPropertyName;
+ SystemTextPropertyName = systemTextPropertyName;
+ }
+
+ public new string ToString()
+ {
+ return $"The system.text.property name {SystemTextPropertyName ?? "(unset)"} and the newtonsoft json property{NewtonsoftJsonPropertyName ?? "(unset)"} name don't match for property {PropertyName}. Because the logic of this package relies on System.Text.Json but ASP.NET Core relies on Newtonsoft internally, both have to match";
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.cs b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.cs
new file mode 100644
index 0000000..47420c8
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.cs
@@ -0,0 +1,336 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.JsonPatch;
+using Microsoft.AspNetCore.JsonPatch.Adapters;
+using Microsoft.AspNetCore.JsonPatch.Operations;
+using Newtonsoft.Json.Serialization;
+
+namespace JsonExtensionDataPatchDocumentAdapter;
+
+///
+/// Assume has a property that is annotated with the .
+/// The client does not know of this property and its behaviour.
+/// If the client sends a in which it attempts to change properties which are internally stored in the extension data property on the server side,
+/// this class modifies the patch document such that s with unknown paths are converted to paths that point to the annotated extension data property.
+/// There are extensive unittests for this class.
+///
+///
+/// I'd love to just drop this piece of code entirely, because my gut feeling is, there must be a proper solution to the problem.
+/// I opened an issue at ASP.NET Core: https://github.com/dotnet/aspnetcore/issues/57711, maybe one day it's solved or someone suggests the 'right' way to do it.
+///
+public class JsonPatchDocumentExtensionDataAdapter
+ where TModel : class
+{
+ ///
+ /// path to the property of that is annotated with the
+ ///
+ protected readonly string ExtensionDataPropertyJsonPath;
+
+ ///
+ /// accessor for the property of that is annotated with the
+ ///
+ protected readonly Expression<
+ Func?>
+ > ExtensionDataPropertyExpression;
+
+ ///
+ /// initialize the class by providing an expression that points to the annotated property
+ ///
+ /// how to get from to the extension data
+ public JsonPatchDocumentExtensionDataAdapter(
+ Expression?>> extensionDataPropertyExpression
+ )
+ {
+ ExtensionDataPropertyExpression = extensionDataPropertyExpression;
+ ExtensionDataPropertyJsonPath = GetJsonPathOfAnnotatedProperty(
+ extensionDataPropertyExpression
+ );
+ }
+
+ ///
+ /// Get the JSON property path from the
+ ///
+ /// The expression pointing to the property.
+ /// The full JSON property path.
+ private string GetJsonPathOfAnnotatedProperty(
+ Expression?>> propertyExpression
+ )
+ {
+ // List to store the path segments
+ var pathSegments = new List();
+
+ // Traverse the expression tree and collect property names
+ var expression = propertyExpression.Body;
+
+ while (expression is MemberExpression memberExpression)
+ {
+ var propertyInfo = memberExpression.Member as PropertyInfo;
+ if (propertyInfo != null)
+ {
+ // Check if the property has a JsonPropertyName attribute
+ var systemTextJsonPropertyNameAttribute =
+ propertyInfo.GetCustomAttribute();
+ var newtonsoftJsonPropertyAttribute =
+ propertyInfo.GetCustomAttribute();
+ if (
+ newtonsoftJsonPropertyAttribute is not null
+ && systemTextJsonPropertyNameAttribute is null
+ )
+ {
+ throw new InconsistentPropertyNamesException(
+ propertyInfo.Name,
+ newtonsoftJsonPropertyAttribute.PropertyName,
+ null
+ );
+ }
+ if (
+ systemTextJsonPropertyNameAttribute is not null
+ && newtonsoftJsonPropertyAttribute is null
+ )
+ {
+ throw new InconsistentPropertyNamesException(
+ propertyInfo.Name,
+ null,
+ systemTextJsonPropertyNameAttribute.Name
+ );
+ }
+ if (
+ newtonsoftJsonPropertyAttribute is not null
+ && systemTextJsonPropertyNameAttribute is not null
+ )
+ {
+ if (
+ newtonsoftJsonPropertyAttribute.PropertyName
+ != systemTextJsonPropertyNameAttribute.Name
+ )
+ {
+ throw new InconsistentPropertyNamesException(
+ propertyInfo.Name,
+ newtonsoftJsonPropertyAttribute.PropertyName,
+ systemTextJsonPropertyNameAttribute.Name
+ );
+ }
+ }
+ var jsonPropertyName =
+ newtonsoftJsonPropertyAttribute?.PropertyName
+ ?? systemTextJsonPropertyNameAttribute?.Name
+ ?? propertyInfo.Name;
+
+ pathSegments.Insert(0, jsonPropertyName);
+ }
+
+ expression =
+ memberExpression.Expression
+ ?? throw new ArgumentNullException(
+ nameof(memberExpression),
+ "The expression inside the property expression must not be null"
+ );
+ }
+
+ if (pathSegments.Count == 0)
+ {
+ throw new ArgumentException("Invalid expression. Expected a property expression.");
+ }
+
+ return "/" + string.Join("/", pathSegments);
+ }
+
+ private static string GetLongestCommonPrefixIgnoreCase(string str1, string str2)
+ {
+ // Convert both strings to lowercase for case-insensitive comparison
+ var lowerStr1 = str1.ToLower();
+ var lowerStr2 = str2.ToLower();
+
+ var minLength = Math.Min(lowerStr1.Length, lowerStr2.Length);
+ var i = 0;
+
+ // Compare characters one by one
+ while (i < minLength && lowerStr1[i] == lowerStr2[i])
+ {
+ i++;
+ }
+
+ // Return the common prefix from the original case-sensitive string
+ return str1.Substring(0, i);
+ }
+
+ private string GetPathRelativeToExtensionData(string originalPath)
+ {
+ var longestSubPath = GetLongestCommonPrefixIgnoreCase(
+ ExtensionDataPropertyJsonPath,
+ originalPath
+ );
+ var result = originalPath[longestSubPath.Length..];
+ return result;
+ }
+
+ ///
+ /// sanitizes the
+ ///
+ /// instance which will be modified
+ private void SanitizeOperation(Operation operation)
+ {
+ if (
+ !operation.path.StartsWith(
+ ExtensionDataPropertyJsonPath,
+ StringComparison.InvariantCultureIgnoreCase
+ )
+ )
+ {
+ return;
+ }
+ if (operation.value is JsonValue jsonValue)
+ {
+ operation.value = jsonValue.ToString();
+ }
+ }
+
+ ///
+ /// Returns a new JsonPatchDocument in which the of the
+ /// have been adapted such that they point to the extension data
+ ///
+ /// Document from the client which isn't aware of the ExtensionData
+ /// The model to which the document should be applied. It won't be modified but is necessary to distinguish between ExtensionData that are already there and which would be newly added.
+ public JsonPatchDocument TransformDocument(
+ JsonPatchDocument document,
+ in TModel model
+ )
+ {
+ if (model is null)
+ {
+ throw new ArgumentNullException(nameof(model));
+ }
+ var testModel = System.Text.Json.JsonSerializer.Deserialize(
+ System.Text.Json.JsonSerializer.Serialize(model)
+ );
+ if (testModel is null)
+ {
+ throw new ArgumentException(
+ "The model must be serializable with System.Text.json",
+ nameof(model)
+ );
+ }
+ var result = new JsonPatchDocument();
+ var expressionFunc = ExtensionDataPropertyExpression.Compile();
+ var extensionData = expressionFunc(testModel) ?? new Dictionary();
+ var extensionDataAlreadyExists = extensionData.Any();
+ JsonPatchError? applyError = null;
+ foreach (var originalOperation in document.Operations)
+ {
+ applyError = null;
+ originalOperation.Apply(
+ testModel,
+ new ObjectAdapter(
+ new DefaultContractResolver(),
+ error =>
+ {
+ applyError = error;
+ }
+ )
+ );
+ if (applyError is null)
+ {
+ result.Operations.Add(originalOperation);
+ continue;
+ }
+
+ var originalError = applyError!;
+ var extensionDataKey = GetPathRelativeToExtensionData(originalOperation.path);
+ if (extensionDataAlreadyExists)
+ {
+ Operation newOperation;
+ if (!extensionData.TryAdd(extensionDataKey, originalOperation.value))
+ {
+ var replacementPath =
+ ExtensionDataPropertyJsonPath
+ + "/"
+ + GetPathRelativeToExtensionData(originalOperation.path);
+ newOperation = new Operation(
+ op: "replace",
+ path: replacementPath,
+ from: null,
+ value: originalOperation.value
+ );
+ }
+ else
+ {
+ var addPath =
+ ExtensionDataPropertyJsonPath
+ + "/"
+ + GetPathRelativeToExtensionData(originalOperation.path);
+ newOperation = new Operation(
+ op: originalOperation.op,
+ path: addPath,
+ from: originalOperation.from,
+ value: originalOperation.value
+ );
+ }
+
+ applyError = null;
+ newOperation.Apply(
+ testModel,
+ new ObjectAdapter(
+ new DefaultContractResolver(),
+ error =>
+ {
+ applyError = error;
+ }
+ )
+ );
+ if (applyError is not null)
+ {
+ throw new InvalidOperationException(
+ $"The operation {originalOperation} could neither be applied to the model ({originalError.ErrorMessage}) nor be adapted to match the JsonExtensionData '{ExtensionDataPropertyJsonPath}' ({applyError.ErrorMessage})"
+ );
+ }
+
+ result.Operations.Add(newOperation);
+ }
+ else
+ {
+ extensionData.Add(extensionDataKey, originalOperation.value);
+ }
+ }
+
+ var useOneOperationToAddEntireExtensiondata = !extensionDataAlreadyExists;
+ if (useOneOperationToAddEntireExtensiondata)
+ {
+ var newOperation = new Operation(
+ op: "add",
+ path: ExtensionDataPropertyJsonPath,
+ null,
+ extensionData
+ );
+ applyError = null;
+ newOperation.Apply(
+ testModel,
+ new ObjectAdapter(
+ new DefaultContractResolver(),
+ error =>
+ {
+ applyError = error;
+ }
+ )
+ );
+ if (applyError is not null)
+ {
+ throw new InvalidOperationException(
+ $"The JsonExtensionData '{ExtensionDataPropertyJsonPath}' could not be added: {applyError!.ErrorMessage}"
+ );
+ }
+
+ result.Operations.Add(newOperation);
+ }
+ foreach (var operation in result.Operations)
+ {
+ SanitizeOperation(operation);
+ }
+
+ return result;
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.csproj b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.csproj
new file mode 100644
index 0000000..8aa022b
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter/JsonPatchDocumentExtensionDataAdapter.csproj
@@ -0,0 +1,18 @@
+
+
+
+ netstandard2.1
+ 12
+ enable
+ JsonExtensionDataPatchDocumentAdapter
+ Hochfrequenz Unternehmensberatung GmbH
+ transforms operation paths in JsonPatchDocuments such that they match the models JsonExtensionData properties
+ https://github.com/Hochfrequenz/JsonPatchDocumentExtensionDataAdapter
+
+
+
+
+
+
+
+
diff --git a/JsonPatchDocumentExtensionDataAdapter/UnitTest/InconsistencyTests.cs b/JsonPatchDocumentExtensionDataAdapter/UnitTest/InconsistencyTests.cs
new file mode 100644
index 0000000..2928621
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/UnitTest/InconsistencyTests.cs
@@ -0,0 +1,24 @@
+using FluentAssertions;
+using JsonExtensionDataPatchDocumentAdapter;
+using Newtonsoft.Json;
+
+namespace UnitTest;
+
+///
+/// tests that an exception is raised if the and differ
+///
+[TestClass]
+public class InconsistencyTests
+{
+ [TestMethod]
+ public void Inconsistent_Serialization_Settings_Raise_Error()
+ {
+ var instantiatingWithInvalidAttributes = () =>
+ new JsonPatchDocumentExtensionDataAdapter(
+ x => x.MyModel!.MyExtensionData
+ );
+ instantiatingWithInvalidAttributes
+ .Should()
+ .ThrowExactly();
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/UnitTest/Models.cs b/JsonPatchDocumentExtensionDataAdapter/UnitTest/Models.cs
new file mode 100644
index 0000000..6574998
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/UnitTest/Models.cs
@@ -0,0 +1,89 @@
+namespace UnitTest;
+
+///
+/// class that has the JsonExtensionData attribute on root level
+///
+internal class MyClass
+{
+ public int? Foo { get; set; }
+ public string? Bar { get; set; }
+
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public IDictionary? MyExtensionData { get; set; }
+}
+
+///
+/// class that has the JsonExtensionData attribute _not_ on root level
+///
+internal class MyClassWithNesting
+{
+ public int MyInteger { get; set; }
+
+ public string? MyString { get; set; }
+
+ public MyClass? MyModel { get; set; }
+}
+
+///
+/// similar to but with JsonPropertyName attributes
+///
+internal class MyClassWithJsonNameAttributes
+{
+ [System.Text.Json.Serialization.JsonPropertyName("somethingLikeFoo")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "somethingLikeFoo")]
+ public int Foo { get; set; }
+ public string? Bar { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("thePropertyWithTheExtensionData")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "thePropertyWithTheExtensionData")]
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public IDictionary? MyExtensionData { get; set; }
+}
+
+///
+/// Similar to but with Newtonsoft and STJ attributes
+///
+internal class MyClassWithNestingAndJsonNameAttribute
+{
+ [System.Text.Json.Serialization.JsonPropertyName("mySAdasdasdInteger")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "mySAdasdasdInteger")]
+ public int MyInteger { get; set; }
+
+ public string? MyString { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("moooooodel")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "moooooodel")]
+ public MyClassWithJsonNameAttributes? MyModel { get; set; }
+}
+
+///
+/// similar to but with JsonPropertyName attributes
+///
+internal class MyClassWithInconsistentJsonNameAttributes
+{
+ [System.Text.Json.Serialization.JsonPropertyName("somethingLikeFoo")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "somethingLikeFoo")]
+ public int Foo { get; set; }
+ public string? Bar { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("thePropertyWithTheExtensionData")]
+ // no newtonsoft attribute here
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public IDictionary? MyExtensionData { get; set; }
+}
+
+///
+/// similar to but with inconsistent/missing Newtonsoft attributes
+///
+internal class MyClassWithNestingAndInconsistentJsonNameAttribute
+{
+ [System.Text.Json.Serialization.JsonPropertyName("mySAdasdasdInteger")]
+ // no newtonsoft attribute here
+ public int MyInteger { get; set; }
+
+ public string? MyString { get; set; }
+
+ [System.Text.Json.Serialization.JsonPropertyName("moooooodel")]
+ [Newtonsoft.Json.JsonProperty(PropertyName = "muuuuuudel")]
+ public MyClassWithJsonNameAttributes? MyModel { get; set; }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/UnitTest/NestedPropertiesTest.cs b/JsonPatchDocumentExtensionDataAdapter/UnitTest/NestedPropertiesTest.cs
new file mode 100644
index 0000000..0f152ec
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/UnitTest/NestedPropertiesTest.cs
@@ -0,0 +1,245 @@
+using FluentAssertions;
+using JsonExtensionDataPatchDocumentAdapter;
+using Microsoft.AspNetCore.JsonPatch;
+using Microsoft.AspNetCore.JsonPatch.Exceptions;
+using Microsoft.AspNetCore.JsonPatch.Operations;
+
+namespace UnitTest;
+
+///
+/// other than this test is less theoretical and uses a real model class in our project.
+///
+[TestClass]
+public class NestedPropertiesTest
+{
+ [TestMethod]
+ public void Test_Patching_Nested_ExtensionData_Where_No_ExtensionData_Exist()
+ {
+ var myNestedInstance = new MyClassWithNesting
+ {
+ MyString = "asd",
+ MyInteger = 42,
+ MyModel = new MyClass
+ {
+ Foo = 17,
+ Bar = "Baz",
+ // ExtensionData are still empty.
+ },
+ };
+ JsonPatchDocument patch = new JsonPatchDocument
+ {
+ Operations =
+ {
+ new Operation { op = "remove", path = "/MyModel/Foo" },
+ new Operation
+ {
+ op = "add",
+ path = "/MyModel/Bar",
+ value = "something else",
+ },
+ new Operation
+ {
+ op = "add",
+ path = "/MyModel/lokationszuordnungen",
+ },
+ },
+ };
+ var patchingWithoutAdapter = () => patch.ApplyTo(myNestedInstance);
+ patchingWithoutAdapter
+ .Should()
+ .ThrowExactly()
+ .Which.Message.Should()
+ .Contain(
+ "The target location specified by path segment 'lokationszuordnungen' was not found."
+ ); // this is what gabriel experienced (see ticket)
+
+ var modifiedPatch = new JsonPatchDocumentExtensionDataAdapter(x =>
+ x.MyModel!.MyExtensionData
+ ).TransformDocument(patch, myNestedInstance);
+ var patchingWithAdapter = () => modifiedPatch.ApplyTo(myNestedInstance);
+ patchingWithAdapter.Should().NotThrow();
+ myNestedInstance.MyModel.Foo.Should().BeNull();
+ myNestedInstance.MyModel.MyExtensionData.Should().ContainKey("lokationszuordnungen");
+ }
+
+ ///
+ /// other than this test covers the case that there are UserProperties already
+ ///
+ [TestMethod]
+ public void Test_Patching_Nested_ExtensionData_Where_ExtensionData_Exist_Already()
+ {
+ var myNestedInstance = new MyClassWithNesting
+ {
+ MyString = "asd",
+ MyInteger = 42,
+ MyModel = new MyClass
+ {
+ Foo = 17,
+ Bar = "Baz",
+ MyExtensionData = new Dictionary { { "foo", "bar" } },
+ },
+ };
+ JsonPatchDocument patch = new JsonPatchDocument
+ {
+ Operations =
+ {
+ new Operation { op = "remove", path = "/MyModel/Foo" },
+ new Operation
+ {
+ op = "add",
+ path = "/MyModel/Bar",
+ value = "asd",
+ },
+ new Operation
+ {
+ op = "add",
+ path = "/MyModel/lokationszuordnungen",
+ },
+ },
+ };
+ var patchingWithoutAdapter = () => patch.ApplyTo(myNestedInstance);
+ patchingWithoutAdapter
+ .Should()
+ .ThrowExactly()
+ .Which.Message.Should()
+ .Contain(
+ "The target location specified by path segment 'lokationszuordnungen' was not found."
+ ); // this is what gabriel experienced (see ticket)
+
+ var modifiedPatch = new JsonPatchDocumentExtensionDataAdapter(x =>
+ x.MyModel!.MyExtensionData
+ ).TransformDocument(patch, myNestedInstance);
+ var patchingWithAdapter = () => modifiedPatch.ApplyTo(myNestedInstance);
+ patchingWithAdapter.Should().NotThrow();
+ myNestedInstance.MyModel.Foo.Should().BeNull();
+ myNestedInstance.MyModel.MyExtensionData.Should().ContainKey("lokationszuordnungen");
+ myNestedInstance
+ .MyModel.MyExtensionData.Should()
+ .ContainKey("foo")
+ .WhoseValue.Should()
+ .Be("bar");
+ }
+
+ ///
+ /// Tests overwriting an existing user property
+ ///
+ [TestMethod]
+ public void Test_Patching_Nested_ExtensionData_Where_ExtensionData_Key_Exist_Already()
+ {
+ var myNestedInstance = new MyClassWithNesting
+ {
+ MyString = "asd",
+ MyInteger = 42,
+ MyModel = new MyClass
+ {
+ Foo = 17,
+ Bar = "Baz",
+ MyExtensionData = new Dictionary { { "unmappedProperty", "bar" } },
+ },
+ };
+ JsonPatchDocument patch = new JsonPatchDocument
+ {
+ Operations =
+ {
+ new Operation
+ {
+ op = "replace",
+ path = "/MyString",
+ value = "my new string",
+ },
+ new Operation
+ {
+ op = "replace",
+ path = "/MyModel/unmappedProperty",
+ value = "xyz",
+ },
+ },
+ };
+ var patchingWithoutAdapter = () => patch.ApplyTo(myNestedInstance);
+ patchingWithoutAdapter
+ .Should()
+ .ThrowExactly()
+ .Which.Message.Should()
+ .Contain(
+ "The target location specified by path segment 'unmappedProperty' was not found."
+ ); // this is what gabriel experienced (see ticket)
+
+ var modifiedPatch = new JsonPatchDocumentExtensionDataAdapter(x =>
+ x.MyModel!.MyExtensionData
+ ).TransformDocument(patch, myNestedInstance);
+ var patchingWithAdapter = () => modifiedPatch.ApplyTo(myNestedInstance);
+ patchingWithAdapter.Should().NotThrow();
+ myNestedInstance.MyString.Should().Be("my new string");
+ myNestedInstance
+ .MyModel.MyExtensionData.Should()
+ .ContainKey("unmappedProperty")
+ .WhoseValue.Should()
+ .Be("xyz");
+ }
+
+ ///
+ /// Tests overwriting an existing user property where the property has json name attributes
+ ///
+ [TestMethod]
+ public void Test_Patching_Nested_ExtensionData_Where_ExtensionData_Key_Exist_Already_With_JsonNames()
+ {
+ var myNestedInstance = new MyClassWithNestingAndJsonNameAttribute
+ {
+ MyString = "asd",
+ MyInteger = 42,
+ MyModel = new MyClassWithJsonNameAttributes
+ {
+ Foo = 17,
+ Bar = "Baz",
+ MyExtensionData = new Dictionary { { "unmappedProperty", "bar" } },
+ },
+ };
+ JsonPatchDocument patch =
+ new JsonPatchDocument
+ {
+ Operations =
+ {
+ new Operation
+ {
+ op = "replace",
+ path = "/MyString",
+ value = "my new string",
+ },
+ new Operation
+ {
+ op = "replace",
+ path = "/mySAdasdasdInteger",
+ value = 43,
+ },
+ new Operation
+ {
+ op = "replace",
+ path = "/moooooodel/unmappedProperty",
+ value = "xyz",
+ },
+ },
+ };
+ var patchingWithoutAdapter = () => patch.ApplyTo(myNestedInstance);
+ patchingWithoutAdapter
+ .Should()
+ .ThrowExactly()
+ .Which.Message.Should()
+ .Contain(
+ "The target location specified by path segment 'unmappedProperty' was not found."
+ ); // this is what gabriel experienced (see ticket)
+
+ var modifiedPatch =
+ new JsonPatchDocumentExtensionDataAdapter(x =>
+ x.MyModel!.MyExtensionData
+ ).TransformDocument(patch, myNestedInstance);
+ var patchingWithAdapter = () => modifiedPatch.ApplyTo(myNestedInstance);
+ patchingWithAdapter.Should().NotThrow();
+ myNestedInstance.MyString.Should().Be("my new string");
+ myNestedInstance.MyInteger.Should().Be(43);
+ myNestedInstance
+ .MyModel.MyExtensionData.Should()
+ .ContainKey("unmappedProperty")
+ .WhoseValue.Should()
+ .Be("xyz");
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/UnitTest/SimpleTests.cs b/JsonPatchDocumentExtensionDataAdapter/UnitTest/SimpleTests.cs
new file mode 100644
index 0000000..0b6678e
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/UnitTest/SimpleTests.cs
@@ -0,0 +1,115 @@
+using FluentAssertions;
+using JsonExtensionDataPatchDocumentAdapter;
+using Microsoft.AspNetCore.JsonPatch;
+using Microsoft.AspNetCore.JsonPatch.Operations;
+
+namespace UnitTest;
+
+[TestClass]
+public class SimpleTests
+{
+ [TestMethod]
+ [DataRow(true, false, false)]
+ [DataRow(true, false, true)]
+ [DataRow(false, true, true)]
+ [DataRow(false, true, false)]
+ [DataRow(false, false, true)]
+ [DataRow(false, false, false)]
+ public void TestPatchingExtensionData(
+ bool initialExtensionDataAreEmpty,
+ bool overwriteExistingExtensionData,
+ bool useStringlyTypedPath
+ )
+ {
+ if (initialExtensionDataAreEmpty && overwriteExistingExtensionData)
+ {
+ throw new Exception("This makes no sense");
+ }
+
+ string myEntityAsJson;
+ if (initialExtensionDataAreEmpty)
+ {
+ myEntityAsJson = """
+ {
+ "Foo": 17,
+ "Bar": "asd"
+ }
+ """;
+ }
+ else
+ {
+ myEntityAsJson = """
+ {
+ "Foo": 17,
+ "Bar": "asd",
+ "abc": "def"
+ }
+ """;
+ }
+
+ var myEntity = System.Text.Json.JsonSerializer.Deserialize(myEntityAsJson);
+ myEntity.Should().NotBeNull();
+ if (initialExtensionDataAreEmpty)
+ {
+ myEntity!.MyExtensionData.Should().BeNullOrEmpty();
+ }
+ else
+ {
+ myEntity!.MyExtensionData.Should().NotBeNullOrEmpty();
+ }
+
+ var myPatch = new JsonPatchDocument();
+ myPatch.Add(x => x.Foo, 42);
+ myPatch.Add(x => x.Bar, "fgh");
+ string modifiedKey = overwriteExistingExtensionData ? "abc" : "uvw";
+
+ if (!useStringlyTypedPath)
+ {
+ if (initialExtensionDataAreEmpty)
+ {
+ myPatch.Add(
+ x => x.MyExtensionData,
+ new Dictionary { { modifiedKey, "xyz" } }
+ );
+ }
+ else
+ {
+ myEntity.MyExtensionData.Should().NotBeNull();
+ myPatch.Add(x => x.MyExtensionData![modifiedKey], "xyz");
+ }
+ }
+ else
+ {
+ myPatch.Operations.Add(
+ new Operation
+ {
+ path = "/" + modifiedKey,
+ op = "add",
+ value = "xyz",
+ }
+ );
+ myPatch = new JsonPatchDocumentExtensionDataAdapter(x =>
+ x.MyExtensionData
+ ).TransformDocument(myPatch, in myEntity);
+ }
+
+ myPatch.ApplyTo(myEntity);
+
+ // Assertions
+ myEntity.Foo.Should().Be(42);
+ myEntity.Bar.Should().Be("fgh");
+ myEntity.MyExtensionData.Should().NotBeNull();
+ myEntity.MyExtensionData.Should().ContainKey(modifiedKey);
+ myEntity.MyExtensionData![modifiedKey].Should().Be("xyz");
+
+ if (!overwriteExistingExtensionData && !initialExtensionDataAreEmpty)
+ {
+ myEntity
+ .MyExtensionData.Should()
+ .ContainKey("abc")
+ .WhoseValue.ToString() // todo: why do we need to string here? it's only relevant for the parametrizations where the initial extension data are not empty (initialExtensionDataAreEmpty = false, overwrite = false)
+ .Should()
+ .Be("def");
+ }
+ }
+}
diff --git a/JsonPatchDocumentExtensionDataAdapter/UnitTest/UnitTest.csproj b/JsonPatchDocumentExtensionDataAdapter/UnitTest/UnitTest.csproj
new file mode 100644
index 0000000..0356764
--- /dev/null
+++ b/JsonPatchDocumentExtensionDataAdapter/UnitTest/UnitTest.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+