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 + + + + + + + + + + + + + + + + + + +