diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index ad89a48ac6..3919f2223e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,31 +3,25 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.1.2", + "version": "2023.2.1", "commands": [ "jb" ] }, "regitlint": { - "version": "6.3.11", + "version": "6.3.12", "commands": [ "regitlint" ] }, - "codecov.tool": { - "version": "1.13.0", - "commands": [ - "codecov" - ] - }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.20", + "version": "5.1.25", "commands": [ "reportgenerator" ] }, "docfx": { - "version": "2.67.1", + "version": "2.70.4", "commands": [ "docfx" ] diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..b065d30682 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: json-api-dotnet diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..aa52764416 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + pull-request-branch-name: + separator: "-" +- package-ecosystem: nuget + directory: "/" + schedule: + interval: daily + pull-request-branch-name: + separator: "-" + open-pull-requests-limit: 25 + ignore: + # Block updates to all exposed dependencies of the NuGet packages we produce, as updating them would be a breaking change. + - dependency-name: 'Ben.Demystifier' + - dependency-name: 'Humanizer*' + - dependency-name: 'Microsoft.CodeAnalysis*' + - dependency-name: 'Microsoft.EntityFrameworkCore*' + # Block major updates of packages that require a matching .NET version. + - dependency-name: 'Microsoft.AspNetCore*' + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..a796be61f0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,304 @@ +# General links +# https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables +# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context +# https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads +# https://docs.github.com/en/actions/learn-github-actions/expressions +# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions +# https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + +name: Build + +on: + push: + branches: [ 'master', 'release/**' ] + pull_request: + branches: [ 'master', 'release/**' ] + release: + types: [published] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + # The Windows runner image has PostgreSQL pre-installed and sets the PGPASSWORD environment variable to "root". + # This conflicts with the default password "postgres", which is used by ikalnytskyi/action-setup-postgres. + # Because action-setup-postgres forgets to update the environment variable accordingly, we do so here. + PGPASSWORD: "postgres" + +jobs: + build-and-test: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - name: Setup PostgreSQL + uses: ikalnytskyi/action-setup-postgres@v4 + with: + username: postgres + password: postgres + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + - name: Setup PowerShell (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + dotnet tool install --global PowerShell + - name: Find latest PowerShell version (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $packageName = "powershell" + $outputText = dotnet tool search $packageName --take 1 + $outputLine = ("" + $outputText) + $indexOfVersionLine = $outputLine.IndexOf($packageName) + $latestVersion = $outputLine.substring($indexOfVersionLine + $packageName.length).trim().split(" ")[0].trim() + + Write-Output "Found PowerShell version: $latestVersion" + Write-Output "POWERSHELL_LATEST_VERSION=$latestVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Setup PowerShell (Windows) + if: matrix.os == 'windows-latest' + shell: cmd + run: | + set DOWNLOAD_LINK=https://github.com/PowerShell/PowerShell/releases/download/v%POWERSHELL_LATEST_VERSION%/PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi + set OUTPUT_PATH=%RUNNER_TEMP%\PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi + echo Downloading from: %DOWNLOAD_LINK% to: %OUTPUT_PATH% + curl --location --output %OUTPUT_PATH% %DOWNLOAD_LINK% + msiexec.exe /package %OUTPUT_PATH% /quiet USE_MU=1 ENABLE_MU=1 ADD_PATH=1 DISABLE_TELEMETRY=1 + - name: Setup PowerShell (macOS) + if: matrix.os == 'macos-latest' + run: | + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + brew install --cask powershell + - name: Show installed versions + shell: pwsh + run: | + Write-Host "$(pwsh --version) is installed at $PSHOME" + psql --version + Write-Host "Active .NET SDK: $(dotnet --version)" + - name: Git checkout + uses: actions/checkout@v4 + - name: Restore tools + run: | + dotnet tool restore + - name: Restore packages + run: | + dotnet restore + - name: Calculate version suffix + shell: pwsh + run: | + if ($env:GITHUB_REF_TYPE -eq 'tag') { + # Get the version prefix/suffix from the git tag. For example: 'v1.0.0-preview1-final' => '1.0.0' and 'preview1-final' + $segments = $env:GITHUB_REF_NAME -split "-" + $versionPrefix = $segments[0].TrimStart('v') + $versionSuffix = $segments.Count -eq 1 ? '' : $segments[1..$($segments.Length-1)] -join '-' + + [xml]$xml = Get-Content Directory.Build.props + $configuredVersionPrefix = $xml.Project.PropertyGroup[0].JsonApiDotNetCoreVersionPrefix + if ($configuredVersionPrefix -ne $versionPrefix) { + Write-Error "Version prefix from git release tag '$versionPrefix' does not match version prefix '$configuredVersionPrefix' stored in Directory.Build.props." + # To recover from this: + # - Delete the GitHub release + # - Run: git push --delete origin the-invalid-tag-name + # - Adjust JsonApiDotNetCoreVersionPrefix in Directory.Build.props, commit and push + # - Recreate the GitHub release + } + } + else { + # Get the version suffix from the auto-incrementing build number. For example: '123' => 'master-00123' + $revision = "{0:D5}" -f [convert]::ToInt32($env:GITHUB_RUN_NUMBER, 10) + $branchName = ![string]::IsNullOrEmpty($env:GITHUB_HEAD_REF) ? $env:GITHUB_HEAD_REF : $env:GITHUB_REF_NAME + $safeName = $branchName.Replace('/', '-').Replace('_', '-') + $versionSuffix = "$safeName-$revision" + } + Write-Output "Using version suffix: $versionSuffix" + Write-Output "PACKAGE_VERSION_SUFFIX=$versionSuffix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Build + shell: pwsh + run: | + if ($env:PACKAGE_VERSION_SUFFIX) { + dotnet build --no-restore --configuration Release --version-suffix=$env:PACKAGE_VERSION_SUFFIX + } + else { + dotnet build --no-restore --configuration Release + } + - name: Test + run: | + dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" -- RunConfiguration.CollectSourceInformation=true DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true + - name: Upload coverage to codecov.io + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v3 + - name: Generate packages + shell: pwsh + run: | + if ($env:PACKAGE_VERSION_SUFFIX) { + dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages --version-suffix=$env:PACKAGE_VERSION_SUFFIX + } + else { + dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages + } + - name: Upload packages to artifacts + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v3 + with: + name: packages + path: artifacts/packages + - name: Generate documentation + shell: pwsh + env: + # This contains the git tag name on release; in that case we build the docs without publishing them. + DOCFX_SOURCE_BRANCH_NAME: ${{ github.base_ref || github.ref_name }} + run: | + cd docs + & ./generate-examples.ps1 + dotnet docfx docfx.json + if ($LastExitCode -ne 0) { + Write-Error "docfx failed with exit code $LastExitCode." + } + Copy-Item CNAME _site/CNAME + Copy-Item home/*.html _site/ + Copy-Item home/*.ico _site/ + New-Item -Force _site/styles -ItemType Directory | Out-Null + Copy-Item -Recurse home/assets/* _site/styles/ + - name: Upload documentation to artifacts + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v3 + with: + name: documentation + path: docs/_site + + inspect-code: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + - name: Git checkout + uses: actions/checkout@v4 + - name: Restore tools + run: | + dotnet tool restore + - name: InspectCode + shell: pwsh + run: | + $inspectCodeOutputPath = Join-Path $env:RUNNER_TEMP 'jetbrains-inspectcode-results.xml' + Write-Output "INSPECT_CODE_OUTPUT_PATH=$inspectCodeOutputPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$inspectCodeOutputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal + - name: Verify outcome + shell: pwsh + run: | + [xml]$xml = Get-Content $env:INSPECT_CODE_OUTPUT_PATH + if ($xml.report.Issues -and $xml.report.Issues.Project) { + foreach ($project in $xml.report.Issues.Project) { + if ($project.Issue.Count -gt 0) { + $project.ForEach({ + Write-Output "`nProject $($project.Name)" + $failed = $true + + $_.Issue.ForEach({ + $issueType = $xml.report.IssueTypes.SelectSingleNode("IssueType[@Id='$($_.TypeId)']") + $severity = $_.Severity ?? $issueType.Severity + + Write-Output "[$severity] $($_.File):$($_.Line) $($_.TypeId): $($_.Message)" + }) + }) + } + } + + if ($failed) { + Write-Error "One or more projects failed code inspection." + } + } + + cleanup-code: + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 6.0.x + - name: Git checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Restore tools + run: | + dotnet tool restore + - name: Restore packages + run: | + dotnet restore + - name: CleanupCode (on PR diff) + if: github.event_name == 'pull_request' + shell: pwsh + run: | + # Not using the environment variables for SHAs, because they may be outdated. This may happen on force-push after the build is queued, but before it starts. + # The below works because HEAD is detached (at the merge commit), so HEAD~1 is at the base branch. When a PR contains no commits, this job will not run. + $headCommitHash = git rev-parse HEAD + $baseCommitHash = git rev-parse HEAD~1 + + Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash in pull request." + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff + - name: CleanupCode (on branch) + if: github.event_name == 'push' || github.event_name == 'release' + shell: pwsh + run: | + Write-Output "Running code cleanup on all files." + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff + + publish: + timeout-minutes: 60 + runs-on: ubuntu-latest + needs: [ build-and-test, inspect-code, cleanup-code ] + if: ${{ !github.event.pull_request.head.repo.fork }} + permissions: + packages: write + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + - name: Publish to GitHub Packages + if: github.event_name == 'push' || github.event_name == 'release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + dotnet nuget add source --username 'json-api-dotnet' --password "$env:GITHUB_TOKEN" --store-password-in-clear-text --name 'github' 'https://nuget.pkg.github.com/json-api-dotnet/index.json' + dotnet nuget push "$env:GITHUB_WORKSPACE/packages/*.nupkg" --api-key "$env:GITHUB_TOKEN" --source 'github' + - name: Publish documentation + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./documentation + commit_message: 'Auto-generated documentation from' + - name: Publish to NuGet + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v') + env: + NUGET_ORG_API_KEY: ${{ secrets.NUGET_ORG_API_KEY }} + shell: pwsh + run: | + dotnet nuget push "$env:GITHUB_WORKSPACE/packages/*.nupkg" --api-key "$env:NUGET_ORG_API_KEY" --source 'nuget.org' diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000000..9225d9f816 --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,33 @@ +# https://www.jetbrains.com/help/qodana/cloud-forward-reports.html#cloud-forward-reports-github-actions + +name: Qodana +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + - 'release/*' + +jobs: + qodana: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit + fetch-depth: 0 # a full history is required for pull request analysis + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2023.2 + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + - name: Upload results to artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + name: qodana_results + path: ${{ runner.temp }}/qodana/results diff --git a/Build.ps1 b/Build.ps1 index 2a143dd168..4f0912079d 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,115 +1,23 @@ -function CheckLastExitCode { - param ([int[]]$SuccessCodes = @(0), [scriptblock]$CleanupScript=$null) - - if ($SuccessCodes -notcontains $LastExitCode) { - throw "Executable returned exit code $LastExitCode" - } -} - -function RunInspectCode { - $outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml') - dotnet jb inspectcode JsonApiDotNetCore.sln --no-build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal - CheckLastExitCode - - [xml]$xml = Get-Content "$outputPath" - if ($xml.report.Issues -and $xml.report.Issues.Project) { - foreach ($project in $xml.report.Issues.Project) { - if ($project.Issue.Count -gt 0) { - $project.ForEach({ - Write-Output "`nProject $($project.Name)" - $failed = $true - - $_.Issue.ForEach({ - $issueType = $xml.report.IssueTypes.SelectSingleNode("IssueType[@Id='$($_.TypeId)']") - $severity = $_.Severity ?? $issueType.Severity - - Write-Output "[$severity] $($_.File):$($_.Line) $($_.TypeId): $($_.Message)" - }) - }) - } - } - - if ($failed) { - throw "One or more projects failed code inspection."; - } - } -} - -function RunCleanupCode { - # When running in cibuild for a pull request, this reformats only the files changed in the PR and fails if the reformat produces changes. - - if ($env:APPVEYOR_PULL_REQUEST_HEAD_COMMIT) { - # In the past, we used $env:APPVEYOR_PULL_REQUEST_HEAD_COMMIT for the merge commit hash. That is the pinned hash at the time the build is enqueued. - # When a force-push happens after that, while the build hasn't yet started, this hash becomes invalid during the build, resulting in a lookup error. - # To prevent failing the build for unobvious reasons we use HEAD, which is always a detached head (the PR merge result). - - $headCommitHash = git rev-parse HEAD - CheckLastExitCode - - $baseCommitHash = git rev-parse "$env:APPVEYOR_REPO_BRANCH" - CheckLastExitCode - - if ($baseCommitHash -ne $headCommitHash) { - Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash in pull request." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff - CheckLastExitCode - } +function VerifySuccessExitCode { + if ($LastExitCode -ne 0) { + throw "Command failed with exit code $LastExitCode." } } -function ReportCodeCoverage { - if ($env:APPVEYOR) { - if ($IsWindows) { - dotnet codecov -f "**\coverage.cobertura.xml" - } - } - else { - dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage - } - - CheckLastExitCode -} - -function CreateNuGetPackage { - if ($env:APPVEYOR_REPO_TAG -eq $true) { - # Get the version suffix from the repo tag. Example: v1.0.0-preview1-final => preview1-final - $segments = $env:APPVEYOR_REPO_TAG_NAME -split "-" - $suffixSegments = $segments[1..2] - $versionSuffix = $suffixSegments -join "-" - } - else { - # Get the version suffix from the auto-incrementing build number. Example: "123" => "master-0123". - if ($env:APPVEYOR_BUILD_NUMBER) { - $revision = "{0:D4}" -f [convert]::ToInt32($env:APPVEYOR_BUILD_NUMBER, 10) - $versionSuffix = "$($env:APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH ?? $env:APPVEYOR_REPO_BRANCH)-$revision" - } - else { - $versionSuffix = "pre-0001" - } - } - - if ([string]::IsNullOrWhitespace($versionSuffix)) { - dotnet pack --no-restore --no-build --configuration Release --output .\artifacts - } - else { - dotnet pack --no-restore --no-build --configuration Release --output .\artifacts --version-suffix=$versionSuffix - } - - CheckLastExitCode -} +Write-Host "$(pwsh --version)" +Write-Host "Active .NET SDK: $(dotnet --version)" dotnet tool restore -CheckLastExitCode - -dotnet build -c Release -CheckLastExitCode +VerifySuccessExitCode -RunInspectCode -RunCleanupCode +dotnet build --configuration Release --version-suffix="pre" +VerifySuccessExitCode -dotnet test -c Release --no-build --collect:"XPlat Code Coverage" -CheckLastExitCode +dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true +VerifySuccessExitCode -ReportCodeCoverage +dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage -filefilters:-*.g.cs +VerifySuccessExitCode -CreateNuGetPackage +dotnet pack --no-build --configuration Release --output artifacts/packages --version-suffix="pre" +VerifySuccessExitCode diff --git a/Directory.Build.props b/Directory.Build.props index 3ee6ae61bd..1c96340f4f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,9 +4,9 @@ 6.0.* 7.0.* 7.0.* - 4.6.* + 4.7.* 2.14.1 - 5.3.0 + 5.4.0 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable @@ -21,6 +21,10 @@ + + true + + $(NoWarn);1591 true @@ -34,7 +38,7 @@ 6.0.* - 4.18.* - 17.6.* + 2.3.* + 17.7.* diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 2b714c22d3..2602272e97 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -665,6 +665,7 @@ $left$ = $right$; True True True + True True True diff --git a/README.md b/README.md index b3ffb5db90..90bb47b405 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # JsonApiDotNetCore A framework for building [JSON:API](http://jsonapi.org/) compliant REST APIs using .NET Core and Entity Framework Core. Includes support for [Atomic Operations](https://jsonapi.org/ext/atomic/). -[![Build](https://ci.appveyor.com/api/projects/status/t8noo6rjtst51kga/branch/master?svg=true)](https://ci.appveyor.com/project/json-api-dotnet/jsonapidotnetcore/branch/master) +[![Build](https://github.com/json-api-dotnet/JsonApiDotNetCore/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/json-api-dotnet/JsonApiDotNetCore/actions/workflows/build.yml?query=branch%3Amaster) [![Coverage](https://codecov.io/gh/json-api-dotnet/JsonApiDotNetCore/branch/master/graph/badge.svg?token=pn036tWV8T)](https://codecov.io/gh/json-api-dotnet/JsonApiDotNetCore) [![NuGet](https://img.shields.io/nuget/v/JsonApiDotNetCore.svg)](https://www.nuget.org/packages/JsonApiDotNetCore/) [![Chat](https://badges.gitter.im/json-api-dotnet-core/Lobby.svg)](https://gitter.im/json-api-dotnet-core/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) @@ -95,15 +95,24 @@ Have a question, found a bug or want to submit code changes? See our [contributi ## Trying out the latest build -After each commit to the master branch, a new prerelease NuGet package is automatically published to AppVeyor at https://ci.appveyor.com/nuget/jsonapidotnetcore. To try it out, follow the next steps: - -* In Visual Studio: **Tools**, **NuGet Package Manager**, **Package Manager Settings**, **Package Sources** - * Click **+** - * Name: **AppVeyor JADNC**, Source: **https://ci.appveyor.com/nuget/jsonapidotnetcore** - * Click **Update**, **Ok** -* Open the NuGet package manager console (**Tools**, **NuGet Package Manager**, **Package Manager Console**) - * Select **AppVeyor JADNC** as package source - * Run command: `Install-Package JonApiDotNetCore -pre` +After each commit to the master branch, a new pre-release NuGet package is automatically published to [GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry). +To try it out, follow the steps below: + +1. [Create a Personal Access Token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with at least `read:packages` scope. +1. Add our package source to your local user-specific `nuget.config` file by running: + ```bash + dotnet nuget add source https://nuget.pkg.github.com/json-api-dotnet/index.json --name github-json-api --username YOUR-GITHUB-USERNAME --password YOUR-PAT-CLASSIC + ``` + In the command above: + - Replace YOUR-GITHUB-USERNAME with the username you use to login your GitHub account. + - Replace YOUR-PAT-CLASSIC with the token your created above. + + :warning: If the above command doesn't give you access in the next step, remove the package source by running: + ```bash + dotnet nuget remove source github-json-api + ``` + and retry with the `--store-password-in-clear-text` switch added. +1. Restart your IDE, open your project, and browse the list of packages from the github-json-api feed (make sure pre-release packages are included). ## Development @@ -125,7 +134,7 @@ And then to run the tests: dotnet test ``` -Alternatively, to build and validate the code, run all tests, generate code coverage and produce the NuGet package: +Alternatively, to build, run all tests, generate code coverage and NuGet packages: ```bash pwsh Build.ps1 diff --git a/appveyor.yml b/appveyor.yml index 4b0196904d..1ad1455837 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,115 +1,12 @@ -image: - - Ubuntu2004 - - Visual Studio 2022 +image: Visual Studio 2022 version: '{build}' -stack: postgresql 15 - -environment: - PGUSER: postgres - PGPASSWORD: Password12! - GIT_ACCESS_TOKEN: - secure: WPzhuEyDE7yuHeEgLi3RoGJ8we+AHU6nMksbFoWQ0AmI/HJLh4bjOR0Jnnzc6aaG - branches: only: - master - - develop - - unstable - /release\/.+/ -pull_requests: - do_not_increment_build_number: true - -nuget: - disable_publish_on_pr: true - -matrix: - fast_finish: true - -for: -- - matrix: - only: - - image: Visual Studio 2022 - services: - - postgresql15 - install: - # Temporary workaround for https://help.appveyor.com/discussions/questions/60488-postgresql-version - - net start postgresql-x64-15 - # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml - before_build: - - pwsh: | - if (-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { - # https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html - git checkout $env:APPVEYOR_REPO_BRANCH -q - } - after_build: - - pwsh: | - CD ./docs - & ./generate-examples.ps1 - & dotnet docfx docfx.json - if ($LastExitCode -ne 0) { - throw "docfx failed with exit code $LastExitCode." - } - - # https://www.appveyor.com/docs/how-to/git-push/ - git config --global credential.helper store - Set-Content -Path "$HOME\.git-credentials" -Value "https://$($env:GIT_ACCESS_TOKEN):x-oauth-basic@github.com`n" -NoNewline - git config --global user.email "cibuild@jsonapi.net" - git config --global user.name "json-api-cibuild" - git config --global core.autocrlf false - git config --global core.safecrlf false - git clone https://github.com/json-api-dotnet/JsonApiDotNetCore.git -b gh-pages origin_site -q - Copy-Item origin_site/.git _site -recurse - Copy-Item CNAME _site/CNAME - Copy-Item home/*.html _site/ - Copy-Item home/*.ico _site/ - New-Item -Force _site/styles -ItemType Directory | Out-Null - Copy-Item -Recurse home/assets/* _site/styles/ - CD _site - git add -A 2>&1 - git commit -m "Automated commit from cibuild" -q - if (-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { - git push origin gh-pages -q - echo "Documentation updated successfully." - } - artifacts: - - path: .\**\artifacts\**\*.nupkg - name: NuGet - deploy: - - provider: NuGet - skip_symbols: false - api_key: - secure: hlP/zkfkHzmutSXPYAiINmPdv+QEj3TpAjKewHEkCtQnHnA2tSo+Xey0g6FVM6S5 - on: - branch: master - appveyor_repo_tag: true - - provider: NuGet - skip_symbols: false - api_key: - secure: hlP/zkfkHzmutSXPYAiINmPdv+QEj3TpAjKewHEkCtQnHnA2tSo+Xey0g6FVM6S5 - on: - branch: /release\/.+/ - appveyor_repo_tag: true - -build_script: -- pwsh: | - Write-Output ".NET version:" - dotnet --version - - Write-Output "PowerShell version:" - pwsh --version - - Write-Output "PostgreSQL version:" - if ($IsWindows) { - . "${env:ProgramFiles}\PostgreSQL\15\bin\psql" --version - } - else { - psql --version - } - - .\Build.ps1 - +build: off test: off +deploy: off diff --git a/cleanupcode.ps1 b/cleanupcode.ps1 index 5740ab5a90..ba1b0ca4c0 100644 --- a/cleanupcode.ps1 +++ b/cleanupcode.ps1 @@ -39,6 +39,6 @@ if ($revision) { } else { Write-Output "Running code cleanup on all files." - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN VerifySuccessExitCode } diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index cbe7d13e9d..4b13408460 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -1,28 +1,28 @@ #Requires -Version 7.3 -# This script generates response documents for ./request-examples +# This script generates HTTP response files (*.json) for .ps1 files in ./request-examples function Get-WebServer-ProcessId { - $processId = $null - if ($IsMacOs || $IsLinux) { - $processId = $(lsof -ti:14141) + $webProcessId = $null + if ($IsMacOS -Or $IsLinux) { + $webProcessId = $(lsof -ti:14141) } elseif ($IsWindows) { - $processId = $(Get-NetTCPConnection -LocalPort 14141 -ErrorAction SilentlyContinue).OwningProcess?[0] + $webProcessId = $(Get-NetTCPConnection -LocalPort 14141 -ErrorAction SilentlyContinue).OwningProcess?[0] } else { - throw [System.Exception] "Unsupported operating system." + throw "Unsupported operating system." } - return $processId + return $webProcessId } -function Kill-WebServer { - $processId = Get-WebServer-ProcessId +function Stop-WebServer { + $webProcessId = Get-WebServer-ProcessId - if ($processId -ne $null) { + if ($webProcessId -ne $null) { Write-Output "Stopping web server" - Get-Process -Id $processId | Stop-Process -ErrorVariable stopErrorMessage + Get-Process -Id $webProcessId | Stop-Process -ErrorVariable stopErrorMessage if ($stopErrorMessage) { throw "Failed to stop web server: $stopErrorMessage" @@ -32,16 +32,28 @@ function Kill-WebServer { function Start-WebServer { Write-Output "Starting web server" - Start-Job -ScriptBlock { dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj } | Out-Null + $startTimeUtc = Get-Date -AsUTC + $job = Start-Job -ScriptBlock { + dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj --configuration Debug --property:TreatWarningsAsErrors=True --urls=http://0.0.0.0:14141 + } $webProcessId = $null + $timeout = [timespan]::FromSeconds(30) + Do { Start-Sleep -Seconds 1 + $hasTimedOut = ($(Get-Date -AsUTC) - $startTimeUtc) -gt $timeout $webProcessId = Get-WebServer-ProcessId - } While ($webProcessId -eq $null) + } While ($webProcessId -eq $null -and -not $hasTimedOut) + + if ($hasTimedOut) { + Write-Host "Failed to start web server, dumping output." + Receive-Job -Job $job + throw "Failed to start web server." + } } -Kill-WebServer +Stop-WebServer Start-WebServer try { @@ -55,10 +67,10 @@ try { & $scriptFile.FullName > .\request-examples\$jsonFileName if ($LastExitCode -ne 0) { - throw [System.Exception] "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode." + throw "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode." } } } finally { - Kill-WebServer + Stop-WebServer } diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index e1caa85797..574ebaf92c 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -158,8 +158,8 @@ We use a similar approach for accessing [MongoDB](https://github.com/json-api-do #### I love JsonApiDotNetCore! How can I support the team? The best way to express your gratitude is by starring our repository. This increases our leverage when asking for bug fixes in dependent projects, such as the .NET runtime and Entity Framework Core. +You can also [sponsor](https://github.com/sponsors/json-api-dotnet) our project. Of course, a simple thank-you message in our [Gitter channel](https://gitter.im/json-api-dotnet-core/Lobby) is appreciated too! -We don't take monetary contributions at the moment. If you'd like to do more: try things out, ask questions, create GitHub bug reports or feature requests, or upvote existing issues that are important to you. We welcome PRs, but keep in mind: The worst thing in the world is opening a PR that gets rejected after you've put a lot of effort into it. diff --git a/docs/usage/options.md b/docs/usage/options.md index 549bfc454c..7607ac8a9e 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -10,16 +10,35 @@ builder.Services.AddJsonApi(options => }); ``` -## Client Generated IDs +## Client-generated IDs By default, the server will respond with a 403 Forbidden HTTP Status Code if a POST request is received with a client-generated ID. -However, this can be allowed by setting the AllowClientGeneratedIds flag in the options: +However, this can be allowed or required globally (for all resource types) by setting `ClientIdGeneration` in options: ```c# -options.AllowClientGeneratedIds = true; +options.ClientIdGeneration = ClientIdGenerationMode.Allowed; ``` +or: + +```c# +options.ClientIdGeneration = ClientIdGenerationMode.Required; +``` + +It is possible to overrule this setting per resource type: + +```c# +[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)] +public class Article : Identifiable +{ + // ... +} +``` + +> [!NOTE] +> JsonApiDotNetCore versions before v5.4.0 only provided the global `AllowClientGeneratedIds` boolean property. + ## Pagination The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`. diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 5e3195ca0b..18a13da907 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -28,7 +28,7 @@ You can enable auto-discovery for the current assembly by adding the following a ```c# // Program.cs -builder.Services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(discovery: discovery => discovery.AddCurrentAssembly()); ``` ### Specifying an Entity Framework Core DbContext @@ -44,7 +44,7 @@ Be aware that this does not register resource definitions, resource services and ```c# // Program.cs -builder.Services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(discovery: discovery => discovery.AddCurrentAssembly()); ``` ### Manual Specification diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs index 510d750fa7..67c7a4138c 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -83,15 +83,16 @@ public Task> GetAsync(CancellationToken cancellat private void LogFiltersInTopScope() { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true - FilterExpression[] filtersInTopScope = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + FilterExpression[] filtersInTopScope = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filtersInTopScope); diff --git a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs index e0c786a106..4264c5db8b 100644 --- a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs @@ -3,7 +3,6 @@ using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks namespace JsonApiDotNetCore; diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs new file mode 100644 index 0000000000..41f856accc --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ClientIdGenerationMode.shared.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Indicates how to handle IDs sent by JSON:API clients when creating resources. +/// +[PublicAPI] +public enum ClientIdGenerationMode +{ + /// + /// Returns an HTTP 403 (Forbidden) response if a client attempts to create a resource with a client-supplied ID. + /// + Forbidden, + + /// + /// Allows a client to create a resource with a client-supplied ID, but does not require it. + /// + Allowed, + + /// + /// Returns an HTTP 422 (Unprocessable Content) response if a client attempts to create a resource without a client-supplied ID. + /// + Required +} diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index 0263958b00..29bd5559b1 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -18,6 +18,12 @@ public sealed class ResourceType /// public string PublicName { get; } + /// + /// Whether API clients are allowed or required to provide IDs when creating resources of this type. When null, the value from global options + /// applies. + /// + public ClientIdGenerationMode? ClientIdGeneration { get; } + /// /// The CLR type of the resource. /// @@ -89,22 +95,24 @@ public sealed class ResourceType /// public LinkTypes RelationshipLinks { get; } - public ResourceType(string publicName, Type clrType, Type identityClrType, LinkTypes topLevelLinks = LinkTypes.NotConfigured, - LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) - : this(publicName, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks) + public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType, + LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, + LinkTypes relationshipLinks = LinkTypes.NotConfigured) + : this(publicName, clientIdGeneration, clrType, identityClrType, null, null, null, topLevelLinks, resourceLinks, relationshipLinks) { } - public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes, - IReadOnlyCollection? relationships, IReadOnlyCollection? eagerLoads, - LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, - LinkTypes relationshipLinks = LinkTypes.NotConfigured) + public ResourceType(string publicName, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType, + IReadOnlyCollection? attributes, IReadOnlyCollection? relationships, + IReadOnlyCollection? eagerLoads, LinkTypes topLevelLinks = LinkTypes.NotConfigured, + LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) { ArgumentGuard.NotNullNorEmpty(publicName); ArgumentGuard.NotNull(clrType); ArgumentGuard.NotNull(identityClrType); PublicName = publicName; + ClientIdGeneration = clientIdGeneration; ClrType = clrType; IdentityClrType = identityClrType; Attributes = attributes ?? Array.Empty(); diff --git a/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs b/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs index 92ba933fe3..a6d11e6093 100644 --- a/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs @@ -1,8 +1,5 @@ using JetBrains.Annotations; -// ReSharper disable CheckNamespace -#pragma warning disable AV1505 // Namespace should match with assembly name - namespace JsonApiDotNetCore.Controllers; // IMPORTANT: An internal copy of this type exists in the SourceGenerators project. Keep these in sync when making changes. diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 0adaf34f74..119d295b35 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -23,10 +23,6 @@ embedded - - true - - diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs index 72669de585..e3f4ce97b5 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; namespace JsonApiDotNetCore.Resources.Annotations; @@ -10,11 +11,23 @@ namespace JsonApiDotNetCore.Resources.Annotations; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public sealed class ResourceAttribute : Attribute { + internal ClientIdGenerationMode? NullableClientIdGeneration { get; set; } + /// /// Optional. The publicly exposed name of this resource type. /// public string? PublicName { get; set; } + /// + /// Optional. Whether API clients are allowed or required to provide IDs when creating resources of this type. When not set, the value from global + /// options applies. + /// + public ClientIdGenerationMode ClientIdGeneration + { + get => NullableClientIdGeneration.GetValueOrDefault(); + set => NullableClientIdGeneration = value; + } + /// /// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to . Set to /// to disable controller generation. diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index 738ba46976..6d79d8c893 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -23,10 +23,6 @@ https://github.com/json-api-dotnet/JsonApiDotNetCore - - true - - diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj.DotSettings b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj.DotSettings new file mode 100644 index 0000000000..c4bbbae949 --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj.DotSettings @@ -0,0 +1,5 @@ + + WARNING + WARNING + WARNING + diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 2def7bfecc..4fe3421dd2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.AtomicOperations; -/// +/// public sealed class LocalIdTracker : ILocalIdTracker { private readonly IDictionary _idsTracked = new Dictionary(); diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index a052aa2d6e..28ac16d612 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.AtomicOperations; -/// +/// [PublicAPI] public class OperationProcessorAccessor : IOperationProcessorAccessor { diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 73306e1237..cf1cdd7b65 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.AtomicOperations; -/// +/// [PublicAPI] public class OperationsProcessor : IOperationsProcessor { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index c8997be8cd..5e63a2d276 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class AddToRelationshipProcessor : IAddToRelationshipProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index e105f54a50..e4ccd7b69d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class CreateProcessor : ICreateProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 356742f9b7..6652d4ddec 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class DeleteProcessor : IDeleteProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index b308d6935a..5fae04f710 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 083bd0d0fc..2de4229aa7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class SetRelationshipProcessor : ISetRelationshipProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 5611f8d1c2..b221951fd2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class UpdateProcessor : IUpdateProcessor where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index bd068b5496..b5c03a05a5 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,5 +1,6 @@ using System.Data; using System.Text.Json; +using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -21,37 +22,40 @@ public interface IJsonApiOptions string? Namespace { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to . This setting can be + /// overruled per attribute using . /// AttrCapabilities DefaultAttrCapabilities { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to . This setting + /// can be overruled per relationship using . /// HasOneCapabilities DefaultHasOneCapabilities { get; } /// - /// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to . + /// Specifies the default set of allowed capabilities on JSON:API to-many relationships. Defaults to . This setting + /// can be overruled per relationship using . /// HasManyCapabilities DefaultHasManyCapabilities { get; } /// - /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. + /// Whether to include a 'jsonapi' object in responses, which contains the highest JSON:API version supported. false by default. /// bool IncludeJsonApiVersion { get; } /// - /// Whether or not stack traces should be included in . False by default. + /// Whether to include stack traces in responses. false by default. /// bool IncludeExceptionStackTraceInErrors { get; } /// - /// Whether or not the request body should be included in when it is invalid. False by default. + /// Whether to include the request body in responses when it is invalid. false by default. /// bool IncludeRequestBodyInErrors { get; } /// - /// Use relative links for all resources. False by default. + /// Whether to use relative links for all resources. false by default. /// /// /// - /// Whether or not the total resource count should be included in top-level meta objects. This requires an additional database query. False by default. + /// Whether to include the total resource count in top-level meta objects. This requires an additional database query. false by default. /// bool IncludeTotalResourceCount { get; } @@ -114,28 +118,40 @@ public interface IJsonApiOptions PageNumber? MaximumPageNumber { get; } /// - /// Whether or not to enable ASP.NET ModelState validation. True by default. + /// Whether ASP.NET ModelState validation is enabled. true by default. /// bool ValidateModelState { get; } /// - /// Whether or not clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned if a client attempts to create - /// a resource with a defined ID. False by default. + /// Whether clients are allowed or required to provide IDs when creating resources. by default. This + /// setting can be overruled per resource type using . /// + ClientIdGenerationMode ClientIdGeneration { get; } + + /// + /// Whether clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned if a client attempts to create a + /// resource with a defined ID. false by default. + /// + /// + /// Setting this to true corresponds to , while false corresponds to + /// . + /// + [PublicAPI] + [Obsolete("Use ClientIdGeneration instead.")] bool AllowClientGeneratedIds { get; } /// - /// Whether or not to produce an error on unknown query string parameters. False by default. + /// Whether to produce an error on unknown query string parameters. false by default. /// bool AllowUnknownQueryStringParameters { get; } /// - /// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default. + /// Whether to produce an error on unknown attribute and relationship keys in request bodies. false by default. /// bool AllowUnknownFieldsInRequestBody { get; } /// - /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. + /// Determines whether legacy filter notation in query strings (such as =eq:, =like:, and =in:) is enabled. false by default. /// bool EnableLegacyFilterNotation { get; } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 821be639f9..6e3e2d718c 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class InverseNavigationResolver : IInverseNavigationResolver { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 778ded8d59..fb0b310118 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class JsonApiOptions : IJsonApiOptions { @@ -69,7 +69,15 @@ public sealed class JsonApiOptions : IJsonApiOptions public bool ValidateModelState { get; set; } = true; /// - public bool AllowClientGeneratedIds { get; set; } + public ClientIdGenerationMode ClientIdGeneration { get; set; } + + /// + [Obsolete("Use ClientIdGeneration instead.")] + public bool AllowClientGeneratedIds + { + get => ClientIdGeneration is ClientIdGenerationMode.Allowed or ClientIdGenerationMode.Required; + set => ClientIdGeneration = value ? ClientIdGenerationMode.Allowed : ClientIdGenerationMode.Forbidden; + } /// public bool AllowUnknownQueryStringParameters { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index f15e33a82e..edf67a3f8e 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class ResourceGraph : IResourceGraph { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 97548bd8fa..4ea0cb30e6 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -237,6 +237,8 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) { + ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType); + IReadOnlyCollection attributes = GetAttributes(resourceClrType); IReadOnlyCollection relationships = GetRelationships(resourceClrType); IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); @@ -246,11 +248,17 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, var linksAttribute = resourceClrType.GetCustomAttribute(true); return linksAttribute == null - ? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads) - : new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, + ? new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads) + : new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks); } + private ClientIdGenerationMode? GetClientIdGeneration(Type resourceClrType) + { + var resourceAttribute = resourceClrType.GetCustomAttribute(true); + return resourceAttribute?.NullableClientIdGeneration; + } + private IReadOnlyCollection GetAttributes(Type resourceClrType) { var attributesByName = new Dictionary(); diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 7ffea227e0..6a1b8517e6 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -21,10 +21,6 @@ embedded - - true - - diff --git a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs index 82e443a9af..a895c20ba4 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter { /// diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index 58d568a3ec..45a0b96891 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// [PublicAPI] public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter { diff --git a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs index 89164c844f..6c394a531f 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter { private readonly IQueryStringReader _queryStringReader; diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 80905d1231..7f7479975a 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// [PublicAPI] public class ExceptionHandler : IExceptionHandler { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index 59563c9268..9988d0dcaf 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class JsonApiInputFormatter : IJsonApiInputFormatter { /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index b38ad986dd..40a784d25c 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -23,10 +23,12 @@ public sealed class JsonApiMiddleware private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); - private readonly RequestDelegate _next; + private readonly RequestDelegate? _next; - public JsonApiMiddleware(RequestDelegate next, IHttpContextAccessor httpContextAccessor) + public JsonApiMiddleware(RequestDelegate? next, IHttpContextAccessor httpContextAccessor) { + ArgumentGuard.NotNull(httpContextAccessor); + _next = next; var session = new AspNetCodeTimerSession(httpContextAccessor); @@ -77,9 +79,12 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin httpContext.RegisterJsonApiRequest(); } - using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) + if (_next != null) { - await _next(httpContext); + using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) + { + await _next(httpContext); + } } } @@ -87,7 +92,8 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin { string timingResults = CodeTimingSessionManager.Current.GetResults(); string url = httpContext.Request.GetDisplayUrl(); - logger.LogInformation($"Measurement results for {httpContext.Request.Method} {url}:{Environment.NewLine}{timingResults}"); + string method = httpContext.Request.Method.Replace(Environment.NewLine, ""); + logger.LogInformation($"Measurement results for {method} {url}:{Environment.NewLine}{timingResults}"); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index 8c97a12ea4..80d7863251 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter { /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 98e42823a3..81e0564311 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// [PublicAPI] public sealed class JsonApiRequest : IJsonApiRequest { diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index e4f127b725..34fc8971d1 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -3,5 +3,3 @@ [assembly: InternalsVisibleTo("Benchmarks")] [assembly: InternalsVisibleTo("JsonApiDotNetCoreTests")] [assembly: InternalsVisibleTo("UnitTests")] -[assembly: InternalsVisibleTo("DiscoveryTests")] -[assembly: InternalsVisibleTo("TestBuildingBlocks")] diff --git a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs index cfbb0ab46b..7c9d983d63 100644 --- a/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Queries; -/// +/// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { private readonly IEnumerable _constraintProviders; @@ -34,15 +34,16 @@ public void Set(IncludeExpression include) // then as a fallback, we feed the requested includes from query string to the response serializer. // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true - _include = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + _include = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() .FirstOrDefault(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore _isAssigned = true; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index a8e87f5db6..4439e37c21 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume return null; } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) { return expression; } diff --git a/src/JsonApiDotNetCore/Queries/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs index 6dfa698044..c433f383da 100644 --- a/src/JsonApiDotNetCore/Queries/PaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Queries; -/// +/// internal sealed class PaginationContext : IPaginationContext { /// diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index fb4920d1a4..d7f80b8aa2 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Queries; -/// +/// [PublicAPI] public class QueryLayerComposer : IQueryLayerComposer { @@ -48,7 +48,7 @@ public QueryLayerComposer(IEnumerable constraintProvid ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true FilterExpression[] filtersInTopScope = constraints .Where(constraint => constraint.Scope == null) @@ -56,7 +56,7 @@ public QueryLayerComposer(IEnumerable constraintProvid .OfType() .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore return GetFilter(filtersInTopScope, primaryResourceType); @@ -83,7 +83,7 @@ public QueryLayerComposer(IEnumerable constraintProvid ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true FilterExpression[] filtersInSecondaryScope = constraints .Where(constraint => constraint.Scope == null) @@ -91,7 +91,7 @@ public QueryLayerComposer(IEnumerable constraintProvid .OfType() .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); @@ -146,14 +146,14 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true QueryExpression[] expressionsInTopScope = constraints .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); @@ -174,7 +174,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraint.Scope == null) @@ -182,7 +182,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection() .FirstOrDefault() ?? IncludeExpression.Empty; - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore IImmutableSet includeElements = ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); @@ -212,15 +212,14 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< }; // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true QueryExpression[] expressionsInCurrentScope = constraints - .Where(constraint => - constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) + .Where(constraint => constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) .Select(constraint => constraint.Expression) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore ResourceType resourceType = includeElement.Relationship.RightType; diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index bec8329c34..d693469bd3 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Queries.QueryableBuilding; -/// +/// [PublicAPI] public class QueryableBuilder : IQueryableBuilder { diff --git a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs index 57df16c62b..d812dc121b 100644 --- a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Queries; -/// +/// public sealed class SparseFieldSetCache : ISparseFieldSetCache { private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); @@ -29,7 +29,7 @@ public SparseFieldSetCache(IEnumerable constraintProvi private static IDictionary> BuildSourceTable(IEnumerable constraintProviders) { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true KeyValuePair[] sparseFieldTables = constraintProviders .SelectMany(provider => provider.GetConstraints()) @@ -40,7 +40,7 @@ private static IDictionary> .SelectMany(table => table) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore var mergedTable = new Dictionary.Builder>(); diff --git a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs index b5fde463ee..de144de7c5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -47,10 +47,14 @@ public virtual void Read(string parameterName, StringValues parameterValue) { try { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute _includeExpression = GetInclude(parameterValue.ToString()); } catch (QueryParseException exception) { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute string specificMessage = exception.GetMessageWithPosition(parameterValue.ToString()); throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", specificMessage, exception); } diff --git a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs index 86feed30bf..93e180275a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -58,6 +58,8 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue.ToString()); if (constraint.Elements.Any(element => element.Scope == null)) @@ -80,6 +82,8 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute string specificMessage = exception.GetMessageWithPosition(isParameterNameValid ? parameterValue.ToString() : parameterName); throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", specificMessage, exception); } diff --git a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs index ceb58df45b..c412d03c94 100644 --- a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.QueryStrings; -/// +/// public sealed class QueryStringReader : IQueryStringReader { private readonly IJsonApiOptions _options; diff --git a/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs index 7b4351aece..00c0bfb69f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.QueryStrings; -/// +/// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; diff --git a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index 816d9e7f3f..c881f04dc4 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.QueryStrings; -/// +/// [PublicAPI] public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader { diff --git a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs index 9fb98581de..542d69b8ec 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -59,12 +59,16 @@ public virtual void Read(string parameterName, StringValues parameterValue) ResourceFieldChainExpression? scope = GetScope(parameterName); parameterNameIsValid = true; + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute SortExpression sort = GetSort(parameterValue.ToString(), scope); var expressionInScope = new ExpressionInScope(scope, sort); _constraints.Add(expressionInScope); } catch (QueryParseException exception) { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", specificMessage, exception); } diff --git a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index d79e9c98ed..9757383c39 100644 --- a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -63,11 +63,15 @@ public virtual void Read(string parameterName, StringValues parameterValue) ResourceType resourceType = GetScope(parameterName); parameterNameIsValid = true; + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), resourceType); _sparseFieldTableBuilder[resourceType] = sparseFieldSet; } catch (QueryParseException exception) { + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-493256/Incorrect-possible-null-assignment + // ReSharper disable once AssignNullToNotNullAttribute string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", specificMessage, exception); } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index 99decdec66..794eecc6f2 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Repositories; -/// +/// [PublicAPI] public sealed class DbContextResolver : IDbContextResolver where TDbContext : DbContext diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index ca30c222c9..4c3314ddab 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -119,7 +119,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) IQueryable source = GetAll(); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true QueryableHandlerExpression[] queryableHandlers = _constraintProviders .SelectMany(provider => provider.GetConstraints()) @@ -128,7 +128,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) .OfType() .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore foreach (QueryableHandlerExpression queryableHandler in queryableHandlers) @@ -470,14 +470,14 @@ private IEnumerable GetRightValueToStoreForAddToToMany(TResource leftResource, H object? rightValueStored = relationship.GetValue(leftResource); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true HashSet rightResourceIdsStored = _collectionConverter .ExtractResources(rightValueStored) .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) .ToHashSet(IdentifiableComparer.Instance); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore if (rightResourceIdsStored.Any()) @@ -519,7 +519,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour object? rightValueStored = relationship.GetValue(leftResourceTracked); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true IIdentifiable[] rightResourceIdsStored = _collectionConverter .ExtractResources(rightValueStored) @@ -527,7 +527,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 97fcd5ff58..6370ddb883 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Repositories; -/// +/// [PublicAPI] public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 08d6537cbc..f6653b9bdb 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public class JsonApiResourceDefinition : IResourceDefinition where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index bcb9648320..7eb57813ca 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 4ebe5cf453..93b71a5546 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public class ResourceDefinitionAccessor : IResourceDefinitionAccessor { @@ -17,6 +17,7 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor private readonly IServiceProvider _serviceProvider; /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] public bool IsReadOnlyRequest { get @@ -27,6 +28,7 @@ public bool IsReadOnlyRequest } /// + [Obsolete("Use injected IQueryableBuilder instead.")] public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 80aaa2c328..b803544bec 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Resources; -/// +/// internal sealed class ResourceFactory : IResourceFactory { private static readonly TypeLocator TypeLocator = new(); diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 4d40fc240d..cb3a0874e0 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public sealed class TargetedFields : ITargetedFields { diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index b842cace0e..ccf33fcd2f 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -12,11 +12,13 @@ namespace JsonApiDotNetCore.Serialization.JsonConverters; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class SingleOrManyDataConverterFactory : JsonConverterFactory { + /// public override bool CanConvert(Type typeToConvert) { return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(SingleOrManyData<>); } + /// public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { Type objectType = typeToConvert.GetGenericArguments()[0]; diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs index a48b8bd4be..2597afacac 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs @@ -19,6 +19,9 @@ public sealed class WriteOnlyDocumentConverter : JsonObjectConverter private static readonly JsonEncodedText IncludedText = JsonEncodedText.Encode("included"); private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + /// + /// Always throws a . This converter is write-only. + /// public override Document Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotSupportedException("This converter cannot be used for reading JSON."); diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs index b740642868..e34fc7636f 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs @@ -14,6 +14,9 @@ public sealed class WriteOnlyRelationshipObjectConverter : JsonObjectConverter + /// Always throws a . This converter is write-only. + /// public override RelationshipObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotSupportedException("This converter cannot be used for reading JSON."); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index 071b8d309e..d0574f2fa1 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; -/// +/// public sealed class AtomicOperationObjectAdapter : IAtomicOperationObjectAdapter { private readonly IResourceDataInOperationsRequestAdapter _resourceDataInOperationsRequestAdapter; @@ -122,13 +122,10 @@ private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOper private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; - return new ResourceIdentityRequirements { - IdConstraint = idConstraint + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) }; } @@ -137,7 +134,7 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen return new ResourceIdentityRequirements { ResourceType = refResult.ResourceType, - IdConstraint = refRequirements.IdConstraint, + EvaluateIdConstraint = refRequirements.EvaluateIdConstraint, IdValue = refResult.Resource.StringId, LidValue = refResult.Resource.LocalId, RelationshipName = refResult.Relationship?.PublicName diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs index 43e4a7a78b..ec9842b271 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; -/// +/// public sealed class DocumentAdapter : IDocumentAdapter { private readonly IJsonApiRequest _request; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index 38a8ce0a29..8a031b692e 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; -/// +/// public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentInResourceOrRelationshipRequestAdapter { private readonly IJsonApiOptions _options; @@ -60,14 +60,11 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; - var requirements = new ResourceIdentityRequirements { ResourceType = state.Request.PrimaryResourceType, - IdConstraint = idConstraint, + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration), IdValue = state.Request.PrimaryId }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 0e90f7df07..5925524e6b 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -70,7 +70,7 @@ private static SingleOrManyData ToIdentifierData(Singl var requirements = new ResourceIdentityRequirements { ResourceType = relationship.RightType, - IdConstraint = JsonElementConstraint.Required, + EvaluateIdConstraint = _ => JsonElementConstraint.Required, RelationshipName = relationship.PublicName }; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 6964427680..be596dae7c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory ArgumentGuard.NotNull(state); ResourceType resourceType = ResolveType(identity, requirements, state); - IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType, state); return (resource, resourceType); } @@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource } } - private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state) + private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, + RequestAdapterState state) { if (state.Request.Kind != EndpointKind.AtomicOperations) { @@ -102,11 +103,13 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertNoIdWithLid(identity, state); - if (requirements.IdConstraint == JsonElementConstraint.Required) + JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType); + + if (idConstraint == JsonElementConstraint.Required) { AssertHasIdOrLid(identity, requirements, state); } - else if (requirements.IdConstraint == JsonElementConstraint.Forbidden) + else if (idConstraint == JsonElementConstraint.Forbidden) { AssertHasNoId(identity, state); } @@ -114,7 +117,7 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertSameIdValue(identity, requirements.IdValue, state); AssertSameLidValue(identity, requirements.LidValue, state); - IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); + IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType); AssignStringId(identity, resource, state); resource.LocalId = identity.Lid; return resource; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index d5498397bf..0d26b807d6 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -1,5 +1,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Request.Adapters; @@ -16,9 +17,9 @@ public sealed class ResourceIdentityRequirements public ResourceType? ResourceType { get; init; } /// - /// When not null, indicates the presence or absence of the "id" element. + /// When not null, provides a callback to indicate the presence or absence of the "id" element. /// - public JsonElementConstraint? IdConstraint { get; init; } + public Func? EvaluateIdConstraint { get; init; } /// /// When not null, indicates what the value of the "id" element must be. @@ -34,4 +35,19 @@ public sealed class ResourceIdentityRequirements /// When not null, indicates the name of the relationship to use in error messages. /// public string? RelationshipName { get; init; } + + internal static JsonElementConstraint? DoEvaluateIdConstraint(ResourceType resourceType, WriteOperationKind? writeOperation, + ClientIdGenerationMode globalClientIdGeneration) + { + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? globalClientIdGeneration; + + return writeOperation == WriteOperationKind.CreateResource + ? clientIdGeneration switch + { + ClientIdGenerationMode.Required => JsonElementConstraint.Required, + ClientIdGenerationMode.Forbidden => JsonElementConstraint.Forbidden, + _ => null + } + : JsonElementConstraint.Required; + } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs index 30e4508894..4bd3741b7e 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -16,7 +16,7 @@ namespace JsonApiDotNetCore.Serialization.Request; -/// +/// public sealed class JsonApiReader : IJsonApiReader { private readonly IJsonApiOptions _options; @@ -40,8 +40,9 @@ public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ArgumentGuard.NotNull(httpRequest); string requestBody = await ReceiveRequestBodyAsync(httpRequest); + string method = httpRequest.Method.Replace(Environment.NewLine, ""); - _traceWriter.LogMessage(() => $"Received {httpRequest.Method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); + _traceWriter.LogMessage(() => $"Received {method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); return GetModel(requestBody); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs index 6fc6b7af61..7a22cf5a60 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// internal sealed class ETagGenerator : IETagGenerator { private readonly IFingerprintGenerator _fingerprintGenerator; diff --git a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs index dfdb20bde1..0f4032edd1 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -1,6 +1,6 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// public sealed class EmptyResponseMeta : IResponseMeta { /// diff --git a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs index 5f9f7eeb6c..e0b3e56b10 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// internal sealed class FingerprintGenerator : IFingerprintGenerator { private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 27923f6f62..22de5284a2 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// public sealed class JsonApiWriter : IJsonApiWriter { private readonly IJsonApiRequest _request; @@ -63,7 +63,12 @@ public async Task WriteAsync(object? model, HttpContext httpContext) } _traceWriter.LogMessage(() => - $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{responseBody}>>"); + { + string method = httpContext.Request.Method.Replace(Environment.NewLine, ""); + string url = httpContext.Request.GetEncodedUrl(); + + return $"Sending {httpContext.Response.StatusCode} response for {method} request at '{url}' with body: <<{responseBody}>>"; + }); await SendResponseBodyAsync(httpContext.Response, responseBody); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 4552e46e5b..c085507365 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -15,6 +15,7 @@ namespace JsonApiDotNetCore.Serialization.Response; +/// [PublicAPI] public class LinkBuilder : ILinkBuilder { diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index 59057c266b..3d1eb5dd26 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// [PublicAPI] public sealed class MetaBuilder : IMetaBuilder { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 6226d6e597..901c1d94fc 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Serialization.Response; internal sealed class ResourceObjectTreeNode : IEquatable { // Placeholder root node for the tree, which is never emitted itself. - private static readonly ResourceType RootType = new("(root)", typeof(object), typeof(object)); + private static readonly ResourceType RootType = new("(root)", ClientIdGenerationMode.Forbidden, typeof(object), typeof(object)); private static readonly IIdentifiable RootResource = new EmptyResource(); // Direct children from root. These are emitted in 'data'. diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index ceba6d2285..4bd6844335 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -14,7 +14,7 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// [PublicAPI] public class ResponseModelAdapter : IResponseModelAdapter { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0d7280e1b4..5984b6215b 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Services; -/// +/// [PublicAPI] public class JsonApiResourceService : IResourceService where TResource : class, IIdentifiable diff --git a/test/AnnotationTests/AnnotationTests.csproj b/test/AnnotationTests/AnnotationTests.csproj index 7b221a9a42..b712b1bb67 100644 --- a/test/AnnotationTests/AnnotationTests.csproj +++ b/test/AnnotationTests/AnnotationTests.csproj @@ -1,13 +1,9 @@ - $(TargetFrameworkName);netstandard1.0 + $(TargetFrameworkName);netstandard2.0 latest - - - - diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs index 9002773680..269758fef6 100644 --- a/test/AnnotationTests/Models/TreeNode.cs +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,8 @@ namespace AnnotationTests.Models; [PublicAPI] -[Resource(PublicName = "tree-node", ControllerNamespace = "Models", GenerateControllerEndpoints = JsonApiEndpoints.Query)] +[Resource(PublicName = "tree-node", ClientIdGeneration = ClientIdGenerationMode.Required, ControllerNamespace = "Models", + GenerateControllerEndpoints = JsonApiEndpoints.Query)] public sealed class TreeNode : Identifiable { [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)] diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index abbec3ed98..a09e322203 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -10,7 +10,7 @@ + - diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3396aed54b..78c4213e93 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,7 +1,5 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; @@ -10,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Moq; using TestBuildingBlocks; using Xunit; @@ -18,45 +15,26 @@ namespace DiscoveryTests; public sealed class ServiceDiscoveryFacadeTests { - private static readonly ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; - private readonly IServiceCollection _services = new ServiceCollection(); - private readonly ResourceGraphBuilder _resourceGraphBuilder; + private readonly ServiceCollection _services = new(); public ServiceDiscoveryFacadeTests() { - var dbResolverMock = new Mock(); - dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); - _services.AddScoped(_ => dbResolverMock.Object); - - IJsonApiOptions options = new JsonApiOptions(); - - _services.AddSingleton(options); - _services.AddSingleton(LoggerFactory); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - - _resourceGraphBuilder = new ResourceGraphBuilder(options, LoggerFactory); + _services.AddSingleton(_ => NullLoggerFactory.Instance); + _services.AddScoped(_ => new FakeDbContextResolver()); } [Fact] public void Can_add_resources_from_assembly_to_graph() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddAssembly(typeof(Person).Assembly); + Action addAction = facade => facade.AddAssembly(typeof(Person).Assembly); // Act - facade.DiscoverResources(); + _services.AddJsonApi(discovery: facade => addAction(facade)); // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceGraph = serviceProvider.GetRequiredService(); ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); personType.ShouldNotBeNull(); @@ -69,33 +47,32 @@ public void Can_add_resources_from_assembly_to_graph() public void Can_add_resource_from_current_assembly_to_graph() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); + Action addAction = facade => facade.AddCurrentAssembly(); // Act - facade.DiscoverResources(); + _services.AddJsonApi(discovery: facade => addAction(facade)); // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceGraph = serviceProvider.GetRequiredService(); - ResourceType? testResourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); - testResourceType.ShouldNotBeNull(); + ResourceType? resourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); + resourceType.ShouldNotBeNull(); } [Fact] public void Can_add_resource_service_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); + Action addAction = facade => facade.AddCurrentAssembly(); // Act - facade.DiscoverInjectables(); + _services.AddJsonApi(discovery: facade => addAction(facade)); // Assert - ServiceProvider services = _services.BuildServiceProvider(); + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceService = serviceProvider.GetRequiredService>(); - var resourceService = services.GetRequiredService>(); resourceService.Should().BeOfType(); } @@ -103,16 +80,15 @@ public void Can_add_resource_service_from_current_assembly_to_container() public void Can_add_resource_repository_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); + Action addAction = facade => facade.AddCurrentAssembly(); // Act - facade.DiscoverInjectables(); + _services.AddJsonApi(discovery: facade => addAction(facade)); // Assert - ServiceProvider services = _services.BuildServiceProvider(); + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceRepository = serviceProvider.GetRequiredService>(); - var resourceRepository = services.GetRequiredService>(); resourceRepository.Should().BeOfType(); } @@ -120,16 +96,35 @@ public void Can_add_resource_repository_from_current_assembly_to_container() public void Can_add_resource_definition_from_current_assembly_to_container() { // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); + Action addAction = facade => facade.AddCurrentAssembly(); // Act - facade.DiscoverInjectables(); + _services.AddJsonApi(discovery: facade => addAction(facade)); // Assert - ServiceProvider services = _services.BuildServiceProvider(); + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceDefinition = serviceProvider.GetRequiredService>(); - var resourceDefinition = services.GetRequiredService>(); resourceDefinition.Should().BeOfType(); } + + private sealed class FakeDbContextResolver : IDbContextResolver + { + private readonly FakeDbContextOptions _dbContextOptions = new(); + + public DbContext GetContext() + { + return new DbContext(_dbContextOptions); + } + + private sealed class FakeDbContextOptions : DbContextOptions + { + public override Type ContextType => typeof(object); + + public override DbContextOptions WithExtension(TExtension extension) + { + return this; + } + } + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 4037f59b62..19a0208b17 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -71,7 +71,7 @@ private bool IsRequestingCollectionOfTelevisionBroadcasts() private bool IsIncludingCollectionOfTelevisionBroadcasts() { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_before_first_method_call true IncludeElementExpression[] includeElements = _constraintProviders .SelectMany(provider => provider.GetConstraints()) @@ -80,7 +80,7 @@ private bool IsIncludingCollectionOfTelevisionBroadcasts() .SelectMany(include => include.Elements) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_before_first_method_call restore // @formatter:wrap_chained_method_calls restore foreach (IncludeElementExpression includeElement in includeElements) @@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs index 6c3116d789..18b5536f36 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs @@ -1,38 +1,31 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; internal sealed class TelevisionFakers : FakerContainer { - private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); - private readonly Lazy> _lazyTelevisionStationFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyTelevisionStationFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); - private readonly Lazy> _lazyTelevisionBroadcastFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) - .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds()) - .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyTelevisionBroadcastFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) + .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds()) + .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset().TruncateToWholeMilliseconds())); - private readonly Lazy> _lazyBroadcastCommentFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) - .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyBroadcastCommentFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 5aff75498d..3331b1f1cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -931,7 +931,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true MusicTrack trackInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.Lyric) @@ -939,7 +939,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(musicTrack => musicTrack.Performers) .FirstWithIdAsync(newTrackId); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore trackInDatabase.Title.Should().Be(newTitle); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 6e9a21d773..15dbc19b07 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -33,7 +33,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Required; } [Fact] @@ -139,6 +139,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_for_missing_client_generated_ID() + { + // Arrange + string? newIsoCode = _fakers.TextLanguage.Generate().IsoCode; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + attributes = new + { + isoCode = newIsoCode + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_resource_for_existing_client_generated_ID() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 0112dc9ed4..5d138404ba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -241,14 +241,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true List tracksInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.OwnedBy) .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) .ToListAsync(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore tracksInDatabase.ShouldHaveCount(elementCount); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 39768280bd..df5bc5f90b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -639,14 +639,14 @@ public async Task Can_update_resource_with_relationships_using_local_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true MusicTrack trackInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.OwnedBy) .Include(musicTrack => musicTrack.Performers) .FirstWithIdAsync(newTrackId); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore trackInDatabase.Title.Should().Be(newTrackTitle); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index fe94e8f073..0dcc99fdcd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -31,7 +31,7 @@ public AtomicSerializationTests(IntegrationTestContext(); options.IncludeExceptionStackTraceInErrors = false; options.IncludeJsonApiVersion = true; - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Allowed; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index 8cf3c07e41..6666a4ffb9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -2,57 +2,48 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; internal sealed class OperationsFakers : FakerContainer { - private static readonly Lazy> LazyLanguageIsoCodes = - new(() => CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .Where(culture => !string.IsNullOrEmpty(culture.Name)) - .Select(culture => culture.Name) - .ToArray()); - - private readonly Lazy> _lazyPlaylistFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); - - private readonly Lazy> _lazyMusicTrackFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) - .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyLyricFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) - .RuleFor(lyric => lyric.Format, "LRC")); - - private readonly Lazy> _lazyTextLanguageFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); - - private readonly Lazy> _lazyPerformerFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) - .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyRecordCompanyFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) - .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); + private static readonly Lazy> LazyLanguageIsoCodes = new(() => CultureInfo + .GetCultures(CultureTypes.NeutralCultures) + .Where(culture => !string.IsNullOrEmpty(culture.Name)) + .Select(culture => culture.Name) + .ToArray()); + + private readonly Lazy> _lazyPlaylistFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); + + private readonly Lazy> _lazyMusicTrackFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) + .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyLyricFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) + .RuleFor(lyric => lyric.Format, "LRC")); + + private readonly Lazy> _lazyTextLanguageFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); + + private readonly Lazy> _lazyPerformerFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) + .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyRecordCompanyFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) + .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); public Faker Playlist => _lazyPlaylistFaker.Value; public Faker MusicTrack => _lazyMusicTrackFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 4b0a10fd82..895d1a0df5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -1742,7 +1742,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true MusicTrack trackInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.Lyric) @@ -1750,7 +1750,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(musicTrack => musicTrack.Performers) .FirstWithIdAsync(existingTrack.Id); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore trackInDatabase.Title.Should().Be(existingTrack.Title); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs new file mode 100644 index 0000000000..262fab70f1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Actor : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public DateTime BornAt { get; set; } + + [HasMany] + public ISet ActsIn { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs new file mode 100644 index 0000000000..3a99d3c015 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs @@ -0,0 +1,127 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal sealed class AuthScopeSet +{ + private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; + + public const string ScopesHeaderName = "X-Auth-Scopes"; + + private readonly Dictionary _scopes = new(); + + public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders) + { + var requestedScopes = new AuthScopeSet(); + + // In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header. + // For simplicity, this sample allows the client to send them directly, which is obviously insecure. + + if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue)) + { + foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions)) + { + string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions); + + if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission)) + { + requestedScopes.Include(scopeParts[1], permission); + } + } + } + + return requestedScopes; + } + + public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields) + { + Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write; + + if (request.PrimaryResourceType != null) + { + Include(request.PrimaryResourceType, permission); + } + + if (request.SecondaryResourceType != null) + { + Include(request.SecondaryResourceType, permission); + } + + if (request.Relationship != null) + { + Include(request.Relationship, permission); + } + + foreach (RelationshipAttribute relationship in targetedFields.Relationships) + { + Include(relationship, permission); + } + } + + public void Include(ResourceType resourceType, Permission permission) + { + Include(resourceType.PublicName, permission); + } + + public void Include(RelationshipAttribute relationship, Permission permission) + { + Include(relationship.LeftType, permission); + Include(relationship.RightType, permission); + } + + private void Include(string name, Permission permission) + { + // Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op. + + if (_scopes.TryGetValue(name, out Permission value)) + { + if (value >= permission) + { + return; + } + } + + _scopes[name] = permission; + } + + public bool ContainsAll(AuthScopeSet other) + { + foreach (string otherName in other._scopes.Keys) + { + if (!_scopes.TryGetValue(otherName, out Permission thisPermission)) + { + return false; + } + + if (thisPermission < other._scopes[otherName]) + { + return false; + } + } + + return true; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key)) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}"); + } + + return builder.ToString(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs new file mode 100644 index 0000000000..f5bcba8fe2 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Genre : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public ISet Movies { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs new file mode 100644 index 0000000000..4e52ff2728 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Movie : Identifiable +{ + [Attr] + public string Title { get; set; } = null!; + + [Attr] + public int ReleaseYear { get; set; } + + [Attr] + public int DurationInSeconds { get; set; } + + [HasOne] + public Genre Genre { get; set; } = null!; + + [HasMany] + public ISet Cast { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs new file mode 100644 index 0000000000..bfdf4aaa94 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs @@ -0,0 +1,53 @@ +using System.Net; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } + + public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + { + AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers); + AuthScopeSet requiredScopes = GetRequiredScopes(operations); + + if (!requestedScopes.ContainsAll(requiredScopes)) + { + return Error(new ErrorObject(HttpStatusCode.Unauthorized) + { + Title = "Insufficient permissions to perform this request.", + Detail = $"Performing this request requires the following scopes: {requiredScopes}.", + Source = new ErrorSource + { + Header = AuthScopeSet.ScopesHeaderName + } + }); + } + + return await base.PostOperationsAsync(operations, cancellationToken); + } + + private AuthScopeSet GetRequiredScopes(IEnumerable operations) + { + var requiredScopes = new AuthScopeSet(); + + foreach (OperationContainer operation in operations) + { + requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields); + } + + return requiredScopes; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs new file mode 100644 index 0000000000..5cca795950 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal enum Permission +{ + Read, + + // Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource. + Write +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs new file mode 100644 index 0000000000..57af5bce7a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs @@ -0,0 +1,466 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeOperationsTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeOperationsTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resources_without_scopes() + { + // Arrange + Genre newGenre = _fakers.Genre.Generate(); + Movie newMovie = _fakers.Movie.Generate(); + + const string genreLocalId = "genre-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "genres", + lid = genreLocalId, + attributes = new + { + name = newGenre.Name + } + } + }, + new + { + op = "add", + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + lid = genreLocalId + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_read_scope() + { + // Arrange + Genre newGenre = _fakers.Genre.Generate(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "genres", + attributes = new + { + name = newGenre.Name + } + } + } + } + }; + + const string route = "/operations"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resources_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + DateTime newBornAt = _fakers.Actor.Generate().BornAt; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + } + } + }, + new + { + op = "update", + data = new + { + type = "actors", + id = "1", + attributes = new + { + bornAt = newBornAt + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + }, + relationships = new + { + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_delete_resources_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "genres", + id = "1" + } + }, + new + { + op = "remove", + @ref = new + { + type = "actors", + id = "1" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "movies", + id = "1", + relationship = "genre" + }, + data = (object?)null + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs new file mode 100644 index 0000000000..98e5ccb7af --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs @@ -0,0 +1,343 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeReadTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeReadTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_get_primary_resources_without_scopes() + { + // Arrange + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_primary_resources_with_incorrect_scopes() + { + // Arrange + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:actors write:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Can_get_primary_resources_with_correct_scope() + { + // Arrange + Movie movie = _fakers.Movie.Generate(); + movie.Genre = _fakers.Genre.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Movies.Add(movie); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("movies"); + responseDocument.Data.ManyValue[0].Id.Should().Be(movie.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_write_scope() + { + // Arrange + Genre genre = _fakers.Genre.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Genres.Add(genre); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/genres"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "write:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("genres"); + responseDocument.Data.ManyValue[0].Id.Should().Be(genre.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_redundant_scopes() + { + // Arrange + Actor actor = _fakers.Actor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Actors.Add(actor); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/actors"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres read:actors write:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("actors"); + responseDocument.Data.ManyValue[0].Id.Should().Be(actor.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_without_scopes() + { + // Arrange + const string route = "/actors/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_secondary_resource_without_scopes() + { + // Arrange + const string route = "/movies/1/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_secondary_resources_without_scopes() + { + // Arrange + const string route = "/genres/1/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_ToOne_relationship_without_scopes() + { + // Arrange + const string route = "/movies/1/relationships/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_ToMany_relationship_without_scopes() + { + // Arrange + const string route = "/genres/1/relationships/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_include_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?include=genre,cast"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_filter_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?filter=and(has(cast),equals(genre.name,'some'))"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_sort_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?sort=count(cast),genre.name"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs new file mode 100644 index 0000000000..bb9d1d05cb --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs @@ -0,0 +1,434 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeWriteTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeWriteTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resource_without_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + } + } + }; + + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_without_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_with_read_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies read:genres read:actors"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + } + } + }; + + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_delete_resource_without_scopes() + { + // Arrange + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new + { + type = "genres", + id = "1" + } + }; + + const string route = "/movies/1/relationships/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs new file mode 100644 index 0000000000..a788b3e115 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -0,0 +1,104 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +// Implements IActionFilter instead of IAuthorizationFilter because it needs to execute *after* parsing query string parameters. +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class ScopesAuthorizationFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + var request = context.HttpContext.RequestServices.GetRequiredService(); + var targetedFields = context.HttpContext.RequestServices.GetRequiredService(); + var constraintProviders = context.HttpContext.RequestServices.GetRequiredService>(); + + if (request.Kind == EndpointKind.AtomicOperations) + { + // Handled in operators controller, because it requires access to the individual operations. + return; + } + + AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(context.HttpContext.Request.Headers); + AuthScopeSet requiredScopes = GetRequiredScopes(request, targetedFields, constraintProviders); + + if (!requestedScopes.ContainsAll(requiredScopes)) + { + context.Result = new UnauthorizedObjectResult(new ErrorObject(HttpStatusCode.Unauthorized) + { + Title = "Insufficient permissions to perform this request.", + Detail = $"Performing this request requires the following scopes: {requiredScopes}.", + Source = new ErrorSource + { + Header = AuthScopeSet.ScopesHeaderName + } + }); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + private AuthScopeSet GetRequiredScopes(IJsonApiRequest request, ITargetedFields targetedFields, IEnumerable constraintProviders) + { + var requiredScopes = new AuthScopeSet(); + requiredScopes.IncludeFrom(request, targetedFields); + + var walker = new QueryStringWalker(requiredScopes); + walker.IncludeScopesFrom(constraintProviders); + + return requiredScopes; + } + + private sealed class QueryStringWalker : QueryExpressionRewriter + { + private readonly AuthScopeSet _authScopeSet; + + public QueryStringWalker(AuthScopeSet authScopeSet) + { + _authScopeSet = authScopeSet; + } + + public void IncludeScopesFrom(IEnumerable constraintProviders) + { + foreach (ExpressionInScope constraint in constraintProviders.SelectMany(provider => provider.GetConstraints())) + { + Visit(constraint.Expression, null); + } + } + + public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, object? argument) + { + _authScopeSet.Include(expression.Relationship, Permission.Read); + + return base.VisitIncludeElement(expression, argument); + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is RelationshipAttribute relationship) + { + _authScopeSet.Include(relationship, Permission.Read); + } + else + { + _authScopeSet.Include(field.Type, Permission.Read); + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs new file mode 100644 index 0000000000..26ce29ff8f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ScopesDbContext : TestableDbContext +{ + public DbSet Movies => Set(); + public DbSet Actors => Set(); + public DbSet Genres => Set(); + + public ScopesDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs new file mode 100644 index 0000000000..3e89966d4d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs @@ -0,0 +1,29 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal sealed class ScopesFakers : FakerContainer +{ + private readonly Lazy> _lazyMovieFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(movie => movie.Title, faker => faker.Random.Words()) + .RuleFor(movie => movie.ReleaseYear, faker => faker.Random.Int(1900, 2050)) + .RuleFor(movie => movie.DurationInSeconds, faker => faker.Random.Int(300, 14400))); + + private readonly Lazy> _lazyActorFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(actor => actor.Name, faker => faker.Person.FullName) + .RuleFor(actor => actor.BornAt, faker => faker.Date.Past())); + + private readonly Lazy> _lazyGenreFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(genre => genre.Name, faker => faker.Random.Word())); + + public Faker Movie => _lazyMovieFaker.Value; + public Faker Actor => _lazyActorFaker.Value; + public Faker Genre => _lazyGenreFaker.Value; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs new file mode 100644 index 0000000000..0f6eaf4391 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ScopesStartup : TestableStartup + where TDbContext : TestableDbContext +{ + public override void ConfigureServices(IServiceCollection services) + { + IMvcCoreBuilder mvcBuilder = services.AddMvcCore(options => options.Filters.Add(int.MaxValue)); + + services.AddJsonApi(SetJsonApiOptions, mvcBuilder: mvcBuilder); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs index 924b67dfa3..22f5a20d36 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs @@ -1,19 +1,18 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Blobs; internal sealed class BlobFakers : FakerContainer { - private readonly Lazy> _lazyImageContainerFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(imageContainer => imageContainer.FileName, faker => faker.System.FileName()) - .RuleFor(imageContainer => imageContainer.Data, faker => faker.Random.Bytes(128)) - .RuleFor(imageContainer => imageContainer.Thumbnail, faker => faker.Random.Bytes(64))); + private readonly Lazy> _lazyImageContainerFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(imageContainer => imageContainer.FileName, faker => faker.System.FileName()) + .RuleFor(imageContainer => imageContainer.Data, faker => faker.Random.Bytes(128)) + .RuleFor(imageContainer => imageContainer.Thumbnail, faker => faker.Random.Bytes(64))); public Faker ImageContainer => _lazyImageContainerFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs index ce3a4455d1..e64e7bf8b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs @@ -1,28 +1,25 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; internal sealed class CompositeKeyFakers : FakerContainer { - private readonly Lazy> _lazyCarFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) - .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); + private readonly Lazy> _lazyCarFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) + .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); - private readonly Lazy> _lazyEngineFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); + private readonly Lazy> _lazyEngineFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); - private readonly Lazy> _lazyDealershipFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); + private readonly Lazy> _lazyDealershipFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); public Faker Car => _lazyCarFaker.Value; public Faker Engine => _lazyEngineFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 9e273be36e..3ff947d7b4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -27,9 +26,6 @@ public CompositeKeyTests(IntegrationTestContext>(); services.AddResourceRepository>(); }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs index d9416d99ab..5a0452010c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs @@ -1,24 +1,22 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; internal sealed class CustomRouteFakers : FakerContainer { - private readonly Lazy> _lazyTownFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(town => town.Name, faker => faker.Address.City()) - .RuleFor(town => town.Latitude, faker => faker.Address.Latitude()) - .RuleFor(town => town.Longitude, faker => faker.Address.Longitude())); + private readonly Lazy> _lazyTownFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(town => town.Name, faker => faker.Address.City()) + .RuleFor(town => town.Latitude, faker => faker.Address.Latitude()) + .RuleFor(town => town.Longitude, faker => faker.Address.Longitude())); - private readonly Lazy> _lazyCivilianFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); + private readonly Lazy> _lazyCivilianFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); public Faker Town => _lazyTownFaker.Value; public Faker Civilian => _lazyCivilianFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs index 0ac7eb6804..77e25a8192 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs @@ -1,43 +1,37 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; internal sealed class EagerLoadingFakers : FakerContainer { - private readonly Lazy> _lazyStateFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(state => state.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyCityFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(city => city.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyStreetFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(street => street.Name, faker => faker.Address.StreetName())); - - private readonly Lazy> _lazyBuildingFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); - - private readonly Lazy> _lazyWindowFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(window => window.HeightInCentimeters, faker => faker.Random.Number(30, 199)) - .RuleFor(window => window.WidthInCentimeters, faker => faker.Random.Number(30, 199))); - - private readonly Lazy> _lazyDoorFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(door => door.Color, faker => faker.Commerce.Color())); + private readonly Lazy> _lazyStateFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(state => state.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyCityFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(city => city.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyStreetFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(street => street.Name, faker => faker.Address.StreetName())); + + private readonly Lazy> _lazyBuildingFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); + + private readonly Lazy> _lazyWindowFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(window => window.HeightInCentimeters, faker => faker.Random.Number(30, 199)) + .RuleFor(window => window.WidthInCentimeters, faker => faker.Random.Number(30, 199))); + + private readonly Lazy> _lazyDoorFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(door => door.Color, faker => faker.Commerce.Color())); public Faker State => _lazyStateFaker.Value; public Faker City => _lazyCityFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index b65d9765f7..5d2ddf0ece 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -245,7 +245,7 @@ public async Task Can_create_resource() await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) @@ -253,7 +253,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(building => building.Windows) .FirstWithIdOrDefaultAsync(newBuildingId); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore buildingInDatabase.ShouldNotBeNull(); @@ -310,7 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) @@ -318,7 +318,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(building => building.Windows) .FirstWithIdOrDefaultAsync(existingBuilding.Id); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore buildingInDatabase.ShouldNotBeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs index 93734a6f08..33bd79ba9d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs @@ -1,22 +1,20 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; internal sealed class HostingFakers : FakerContainer { - private readonly Lazy> _lazyArtGalleryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); + private readonly Lazy> _lazyArtGalleryFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); - private readonly Lazy> _lazyPaintingFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); + private readonly Lazy> _lazyPaintingFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); public Faker ArtGallery => _lazyArtGalleryFaker.Value; public Faker Painting => _lazyPaintingFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs index 201089d15b..2755d1ebcb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -1,23 +1,21 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; internal sealed class ObfuscationFakers : FakerContainer { - private readonly Lazy> _lazyBankAccountFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); + private readonly Lazy> _lazyBankAccountFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); - private readonly Lazy> _lazyDebitCardFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(debitCard => debitCard.OwnerName, faker => faker.Name.FullName()) - .RuleFor(debitCard => debitCard.PinCode, faker => (short)faker.Random.Number(1000, 9999))); + private readonly Lazy> _lazyDebitCardFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(debitCard => debitCard.OwnerName, faker => faker.Name.FullName()) + .RuleFor(debitCard => debitCard.PinCode, faker => (short)faker.Random.Number(1000, 9999))); public Faker BankAccount => _lazyBankAccountFaker.Value; public Faker DebitCard => _lazyDebitCardFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs index a16e9a97b7..1939a168dc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -2,8 +2,8 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; @@ -15,25 +15,22 @@ internal sealed class ModelStateFakers : FakerContainer private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture); private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture); - private readonly Lazy> _lazySystemVolumeFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); - - private readonly Lazy> _lazySystemFileFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) - .RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly)) - .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)) - .RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn)) - .RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt))); - - private readonly Lazy> _lazySystemDirectoryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemDirectory => systemDirectory.Name, faker => Path.GetFileNameWithoutExtension(faker.System.FileName())) - .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool())); + private readonly Lazy> _lazySystemVolumeFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazySystemFileFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) + .RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly)) + .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)) + .RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn)) + .RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt))); + + private readonly Lazy> _lazySystemDirectoryFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemDirectory => systemDirectory.Name, faker => Path.GetFileNameWithoutExtension(faker.System.FileName())) + .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool())); public Faker SystemVolume => _lazySystemVolumeFaker.Value; public Faker SystemFile => _lazySystemFileFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs index bd34d85cf5..a1acafeb7d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs @@ -1,29 +1,26 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Links; internal sealed class LinksFakers : FakerContainer { - private readonly Lazy> _lazyPhotoAlbumFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); + private readonly Lazy> _lazyPhotoAlbumFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); - private readonly Lazy> _lazyPhotoFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photo => photo.Url, faker => faker.Image.PlaceImgUrl())); + private readonly Lazy> _lazyPhotoFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photo => photo.Url, faker => faker.Image.PlaceImgUrl())); - private readonly Lazy> _lazyPhotoLocationFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoLocation => photoLocation.PlaceName, faker => faker.Address.FullAddress()) - .RuleFor(photoLocation => photoLocation.Latitude, faker => faker.Address.Latitude()) - .RuleFor(photoLocation => photoLocation.Longitude, faker => faker.Address.Longitude())); + private readonly Lazy> _lazyPhotoLocationFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoLocation => photoLocation.PlaceName, faker => faker.Address.FullAddress()) + .RuleFor(photoLocation => photoLocation.Latitude, faker => faker.Address.Latitude()) + .RuleFor(photoLocation => photoLocation.Longitude, faker => faker.Address.Longitude())); public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; public Faker Photo => _lazyPhotoFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index 1c467dc782..5d2b25a74c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -1,19 +1,17 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; internal sealed class LoggingFakers : FakerContainer { - private readonly Lazy> _lazyAuditEntryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) - .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyAuditEntryFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) + .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); public Faker AuditEntry => _lazyAuditEntryFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index 726dd82923..c6cd2b5859 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs @@ -1,22 +1,20 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; internal sealed class MetaFakers : FakerContainer { - private readonly Lazy> _lazyProductFamilyFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); + private readonly Lazy> _lazyProductFamilyFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); - private readonly Lazy> _lazySupportTicketFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); + private readonly Lazy> _lazySupportTicketFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); public Faker ProductFamily => _lazyProductFamilyFaker.Value; public Faker SupportTicket => _lazySupportTicketFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs index 8109dadf31..54d3812b97 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs @@ -1,23 +1,21 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; internal sealed class DomainFakers : FakerContainer { - private readonly Lazy> _lazyDomainUserFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) - .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); + private readonly Lazy> _lazyDomainUserFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) + .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); - private readonly Lazy> _lazyDomainGroupFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); + private readonly Lazy> _lazyDomainGroupFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); public Faker DomainUser => _lazyDomainUserFaker.Value; public Faker DomainGroup => _lazyDomainGroupFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs index 564985f107..e92f23808f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs @@ -1,23 +1,21 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; internal sealed class MultiTenancyFakers : FakerContainer { - private readonly Lazy> _lazyWebShopFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); + private readonly Lazy> _lazyWebShopFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); - private readonly Lazy> _lazyWebProductFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) - .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); + private readonly Lazy> _lazyWebProductFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) + .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); public Faker WebShop => _lazyWebShopFaker.Value; public Faker WebProduct => _lazyWebProductFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs index e60dbdf525..dee6e4b19f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs @@ -1,27 +1,24 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; internal sealed class NamingFakers : FakerContainer { - private readonly Lazy> _lazySwimmingPoolFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); + private readonly Lazy> _lazySwimmingPoolFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); - private readonly Lazy> _lazyWaterSlideFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(waterSlide => waterSlide.LengthInMeters, faker => faker.Random.Decimal(3, 100))); + private readonly Lazy> _lazyWaterSlideFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(waterSlide => waterSlide.LengthInMeters, faker => faker.Random.Decimal(3, 100))); - private readonly Lazy> _lazyDivingBoardFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(divingBoard => divingBoard.HeightInMeters, faker => faker.Random.Decimal(1, 15))); + private readonly Lazy> _lazyDivingBoardFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(divingBoard => divingBoard.HeightInMeters, faker => faker.Random.Decimal(1, 15))); public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; public Faker WaterSlide => _lazyWaterSlideFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 4afd53e405..849e53b720 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -1,95 +1,79 @@ using Bogus; using TestBuildingBlocks; -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; internal sealed class QueryStringFakers : FakerContainer { - private readonly Lazy> _lazyBlogFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(blog => blog.Title, faker => faker.Lorem.Word()) - .RuleFor(blog => blog.PlatformName, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyBlogFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(blog => blog.Title, faker => faker.Lorem.Word()) + .RuleFor(blog => blog.PlatformName, faker => faker.Company.CompanyName())); - private readonly Lazy> _lazyBlogPostFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(blogPost => blogPost.Caption, faker => faker.Lorem.Sentence()) - .RuleFor(blogPost => blogPost.Url, faker => faker.Internet.Url())); + private readonly Lazy> _lazyBlogPostFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(blogPost => blogPost.Caption, faker => faker.Lorem.Sentence()) + .RuleFor(blogPost => blogPost.Url, faker => faker.Internet.Url())); - private readonly Lazy> _lazyLabelFaker = new(() => - new Faker